test: tighten coverage to 99.69% backend / 98.62% frontend
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>
This commit is contained in:
2026-05-08 14:22:46 -04:00
parent 2a8bf3895f
commit a9ad014bd1
9 changed files with 712 additions and 0 deletions
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration\Command;
use App\Entity\Device;
use App\Entity\Image;
use App\Entity\RenderedAsset;
use App\Enum\DeviceModel;
use App\Enum\Orientation;
use App\Enum\RenderStatus;
use App\Tests\AppKernelTestCase;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
/**
* The rerender command is the operational lever for "renderer changed,
* regenerate every existing asset." A regression here would silently leave
* old bins on disk after a renderer bump, so end users see stale colors
* with no indication anything's wrong.
*/
class RerenderAssetsCommandTest extends AppKernelTestCase
{
private function commandTester(): CommandTester
{
$kernel = self::bootKernel();
$app = new Application($kernel);
return new CommandTester($app->find('app:rerender-assets'));
}
public function test_resets_ready_assets_to_pending_and_dispatches_render_messages(): void
{
$user = $this->createUser('rerender@example.com');
$device = (new Device())
->setMac('AA:BB:CC:DD:EE:F0')
->setName('Frame')
->setUser($user)
->setModel(DeviceModel::V1)
->setOrientation(Orientation::Landscape);
self::em()->persist($device);
$image = (new Image())->setUser($user)->setOriginalFilename('a.jpg')->setStoragePath('a');
self::em()->persist($image);
$asset = (new RenderedAsset())
->setImage($image)
->setDeviceModel(DeviceModel::V1)
->setOrientation(Orientation::Landscape)
->setStatus(RenderStatus::Ready)
->setFilePath('var/storage/x.bin');
self::em()->persist($asset);
self::em()->flush();
$tester = $this->commandTester();
$exit = $tester->execute([]);
$this->assertSame(0, $exit);
$this->assertStringContainsString('Reset and re-dispatched 1 rendered assets', $tester->getDisplay());
self::em()->clear();
$reloaded = self::em()->find(RenderedAsset::class, $asset->getId());
$this->assertSame(RenderStatus::Pending, $reloaded->getStatus());
$this->assertNull($reloaded->getFilePath());
// The in-memory transport doesn't reliably retain messages dispatched
// from CommandTester across the kernel boundary, so we verify the
// dispatch indirectly: a fresh asset was reset (filePath cleared,
// Pending status), which only happens on the path that ALSO calls
// $bus->dispatch(). The reset wouldn't be flushed if the dispatch
// had thrown.
}
public function test_no_assets_means_no_messages_dispatched(): void
{
$tester = $this->commandTester();
$exit = $tester->execute([]);
$this->assertSame(0, $exit);
$this->assertStringContainsString('Reset and re-dispatched 0 rendered assets', $tester->getDisplay());
}
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration\Command;
use App\Entity\Device;
use App\Entity\User;
use App\Tests\AppKernelTestCase;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
/**
* The seed command is dev-only but the rotation pipeline relies on its
* "5 devices in different sync states" output for visual UI checks.
* If it silently breaks (e.g. an entity field rename), nobody catches it
* for a week. This test exercises the create / remove / re-run paths.
*/
class SeedFakeDevicesCommandTest extends AppKernelTestCase
{
private function commandTester(): CommandTester
{
$kernel = self::bootKernel();
$app = new Application($kernel);
return new CommandTester($app->find('app:seed-fake-devices'));
}
public function test_errors_when_email_does_not_match_a_user(): void
{
$tester = $this->commandTester();
$exit = $tester->execute(['email' => 'nobody@example.com']);
$this->assertSame(1, $exit);
$this->assertStringContainsString('No user found', $tester->getDisplay());
}
public function test_creates_five_fake_devices_for_an_existing_user(): void
{
$user = $this->createUser('seed@example.com');
$tester = $this->commandTester();
$exit = $tester->execute(['email' => 'seed@example.com']);
$this->assertSame(0, $exit);
$this->assertStringContainsString('Seeded 5 fake devices', $tester->getDisplay());
self::em()->clear();
$reloaded = self::em()->getRepository(User::class)->findOneBy(['email' => 'seed@example.com']);
$devices = self::em()->getRepository(Device::class)->findBy(['user' => $reloaded]);
$this->assertCount(5, $devices);
// The Cabin device gets wakeTimes=[4*60]; everything else is empty.
$withWakeTimes = array_filter($devices, fn(Device $d) => !empty($d->getWakeTimes()));
$this->assertCount(1, $withWakeTimes);
$this->assertSame([4 * 60], reset($withWakeTimes)->getWakeTimes());
}
public function test_re_runs_are_idempotent_existing_fakes_get_swept(): void
{
$this->createUser('seed-rerun@example.com');
$tester = $this->commandTester();
$tester->execute(['email' => 'seed-rerun@example.com']);
$tester->execute(['email' => 'seed-rerun@example.com']);
// After two runs we should still have exactly five devices and the
// second run's output mentions sweeping.
$this->assertStringContainsString('Removed 5 existing fake device(s)', $tester->getDisplay());
self::em()->clear();
$user = self::em()->getRepository(User::class)->findOneBy(['email' => 'seed-rerun@example.com']);
$devices = self::em()->getRepository(Device::class)->findBy(['user' => $user]);
$this->assertCount(5, $devices);
}
public function test_remove_flag_sweeps_and_exits_without_creating(): void
{
$this->createUser('seed-rm@example.com');
$tester = $this->commandTester();
$tester->execute(['email' => 'seed-rm@example.com']);
$exit = $tester->execute(['email' => 'seed-rm@example.com', '--remove' => true]);
$this->assertSame(0, $exit);
self::em()->clear();
$user = self::em()->getRepository(User::class)->findOneBy(['email' => 'seed-rm@example.com']);
$devices = self::em()->getRepository(Device::class)->findBy(['user' => $user]);
$this->assertCount(0, $devices);
}
}