Files
football2801 2a8bf3895f
CI / test (push) Has been cancelled
chore(dev): DDEV setup so the test suite actually runs
Mirrors aqua-iq's pattern but adapted for pictureFrame's stack:
postgres 16, php 8.4, node 22, imagick + pcov via apt extras,
Mercure hub at https://pictureframe.ddev.site/.well-known/mercure,
and four custom commands — `ddev tests`, `ddev coverage`,
`ddev frontend` (vite HMR), `ddev worker`.

Also restores dev deps (DAMA, Doctrine fixtures, symfony/uid) that
got dropped during earlier composer reshuffles, and adds a separate
`db_test` connection in .env.test so DAMA's transactional isolation
doesn't share state with whatever dev is mid-experiment with.

Two test fixes the new env exposed:
  - RotationServiceTest::test_prioritize_never_shown_falls_through_when_all_shown
    needed uniquenessWindow=2 so the recent-window filter wipes the
    set and the fallback restores the full pool — otherwise window=1
    excluded the most-recently-served image and the assertion drifted.
  - DeviceImageControllerTest::test_locked_image_served_without_rotation_advance
    was asserting currentImage stays null on a lock poll, but the
    controller intentionally sets currentImage on the lock path so
    Home reflects the live frame state. Now asserts both the
    currentImage update AND that no DeviceImageHistory row was
    written (the actual rotation-bypass guarantee).

Backend coverage (full suite via `ddev coverage`): 89.08% lines /
92.24% methods / 74.36% classes — the first real number we've had.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 13:56:36 -04:00

