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
@@ -311,4 +311,284 @@ class DeviceApiControllerTest extends AppWebTestCase
$this->assertResponseStatusCodeSame(404);
}
// ── Validation branch coverage for PATCH /api/devices/{id} ───────────
public function test_patch_rejects_non_array_wakeTimes(): void
{
$user = $this->createUser('vbad-array@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:D1', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['wakeTimes' => 'not-an-array']));
$this->assertResponseStatusCodeSame(422);
}
public function test_patch_rejects_non_integer_wakeTimes_entries(): void
{
$user = $this->createUser('vbad-non-int@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:D2', $user);
$client = $this->loginAs($user);
// Float-as-string is neither int nor digit-only-string → rejected.
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['wakeTimes' => ['1.5']]));
$this->assertResponseStatusCodeSame(422);
}
public function test_patch_accepts_digit_string_wakeTimes_for_pwa_form_submits(): void
{
// The PWA's <select> value comes through as a string when the body is
// form-encoded; the controller's ctype_digit branch must accept that.
$user = $this->createUser('vstr@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:D3', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['wakeTimes' => ['360']]));
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame([360], $data['wakeTimes']);
}
public function test_patch_rejects_invalid_rotation_mode(): void
{
$user = $this->createUser('vmode@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:D4', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['rotationMode' => 'shuffle-please']));
$this->assertResponseStatusCodeSame(422);
}
public function test_patch_accepts_valid_rotation_modes(): void
{
$user = $this->createUser('vmode-ok@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:D5', $user);
$client = $this->loginAs($user);
foreach (['random', 'least_recently_shown', 'newest_upload', 'oldest_upload'] as $mode) {
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['rotationMode' => $mode]));
$this->assertResponseIsSuccessful('mode=' . $mode);
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame($mode, $data['rotationMode']);
}
}
public function test_patch_sets_prioritize_never_shown(): void
{
$user = $this->createUser('vprio@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:D6', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['prioritizeNeverShown' => true]));
$this->assertResponseIsSuccessful();
$this->assertTrue(json_decode($client->getResponse()->getContent(), true)['prioritizeNeverShown']);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['prioritizeNeverShown' => false]));
$this->assertFalse(json_decode($client->getResponse()->getContent(), true)['prioritizeNeverShown']);
}
public function test_patch_rejects_empty_name(): void
{
$user = $this->createUser('vname@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:D7', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['name' => ' ']));
$this->assertResponseStatusCodeSame(422);
}
public function test_patch_rejects_invalid_orientation(): void
{
$user = $this->createUser('vori@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:D8', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['orientation' => 'sideways']));
$this->assertResponseStatusCodeSame(422);
}
public function test_patch_rejects_invalid_timezone(): void
{
$user = $this->createUser('vtz@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:D9', $user);
$client = $this->loginAs($user);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['timezone' => 'Mars/Olympus_Mons']));
$this->assertResponseStatusCodeSame(422);
}
public function test_patch_404_for_other_users_device(): void
{
$owner = $this->createUser('vown@example.com');
$other = $this->createUser('vother@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:DA', $owner);
$client = $this->loginAs($other);
$client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode(['name' => 'pwn']));
$this->assertResponseStatusCodeSame(404);
}
// ── /preview endpoint coverage ───────────────────────────────────────
public function test_preview_404_for_other_users_device(): void
{
$owner = $this->createUser('pown@example.com');
$other = $this->createUser('pother@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:E0', $owner);
$client = $this->loginAs($other);
$client->request('GET', '/api/devices/' . $device->getId() . '/preview');
$this->assertResponseStatusCodeSame(404);
}
public function test_preview_404_when_device_has_no_current_image(): void
{
$user = $this->createUser('pnocur@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:E1', $user);
$client = $this->loginAs($user);
$client->request('GET', '/api/devices/' . $device->getId() . '/preview');
$this->assertResponseStatusCodeSame(404);
$this->assertStringContainsString('No current image', $client->getResponse()->getContent());
}
public function test_preview_404_when_no_ready_render_for_devices_orientation(): void
{
$user = $this->createUser('pnoasset@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:E2', $user);
$image = $this->makeImage($user);
$device->setCurrentImage($image);
$this->em()->flush();
$client = $this->loginAs($user);
$client->request('GET', '/api/devices/' . $device->getId() . '/preview');
$this->assertResponseStatusCodeSame(404);
$this->assertStringContainsString('Render not ready', $client->getResponse()->getContent());
}
public function test_preview_404_when_render_file_is_missing_from_disk(): void
{
$user = $this->createUser('pmissing@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:E3', $user);
$image = $this->makeImage($user);
$device->setCurrentImage($image);
$asset = (new \App\Entity\RenderedAsset())
->setImage($image)
->setDeviceModel(DeviceModel::V1)
->setOrientation(Orientation::Landscape)
->setStatus(\App\Enum\RenderStatus::Ready)
->setFilePath('var/storage/images/nope/v1_landscape.bin');
$this->em()->persist($asset);
$this->em()->flush();
$client = $this->loginAs($user);
$client->request('GET', '/api/devices/' . $device->getId() . '/preview');
$this->assertResponseStatusCodeSame(404);
$this->assertStringContainsString('Render file missing', $client->getResponse()->getContent());
}
public function test_preview_returns_png_for_landscape_device(): void
{
$user = $this->createUser('ppng-l@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:E4', $user);
$image = $this->makeImage($user);
$device->setCurrentImage($image);
// 800×480 EPD = 384,000 pixels. 4bpp packed = 192,000 bytes. Fill
// with palette index 0x1 (white) so the renderer produces a valid PNG.
$projectDir = static::getContainer()->getParameter('kernel.project_dir');
$relPath = 'var/storage/images/test-preview/v1_landscape.bin';
$absPath = $projectDir . '/' . $relPath;
if (!is_dir(dirname($absPath))) {
mkdir(dirname($absPath), 0755, true);
}
file_put_contents($absPath, str_repeat(chr(0x11), 192000));
$asset = (new \App\Entity\RenderedAsset())
->setImage($image)
->setDeviceModel(DeviceModel::V1)
->setOrientation(Orientation::Landscape)
->setStatus(\App\Enum\RenderStatus::Ready)
->setFilePath($relPath);
$this->em()->persist($asset);
$this->em()->flush();
$client = $this->loginAs($user);
$client->request('GET', '/api/devices/' . $device->getId() . '/preview');
$this->assertResponseIsSuccessful();
$this->assertSame('image/png', $client->getResponse()->headers->get('Content-Type'));
// Cleanup the bin + the cached png.
@unlink($absPath);
@unlink(preg_replace('/\.bin$/', '.png', $absPath));
}
public function test_preview_returns_png_for_portrait_device(): void
{
// Portrait covers the rotateImage(90) branch in renderBinToPng. Same
// 192,000-byte buffer (the .bin is always EPD-native 800×480; orientation
// only affects post-decode rotation).
$user = $this->createUser('ppng-p@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:E6', $user);
$device->setOrientation(Orientation::Portrait);
$image = $this->makeImage($user);
$device->setCurrentImage($image);
$projectDir = static::getContainer()->getParameter('kernel.project_dir');
$relPath = 'var/storage/images/test-preview-portrait/v1_portrait.bin';
$absPath = $projectDir . '/' . $relPath;
if (!is_dir(dirname($absPath))) {
mkdir(dirname($absPath), 0755, true);
}
// Mix in some 0x4 nibbles — 0x4 is unused in the Spectra-6 palette;
// the renderer must fall back to the white default rather than
// throwing on the unknown index.
file_put_contents($absPath, str_repeat(chr(0x14), 192000));
$asset = (new \App\Entity\RenderedAsset())
->setImage($image)
->setDeviceModel(DeviceModel::V1)
->setOrientation(Orientation::Portrait)
->setStatus(\App\Enum\RenderStatus::Ready)
->setFilePath($relPath);
$this->em()->persist($asset);
$this->em()->flush();
$client = $this->loginAs($user);
$client->request('GET', '/api/devices/' . $device->getId() . '/preview');
$this->assertResponseIsSuccessful();
$this->assertSame('image/png', $client->getResponse()->headers->get('Content-Type'));
@unlink($absPath);
@unlink(preg_replace('/\.bin$/', '.png', $absPath));
}
public function test_preview_404_when_image_is_soft_deleted(): void
{
$user = $this->createUser('pdeleted@example.com');
$device = $this->makeDevice('AA:BB:CC:DD:EE:E5', $user);
$image = $this->makeImage($user);
$device->setCurrentImage($image);
// Soft-delete the image — preview must refuse rather than serve the
// last cached render.
$image->setDeletedAt(new \DateTimeImmutable());
$this->em()->flush();
$client = $this->loginAs($user);
$client->request('GET', '/api/devices/' . $device->getId() . '/preview');
$this->assertResponseStatusCodeSame(404);
}
}