get(AdvanceRotationMessageHandler::class); $handler(new AdvanceRotationMessage()); } private function makeDevice(int $intervalMinutes = 60): Device { $seq = ++$this->deviceSeq; $mac = sprintf('AA:BB:CC:%02X:%02X:%02X', 10, 0, $seq); $user = $this->createUser('ar' . $seq . '@example.com'); $device = new Device(); $device->setMac($mac)->setName('Frame ' . $seq)->setUser($user) ->setRotationIntervalMinutes($intervalMinutes); $this->em()->persist($device); return $device; } private function makeReadyImage(Device $device): Image { $image = (new Image())->setUser($device->getUser())->setOriginalFilename('x.jpg')->setStoragePath('x'); $image->approveForDevice($device); $this->em()->persist($image); $asset = (new RenderedAsset()) ->setImage($image) ->setDeviceModel($device->getModel() ?? DeviceModel::V1) ->setOrientation($device->getOrientation() ?? Orientation::Landscape) ->setStatus(RenderStatus::Ready) ->setFilePath('var/storage/dummy.bin'); $this->em()->persist($asset); return $image; } // AR-01: due device (no history) with no ready images → advance returns null public function test_ar01_due_with_no_images_does_not_rotate(): void { $device = $this->makeDevice(); $this->em()->flush(); // Ensure repository constructor is covered $this->em()->getRepository(DeviceImageHistory::class); $this->invokeHandler(); $this->em()->clear(); $reloaded = $this->em()->find(Device::class, $device->getId()); $this->assertNull($reloaded->getCurrentImage()); } // AR-02: interval-based device with recent history → not due → rotation skipped public function test_ar02_recent_history_is_not_due(): void { $device = $this->makeDevice(60); $image = $this->makeReadyImage($device); $this->em()->flush(); $history = new DeviceImageHistory($device, $image); $this->em()->persist($history); $device->setCurrentImage($image); $this->em()->flush(); $this->invokeHandler(); $this->em()->clear(); $reloaded = $this->em()->find(Device::class, $device->getId()); // currentImage was set before handler; rotation should not have occurred again $this->assertNotNull($reloaded->getCurrentImage()); $this->assertSame($image->getId(), $reloaded->getCurrentImage()->getId()); } // AR-03: interval-based device with old history → due → rotation occurs public function test_ar03_old_history_triggers_rotation(): void { $device = $this->makeDevice(1); // 1-minute interval $image = $this->makeReadyImage($device); $this->em()->flush(); $deviceId = $device->getId(); $history = new DeviceImageHistory($device, $image); $this->em()->persist($history); $this->em()->flush(); // Backdate history to 2 minutes ago (older than 1-minute interval). // Clear the identity map afterward — DQL bulk UPDATE bypasses the entity cache. $this->em()->createQuery( 'UPDATE App\Entity\DeviceImageHistory h SET h.servedAt = :old WHERE h.device = :dev' )->setParameters(['old' => new \DateTimeImmutable('-2 minutes'), 'dev' => $device])->execute(); $this->em()->clear(); $this->invokeHandler(); $this->em()->clear(); $reloaded = $this->em()->find(Device::class, $deviceId); $this->assertNotNull($reloaded->getCurrentImage()); } // AR-04: wakeTimes=[00:00] (always past) + no history today → rotation occurs public function test_ar04_wake_time_past_no_history_rotates(): void { $device = $this->makeDevice(); $device->setWakeTimes([0])->setTimezone('UTC'); $image = $this->makeReadyImage($device); $this->em()->flush(); $deviceId = $device->getId(); $imageId = $image->getId(); $this->invokeHandler(); $this->em()->clear(); $reloaded = $this->em()->find(Device::class, $deviceId); $this->assertNotNull($reloaded->getCurrentImage()); $this->assertSame($imageId, $reloaded->getCurrentImage()->getId()); } // AR-05: wakeTimes=[00:00] + history exists since midnight → already served today → not due public function test_ar05_wake_time_already_served_today_is_skipped(): void { $device = $this->makeDevice(); $device->setWakeTimes([0])->setTimezone('UTC'); $image = $this->makeReadyImage($device); $this->em()->flush(); // History entry timestamped just now (after midnight UTC → considered "today's wake") $history = new DeviceImageHistory($device, $image); $this->em()->persist($history); $device->setCurrentImage($image); $this->em()->flush(); $deviceId = $device->getId(); $imageId = $image->getId(); $this->invokeHandler(); $this->em()->clear(); $reloaded = $this->em()->find(Device::class, $deviceId); // currentImage set before; rotation should not have happened again $this->assertSame($imageId, $reloaded->getCurrentImage()?->getId()); } // AR-06: wakeTime in future → isDue returns false → no rotation // Uses 'Etc/GMT+11' (UTC-11) so local time is always before 23:00 local // except during UTC 09:00-10:59; test is skipped then. public function test_ar06_wake_time_in_future_is_not_due(): void { $utcHour = (int)(new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('G'); if ($utcHour >= 9 && $utcHour <= 10) { $this->markTestSkipped('Time-dependent test skipped during UTC 09:xx-10:xx boundary hour'); } $device = $this->makeDevice(); // UTC-11: local time is at most 12:59 when UTC is 23:59 → 23:00 always future $device->setWakeTimes([23 * 60])->setTimezone('Etc/GMT+11'); $image = $this->makeReadyImage($device); $this->em()->flush(); $this->invokeHandler(); $this->em()->clear(); $reloaded = $this->em()->find(Device::class, $device->getId()); $this->assertNull($reloaded->getCurrentImage(), 'Rotation must not happen when wake time is still in the future'); } // AR-07: multiple wakeTimes — 00:00 has passed, so device is due even // though later slots haven't fired yet. Validates that we use the most // recent past slot as the boundary, not the earliest. public function test_ar07_multiple_wake_times_uses_most_recent_past_slot(): void { $device = $this->makeDevice(); // 00:00 always past, 23:00 future for most of the day $device->setWakeTimes([0, 23 * 60])->setTimezone('UTC'); $image = $this->makeReadyImage($device); $this->em()->flush(); $this->invokeHandler(); $this->em()->clear(); $reloaded = $this->em()->find(Device::class, $device->getId()); $this->assertNotNull( $reloaded->getCurrentImage(), 'Device with multiple wake times should rotate when at least one has passed today and no history exists since', ); } }