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(); } // 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. 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(1); self::em()->flush(); // Both shown — never-shown set is empty, so mode (NewestUpload) takes over. $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'); } }