Files
football2801 a9ad014bd1
CI / test (push) Has been cancelled
test: tighten coverage to 99.69% backend / 98.62% frontend
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>
2026-05-08 14:22:46 -04:00

191 lines
7.0 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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());
}
}