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); } }