a9ad014bd1
CI / test (push) Has been cancelled
Started: 89.08% backend / 97.01% frontend lines.
Landed: 99.69% backend / 98.62% frontend.
Closed gaps targeted at logic gates, branches, and assumption boundaries
that real users hit. Each test exercises a use case the production code
actually serves; nothing here is line-padding.
Backend additions:
- DeviceModelTest: pin landscape vs portrait dimension swap, plus the
nativeWidth/Height "ignore orientation" contract the firmware relies on.
- DeviceApiControllerTest: validation branches the PWA forms can't
even produce (raw API misuse) — non-array wakeTimes, non-int entries,
invalid rotation mode, invalid timezone, empty name, invalid orientation,
other-user PATCH returns 404. Plus full /preview coverage: 404 for
other-user / no-current / no-asset / missing-file / soft-deleted, and
happy paths for landscape AND portrait (the rotateImage(90) branch).
- ImageApiControllerTest: cropOrientation now exercised on both upload
and reprocess paths.
- TokenActionControllerTest: TK-01c covers the bad-device-id "continue"
branch in submit.
- RenderImageMessageHandlerTest: explicit portrait test pins the
rotateImage(-90) branch and the 192,000-byte EPD-native bin shape.
- SeedFakeDevicesCommandTest: 4 cases covering missing-user, fresh
create, idempotent re-run, and --remove path. The dev seed command
is load-bearing for the multi-frame UI; a silent break would surface
a week later.
- RerenderAssetsCommandTest: reset + dispatch path, no-assets path.
Frontend additions:
- FrameCardTest: lastSync-only and nextSync-only rendering branches.
- HomeView.test:
* + Add time fallback path when all 9 default candidates are taken.
* Multi-day "in Nd" nextSync formatting (offline / huge-interval case).
* Medium-horizon (5h) nextSync formats as clock-time + day label.
* visibilitychange triggers a silent re-fetch.
* add-photo handler creates input + navigates to /upload after pick.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
191 lines
7.0 KiB
PHP
191 lines
7.0 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Tests\Integration\MessageHandler;
|
||
|
||
use App\Entity\Image;
|
||
use App\Enum\RenderStatus;
|
||
use App\Message\RenderImageMessage;
|
||
use App\MessageHandler\RenderImageMessageHandler;
|
||
use App\Repository\RenderedAssetRepository;
|
||
use App\Tests\AppKernelTestCase;
|
||
|
||
class RenderImageMessageHandlerTest extends AppKernelTestCase
|
||
{
|
||
private string $projectDir;
|
||
private string $fixtureJpeg;
|
||
private array $createdDirs = [];
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
$this->projectDir = static::getContainer()->getParameter('kernel.project_dir');
|
||
$this->createdDirs = [];
|
||
|
||
$storageDir = $this->projectDir . '/var/storage/images';
|
||
if (!is_dir($storageDir)) {
|
||
mkdir($storageDir, 0755, true);
|
||
}
|
||
|
||
$this->fixtureJpeg = $this->projectDir . '/var/storage/images/_render_fixture.jpg';
|
||
$imagick = new \Imagick();
|
||
$imagick->newImage(20, 20, new \ImagickPixel('white'));
|
||
$imagick->setImageFormat('jpeg');
|
||
$imagick->writeImage($this->fixtureJpeg);
|
||
$imagick->destroy();
|
||
}
|
||
|
||
protected function tearDown(): void
|
||
{
|
||
foreach ($this->createdDirs as $dir) {
|
||
if (is_dir($dir)) {
|
||
$this->deleteDir($dir);
|
||
}
|
||
}
|
||
if (file_exists($this->fixtureJpeg)) {
|
||
unlink($this->fixtureJpeg);
|
||
}
|
||
parent::tearDown();
|
||
}
|
||
|
||
private function deleteDir(string $dir): void
|
||
{
|
||
foreach (new \FilesystemIterator($dir) as $item) {
|
||
$item->isDir() ? $this->deleteDir($item->getPathname()) : unlink($item->getPathname());
|
||
}
|
||
rmdir($dir);
|
||
}
|
||
|
||
private function invokeHandler(int $imageId, string $model = 'v1', string $orientation = 'landscape'): void
|
||
{
|
||
$handler = static::getContainer()->get(RenderImageMessageHandler::class);
|
||
$handler(new RenderImageMessage($imageId, $model, $orientation));
|
||
}
|
||
|
||
// MH-01: happy path — bin written, RenderedAsset status = Ready
|
||
public function test_mh01_renders_image_to_bin_and_marks_ready(): void
|
||
{
|
||
$user = $this->createUser('mh01@example.com');
|
||
$image = (new Image())->setUser($user)
|
||
->setOriginalFilename('test.jpg')
|
||
->setStoragePath('var/storage/images/_render_fixture.jpg');
|
||
$this->em()->persist($image);
|
||
$this->em()->flush();
|
||
|
||
$imageId = $image->getId();
|
||
$imageDir = $this->projectDir . '/var/storage/images/' . $imageId;
|
||
mkdir($imageDir, 0755, true);
|
||
$this->createdDirs[] = $imageDir;
|
||
|
||
$this->invokeHandler($imageId);
|
||
|
||
/** @var RenderedAssetRepository $assetRepo */
|
||
$assetRepo = $this->em()->getRepository(\App\Entity\RenderedAsset::class);
|
||
$asset = $assetRepo->findOneBy(['image' => $image]);
|
||
|
||
$this->assertNotNull($asset);
|
||
$this->assertSame(RenderStatus::Ready, $asset->getStatus());
|
||
$this->assertFileExists($this->projectDir . '/' . $asset->getFilePath());
|
||
}
|
||
|
||
// MH-01b: composited.jpg is preferred over the raw original when present
|
||
public function test_mh01b_composited_jpg_preferred_over_original(): void
|
||
{
|
||
$user = $this->createUser('mh01b@example.com');
|
||
$image = (new Image())->setUser($user)
|
||
->setOriginalFilename('test.jpg')
|
||
->setStoragePath('var/storage/images/_render_fixture.jpg');
|
||
$this->em()->persist($image);
|
||
$this->em()->flush();
|
||
|
||
$imageId = $image->getId();
|
||
$imageDir = $this->projectDir . '/var/storage/images/' . $imageId;
|
||
mkdir($imageDir, 0755, true);
|
||
$this->createdDirs[] = $imageDir;
|
||
|
||
copy($this->fixtureJpeg, $imageDir . '/composited.jpg');
|
||
|
||
$this->invokeHandler($imageId);
|
||
|
||
$assetRepo = $this->em()->getRepository(\App\Entity\RenderedAsset::class);
|
||
$asset = $assetRepo->findOneBy(['image' => $image]);
|
||
|
||
$this->assertNotNull($asset);
|
||
$this->assertSame(RenderStatus::Ready, $asset->getStatus());
|
||
}
|
||
|
||
// MH-02: non-existent imageId → handler returns early, no RenderedAsset created
|
||
public function test_mh02_nonexistent_image_returns_early(): void
|
||
{
|
||
$this->invokeHandler(999999999);
|
||
|
||
$assetRepo = $this->em()->getRepository(\App\Entity\RenderedAsset::class);
|
||
$assets = $assetRepo->findAll();
|
||
$this->assertCount(0, $assets);
|
||
}
|
||
|
||
// MH-03: corrupt/non-image file → RenderedAsset status = Failed
|
||
public function test_mh03_imagick_failure_marks_asset_as_failed(): void
|
||
{
|
||
$badFile = $this->projectDir . '/var/storage/images/_render_bad.txt';
|
||
file_put_contents($badFile, 'not-an-image');
|
||
|
||
$user = $this->createUser('mh03@example.com');
|
||
$image = (new Image())->setUser($user)
|
||
->setOriginalFilename('bad.jpg')
|
||
->setStoragePath('var/storage/images/_render_bad.txt');
|
||
$this->em()->persist($image);
|
||
$this->em()->flush();
|
||
|
||
$imageId = $image->getId();
|
||
$imageDir = $this->projectDir . '/var/storage/images/' . $imageId;
|
||
mkdir($imageDir, 0755, true);
|
||
$this->createdDirs[] = $imageDir;
|
||
|
||
$this->invokeHandler($imageId);
|
||
|
||
$assetRepo = $this->em()->getRepository(\App\Entity\RenderedAsset::class);
|
||
$asset = $assetRepo->findOneBy(['image' => $image]);
|
||
|
||
$this->assertNotNull($asset);
|
||
$this->assertSame(RenderStatus::Failed, $asset->getStatus());
|
||
|
||
if (file_exists($badFile)) {
|
||
unlink($badFile);
|
||
}
|
||
}
|
||
|
||
// MH-PORTRAIT: portrait orientation rotates the source 90° CCW so the
|
||
// photo's top edge maps to the EPD's left column (the panel is physically
|
||
// rotated when mounted vertically). Without the rotate, the rendered .bin
|
||
// would come out sideways. This test exercises the rotateImage(-90) branch
|
||
// in the handler that the landscape-only happy-path tests skip.
|
||
public function test_portrait_rotation_branch_runs(): void
|
||
{
|
||
$user = $this->createUser('mh-portrait@example.com');
|
||
$image = (new Image())->setUser($user)
|
||
->setOriginalFilename('p.jpg')
|
||
->setStoragePath('var/storage/images/_render_fixture.jpg');
|
||
$this->em()->persist($image);
|
||
$this->em()->flush();
|
||
|
||
$imageId = $image->getId();
|
||
$imageDir = $this->projectDir . '/var/storage/images/' . $imageId;
|
||
mkdir($imageDir, 0755, true);
|
||
$this->createdDirs[] = $imageDir;
|
||
|
||
$this->invokeHandler($imageId, 'v1', 'portrait');
|
||
|
||
// Portrait .bin should still be 800×480 EPD-native (192,000 bytes).
|
||
$binPath = $imageDir . '/v1_portrait.bin';
|
||
$this->assertFileExists($binPath);
|
||
$this->assertSame(192000, filesize($binPath));
|
||
|
||
$assetRepo = static::getContainer()->get(\App\Repository\RenderedAssetRepository::class);
|
||
$asset = $assetRepo->findOneBy(['image' => $image, 'orientation' => \App\Enum\Orientation::Portrait]);
|
||
$this->assertNotNull($asset);
|
||
$this->assertSame(RenderStatus::Ready, $asset->getStatus());
|
||
}
|
||
}
|