12245759ac
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>
269 lines
9.1 KiB
PHP
269 lines
9.1 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\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);
|
|
}
|
|
}
|