chore: stage all in-progress work before repo split
CI / test (push) Has been cancelled

Web app: new entities (Image, RenderedAsset, SharedImage, Token,
DeviceImageHistory), enums, repositories, controllers, message handlers,
migrations, tests, frontend upload/library/sticker UI, Vue components.

Firmware: EPD background screen binaries + gen scripts, setup_bg header.

Infra: ddev config, test bundle, gitignore coverage dir.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 12:11:31 -04:00
parent 062c52eec7
commit 12245759ac
149 changed files with 14846 additions and 92 deletions
@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration\MessageHandler;
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\Message\AdvanceRotationMessage;
use App\MessageHandler\AdvanceRotationMessageHandler;
use App\Repository\DeviceImageHistoryRepository;
use App\Tests\AppKernelTestCase;
class AdvanceRotationMessageHandlerTest extends AppKernelTestCase
{
private int $deviceSeq = 0;
private function invokeHandler(): void
{
$handler = static::getContainer()->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: wakeHour=0 (midnight, always past) + no history today → rotation occurs
public function test_ar04_wake_hour_past_no_history_rotates(): void
{
$device = $this->makeDevice();
$device->setWakeHour(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: wakeHour=0 (midnight) + history exists since midnight → already served today → not due
public function test_ar05_wake_hour_already_served_today_is_skipped(): void
{
$device = $this->makeDevice();
$device->setWakeHour(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: wakeHour in future → isDue returns false → no rotation
// Uses 'Etc/GMT+11' (UTC-11) so local time is always before wakeHour=22
// except during UTC 09:00-10:59; test is skipped then.
public function test_ar06_wake_hour_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 → wakeHour=23 is always future
$device->setWakeHour(23)->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 wakeHour is still in the future');
}
}