510 lines
20 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Entity\Device;
use App\Entity\DeviceImageHistory;
use App\Entity\Image;
use App\Entity\RenderedAsset;
use App\Enum\DeviceModel;
use App\Enum\Orientation;
use App\Enum\RenderStatus;
use App\Enum\RotationMode;
use App\Service\RotationService;
use App\Tests\AppKernelTestCase;
/**
* Integration tests for RotationService (requires real DB + dama rollback).
*/
class RotationServiceTest extends AppKernelTestCase
{
private RotationService $rotation;
protected function setUp(): void
{
parent::setUp();
$this->rotation = static::getContainer()->get(RotationService::class);
}
/**
* Creates a User+Device approved for a ready Image with V1/Landscape asset.
* Returns [Device, Image].
*/
private function createDeviceWithReadyImage(
string $email = 'test@example.com',
RenderStatus $status = RenderStatus::Ready,
bool $approveForDevice = true,
?\DateTimeImmutable $uploadedAt = null,
): array {
$user = $this->createUser($email);
$device = new Device();
$device->setMac(strtoupper(bin2hex(random_bytes(3)) . ':' . bin2hex(random_bytes(3)) . ':AA'));
$device->setName('Test Device');
$device->setUser($user);
$device->setModel(DeviceModel::V1);
$device->setOrientation(Orientation::Landscape);
self::em()->persist($device);
$image = new Image();
$image->setUser($user);
$image->setOriginalFilename('test.jpg');
$image->setStoragePath('var/storage/images/test/original.jpg');
if ($uploadedAt !== null) {
// Use reflection to set uploadedAt since it's set in constructor
$ref = new \ReflectionProperty(Image::class, 'uploadedAt');
$ref->setAccessible(true);
$ref->setValue($image, $uploadedAt);
}
if ($approveForDevice) {
$image->approveForDevice($device);
}
self::em()->persist($image);
$asset = (new RenderedAsset())
->setImage($image)
->setDeviceModel(DeviceModel::V1)
->setOrientation(Orientation::Landscape)
->setStatus($status)
->setFilePath('var/storage/images/test/v1_landscape.bin');
self::em()->persist($asset);
self::em()->flush();
return [$device, $image];
}
public function test_advance_returns_null_when_pool_empty(): void
{
$user = $this->createUser('empty@example.com');
$device = new Device();
$device->setMac('AA:BB:CC:DD:EE:FF');
$device->setName('Empty Device');
$device->setUser($user);
self::em()->persist($device);
self::em()->flush();
$result = $this->rotation->advance($device);
$this->assertNull($result);
}
public function test_advance_picks_oldest_image(): void
{
$user = $this->createUser('oldest@example.com');
$device = new Device();
$device->setMac('AA:BB:CC:DD:EE:01');
$device->setName('Device');
$device->setUser($user);
self::em()->persist($device);
$older = new Image();
$older->setUser($user)->setOriginalFilename('old.jpg')->setStoragePath('x');
$ref = new \ReflectionProperty(Image::class, 'uploadedAt');
$ref->setAccessible(true);
$ref->setValue($older, new \DateTimeImmutable('2024-01-01'));
$older->approveForDevice($device);
self::em()->persist($older);
$assetOld = (new RenderedAsset())->setImage($older)->setDeviceModel(DeviceModel::V1)
->setOrientation(Orientation::Landscape)->setStatus(RenderStatus::Ready)->setFilePath('x/old.bin');
self::em()->persist($assetOld);
$newer = new Image();
$newer->setUser($user)->setOriginalFilename('new.jpg')->setStoragePath('y');
$ref->setValue($newer, new \DateTimeImmutable('2024-06-01'));
$newer->approveForDevice($device);
self::em()->persist($newer);
$assetNew = (new RenderedAsset())->setImage($newer)->setDeviceModel(DeviceModel::V1)
->setOrientation(Orientation::Landscape)->setStatus(RenderStatus::Ready)->setFilePath('y/new.bin');
self::em()->persist($assetNew);
self::em()->flush();
$result = $this->rotation->advance($device);
$this->assertNotNull($result);
$this->assertSame($older->getId(), $result->getId());
}
public function test_advance_skips_recently_shown(): void
{
[$device, $imageA] = $this->createDeviceWithReadyImage('skip@example.com');
$imageB = new Image();
$imageB->setUser($device->getUser())->setOriginalFilename('b.jpg')->setStoragePath('b');
$imageB->approveForDevice($device);
self::em()->persist($imageB);
$assetB = (new RenderedAsset())->setImage($imageB)->setDeviceModel(DeviceModel::V1)
->setOrientation(Orientation::Landscape)->setStatus(RenderStatus::Ready)->setFilePath('b.bin');
self::em()->persist($assetB);
// Put imageA in history
$history = new DeviceImageHistory($device, $imageA);
self::em()->persist($history);
$device->setUniquenessWindow(2);
self::em()->flush();
$result = $this->rotation->advance($device);
$this->assertNotNull($result);
$this->assertSame($imageB->getId(), $result->getId());
}
public function test_advance_resets_when_all_in_window(): void
{
[$device, $imageA] = $this->createDeviceWithReadyImage('reset@example.com');
$imageB = new Image();
$imageB->setUser($device->getUser())->setOriginalFilename('b.jpg')->setStoragePath('b');
$imageB->approveForDevice($device);
self::em()->persist($imageB);
$assetB = (new RenderedAsset())->setImage($imageB)->setDeviceModel(DeviceModel::V1)
->setOrientation(Orientation::Landscape)->setStatus(RenderStatus::Ready)->setFilePath('b.bin');
self::em()->persist($assetB);
$histA = new DeviceImageHistory($device, $imageA);
$histB = new DeviceImageHistory($device, $imageB);
self::em()->persist($histA);
self::em()->persist($histB);
$device->setUniquenessWindow(2);
self::em()->flush();
$result = $this->rotation->advance($device);
$this->assertNotNull($result);
}
public function test_advance_respects_uniqueness_window(): void
{
$user = $this->createUser('window@example.com');
$device = new Device();
$device->setMac('AA:BB:CC:DD:EE:02');
$device->setName('Window Device');
$device->setUser($user);
$device->setUniquenessWindow(1);
self::em()->persist($device);
$images = [];
for ($i = 0; $i < 3; $i++) {
$img = new Image();
$img->setUser($user)->setOriginalFilename("img{$i}.jpg")->setStoragePath("p{$i}");
$ref = new \ReflectionProperty(Image::class, 'uploadedAt');
$ref->setAccessible(true);
$ref->setValue($img, new \DateTimeImmutable("2024-01-0" . ($i + 1)));
$img->approveForDevice($device);
self::em()->persist($img);
$asset = (new RenderedAsset())->setImage($img)->setDeviceModel(DeviceModel::V1)
->setOrientation(Orientation::Landscape)->setStatus(RenderStatus::Ready)->setFilePath("p{$i}.bin");
self::em()->persist($asset);
$images[] = $img;
}
// Put image[0] in history (most recent)
$hist = new DeviceImageHistory($device, $images[0]);
self::em()->persist($hist);
self::em()->flush();
$result = $this->rotation->advance($device);
$this->assertNotNull($result);
$this->assertNotSame($images[0]->getId(), $result->getId());
}
public function test_advance_persists_history(): void
{
[$device] = $this->createDeviceWithReadyImage('history@example.com');
$this->rotation->advance($device);
$count = self::em()->createQueryBuilder()
->select('COUNT(h.id)')
->from(DeviceImageHistory::class, 'h')
->where('h.device = :device')
->setParameter('device', $device)
->getQuery()
->getSingleScalarResult();
$this->assertSame(1, (int) $count);
}
public function test_advance_updates_current_image(): void
{
[$device, $image] = $this->createDeviceWithReadyImage('current@example.com');
$this->rotation->advance($device);
self::em()->refresh($device);
$this->assertNotNull($device->getCurrentImage());
$this->assertSame($image->getId(), $device->getCurrentImage()->getId());
}
public function test_advance_excludes_pending_asset(): void
{
[$device, ] = $this->createDeviceWithReadyImage('pending@example.com', RenderStatus::Pending);
$result = $this->rotation->advance($device);
$this->assertNull($result);
}
public function test_advance_excludes_unapproved_image(): void
{
[$device, ] = $this->createDeviceWithReadyImage('unapproved@example.com', RenderStatus::Ready, false);
$result = $this->rotation->advance($device);
$this->assertNull($result);
}
/**
* Builds a device approved for N ready images with explicit uploadedAt
* timestamps. Returns [device, [image, ...]] with images in upload-time
* order (oldest first, newest last).
*
* @param string[] $uploadedAt ISO-8601 dates, oldest first
* @return array{0: Device, 1: Image[]}
*/
private function setupDeviceAndImages(string $email, array $uploadedAt): array
{
$user = $this->createUser($email);
$device = new Device();
$device->setMac(strtoupper(bin2hex(random_bytes(3)) . ':' . bin2hex(random_bytes(3)) . ':BB'));
$device->setName('Test Device');
$device->setUser($user);
// Loose window so the uniqueness filter never empties the candidate set
// unintentionally — we want each test to drive its own filter behavior.
$device->setUniquenessWindow(1);
self::em()->persist($device);
$images = [];
$ref = new \ReflectionProperty(Image::class, 'uploadedAt');
$ref->setAccessible(true);
foreach ($uploadedAt as $i => $ts) {
$img = new Image();
$img->setUser($user)
->setOriginalFilename("img{$i}.jpg")
->setStoragePath("p{$i}");
$ref->setValue($img, new \DateTimeImmutable($ts));
$img->approveForDevice($device);
self::em()->persist($img);
$asset = (new RenderedAsset())
->setImage($img)
->setDeviceModel(DeviceModel::V1)
->setOrientation(Orientation::Landscape)
->setStatus(RenderStatus::Ready)
->setFilePath("p{$i}.bin");
self::em()->persist($asset);
$images[] = $img;
}
self::em()->flush();
return [$device, $images];
}
/** Backdate a history row so least-recently-shown ordering is testable. */
private function recordHistoryAt(Device $device, Image $image, string $servedAt): void
{
$history = new DeviceImageHistory($device, $image);
$ref = new \ReflectionProperty(DeviceImageHistory::class, 'servedAt');
$ref->setAccessible(true);
$ref->setValue($history, new \DateTimeImmutable($servedAt));
self::em()->persist($history);
self::em()->flush();
}
// ID-TZ-01: Regression — isDue() in a non-UTC device timezone.
//
// Bug: device_image_history.served_at is `timestamp without time zone`
// storing UTC components, while the boundary in isDue() was bound with
// the device's local-tz components. Postgres compared the strings
// literally, so for tzs west of UTC every same-day row falsely matched
// the `>= boundary` predicate and isDue returned false — wakeTimes
// schedules silently never fired in EDT/PST/CST/etc.
//
// Real-world repro (Matt's EDT frame, 2026-05-08): wakeTimes=[12:30 PM
// NY], history at 12:28:50 EDT = 16:28:50 UTC. Boundary serialized as
// "12:30:00" (NY components), history stored as "16:28:50" (UTC):
// "16:28:50" >= "12:30:00" lexically → false-match → bug. Fix: bind
// the boundary in UTC so both sides agree.
//
// Test windowing: needs NY local to be past 12:30 PM (so the wake slot
// qualifies as "today's most-recent past"). Skipped before 13:00 NY.
public function test_id_tz_01_isDue_correct_in_non_utc_timezone(): void
{
$nyHour = (int) (new \DateTimeImmutable('now', new \DateTimeZone('America/New_York')))->format('G');
if ($nyHour < 13) {
$this->markTestSkipped('Test exercises a 12:30 PM NY-local boundary; skip until after 13:00 NY');
}
[$device, $images] = $this->setupDeviceAndImages('tz-edt@example.com', [
'2024-01-01',
]);
$device->setTimezone('America/New_York');
$device->setWakeTimes([12 * 60 + 30]); // 12:30 PM NY-local
self::em()->flush();
// Record a history row at 12:00 PM NY today, stored as UTC components
// (16:00:00 in EDT, 17:00:00 in EST). The buggy comparison sees
// "16:00:00" >= "12:30:00" (boundary was NY-formatted) → falsely
// matches → isDue returns false. The fixed comparison binds the
// boundary as "16:30:00" UTC → "16:00:00" < "16:30:00" → no match →
// isDue returns true.
$historyUtc = (new \DateTimeImmutable('today 12:00:00', new \DateTimeZone('America/New_York')))
->setTimezone(new \DateTimeZone('UTC'));
$this->recordHistoryAt($device, $images[0], $historyUtc->format('Y-m-d H:i:s'));
$this->assertTrue(
$this->rotation->isDue($device),
'isDue must use a tz-consistent comparison; the regression is invisible on UTC-tz devices but breaks every other tz',
);
}
// RM-01: NewestUpload picks the most recent upload.
public function test_newest_upload_mode_picks_newest(): void
{
[$device, $images] = $this->setupDeviceAndImages('newest@example.com', [
'2024-01-01', '2024-06-01', '2024-12-01',
]);
$device->setRotationMode(RotationMode::NewestUpload);
self::em()->flush();
$result = $this->rotation->advance($device);
$this->assertNotNull($result);
$this->assertSame($images[2]->getId(), $result->getId(), 'newest upload should win');
}
// RM-02: Random returns *some* eligible candidate. Run a few times so a
// freak coincidence with a deterministic mode would still be unlikely to
// pass. We can't assert exact distribution without a seedable RNG, but we
// can assert randomness produces more than one distinct outcome over a
// handful of calls (probabilistic; failure is ~1/3^7 = 0.05%).
public function test_random_mode_yields_variety(): void
{
[$device, $images] = $this->setupDeviceAndImages('random@example.com', [
'2024-01-01', '2024-06-01', '2024-12-01',
]);
$device->setRotationMode(RotationMode::Random);
$device->setUniquenessWindow(1); // only the very last is forbidden
self::em()->flush();
$imageIds = array_map(static fn(Image $i) => $i->getId(), $images);
$seen = [];
for ($i = 0; $i < 7; $i++) {
$result = $this->rotation->advance($device);
$this->assertNotNull($result);
$this->assertContains($result->getId(), $imageIds, 'random pick must come from the candidate pool');
$seen[$result->getId()] = true;
}
$this->assertGreaterThan(1, count($seen), 'random over 7 picks should yield more than 1 distinct image');
}
// RM-03: LeastRecentlyShown sorts by oldest most-recent serve.
public function test_least_recently_shown_picks_oldest_history(): void
{
[$device, $images] = $this->setupDeviceAndImages('lrs@example.com', [
'2024-01-01', '2024-06-01', '2024-12-01',
]);
$device->setRotationMode(RotationMode::LeastRecentlyShown);
$device->setUniquenessWindow(1); // allow image[0] back in the candidate pool
self::em()->flush();
// Serve history: image[0] longest ago, image[1] middle, image[2] most recent.
// We expect image[0] to be picked.
$this->recordHistoryAt($device, $images[0], '2025-01-01');
$this->recordHistoryAt($device, $images[1], '2025-06-01');
$this->recordHistoryAt($device, $images[2], '2025-12-01');
$result = $this->rotation->advance($device);
$this->assertNotNull($result);
$this->assertSame($images[0]->getId(), $result->getId(), 'oldest last-served should win');
}
// RM-04: LeastRecentlyShown — never-shown sorts before any shown image.
public function test_least_recently_shown_prefers_never_shown(): void
{
[$device, $images] = $this->setupDeviceAndImages('lrs-never@example.com', [
'2024-01-01', '2024-06-01',
]);
$device->setRotationMode(RotationMode::LeastRecentlyShown);
$device->setUniquenessWindow(1);
self::em()->flush();
// image[0] has been shown; image[1] never has.
$this->recordHistoryAt($device, $images[0], '2024-12-01');
$result = $this->rotation->advance($device);
$this->assertNotNull($result);
$this->assertSame($images[1]->getId(), $result->getId(), 'never-shown image beats any history');
}
// RM-05: prioritizeNeverShown narrows the candidate set to never-shown
// images before the mode runs, even when the mode would normally pick
// a shown image.
public function test_prioritize_never_shown_narrows_candidate_set(): void
{
[$device, $images] = $this->setupDeviceAndImages('prio@example.com', [
'2024-01-01', // oldest — Oldest mode would normally pick this
'2024-06-01',
'2024-12-01', // newest, but never-shown
]);
$device->setRotationMode(RotationMode::OldestUpload);
$device->setPrioritizeNeverShown(true);
$device->setUniquenessWindow(1);
self::em()->flush();
// Mark the older two as already shown. Only image[2] is never-shown.
$this->recordHistoryAt($device, $images[0], '2025-06-01');
$this->recordHistoryAt($device, $images[1], '2025-06-02');
$result = $this->rotation->advance($device);
$this->assertNotNull($result);
$this->assertSame(
$images[2]->getId(),
$result->getId(),
'never-shown narrowing must override the OldestUpload mode',
);
}
// RM-06: prioritizeNeverShown is a no-op when no never-shown images
// remain — falls through to the mode. Uses a uniquenessWindow large
// enough that the recent-window filter wipes the candidate set and the
// fallback restores the full pool, otherwise window=1 would exclude the
// most-recently-served image and the mode-picks-from-2 assertion is
// testing the wrong axis.
public function test_prioritize_never_shown_falls_through_when_all_shown(): void
{
[$device, $images] = $this->setupDeviceAndImages('prio-fall@example.com', [
'2024-01-01', '2024-12-01',
]);
$device->setRotationMode(RotationMode::NewestUpload);
$device->setPrioritizeNeverShown(true);
$device->setUniquenessWindow(2);
self::em()->flush();
// Both shown — never-shown set is empty, so mode (NewestUpload) takes
// over. Window=2 wipes both via the recent filter and the fallback
// restores the full pool, so the mode genuinely chooses between both.
$this->recordHistoryAt($device, $images[0], '2025-06-01');
$this->recordHistoryAt($device, $images[1], '2025-06-02');
$result = $this->rotation->advance($device);
$this->assertNotNull($result);
$this->assertSame($images[1]->getId(), $result->getId(), 'falls through to NewestUpload mode');
}
}