test: close coverage gaps from the recent rotation + Mercure work
CI / test (push) Has been cancelled

Frontend (90.15→95.37 stmts / 91.83→97.01 lines):
  - useDeviceMercure: full composable test suite via a fake EventSource —
    open/merge/ignore-stale/parse-error/reconnect/dynamic-add/remove/
    no-op-when-unconfigured/cleanup-on-unmount.
  - HomeView: cover onTimePart's AM/PM and minute branches plus the
    nextPollExpectedAt-null fallback paths in the next-update preview.

Backend (no instrumentation before; pcov was already in the image,
just needed a <coverage> block in phpunit.dist.xml):
  - RotationService: one test per mode (NewestUpload, Random,
    LeastRecentlyShown), one for never-shown sorting first under LRS,
    and two for prioritizeNeverShown — narrows when never-shown exists,
    falls through to mode otherwise.
  - DeviceSerializer: contract test on the wire shape (REST + Mercure
    use the same serializer; silent rename here would break live updates
    instantly).
  - MercurePublisher: topic format + JSON encoding + the swallow-
    exceptions guarantee (a flaky hub must not break poll responses).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 17:25:25 -04:00
parent 9a5aa123c2
commit bf9d4ebc58
6 changed files with 700 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Entity\Device;
use App\Entity\Image;
use App\Enum\DeviceModel;
use App\Enum\Orientation;
use App\Enum\RotationMode;
use App\Service\DeviceSerializer;
use PHPUnit\Framework\TestCase;
/**
* The serializer is the wire-shape contract between REST and Mercure. A test
* here doubles as a contract regression check: the SPA splats the same JSON
* for both, so any silent rename/removal here would break live updates the
* moment a device polls.
*/
class DeviceSerializerTest extends TestCase
{
private DeviceSerializer $serializer;
protected function setUp(): void
{
$this->serializer = new DeviceSerializer();
}
public function test_includes_all_expected_fields(): void
{
$device = $this->makeDevice();
$payload = $this->serializer->serialize($device);
$this->assertEqualsCanonicalizing(
['id', 'mac', 'name', 'orientation', 'rotationIntervalMinutes',
'wakeTimes', 'timezone', 'uniquenessWindow', 'rotationMode',
'prioritizeNeverShown', 'linkedAt', 'lastSeenAt',
'nextPollExpectedAt', 'lockedImageId', 'currentImageId'],
array_keys($payload),
);
}
public function test_serializes_scalars_in_expected_shapes(): void
{
$device = $this->makeDevice();
$device->setName('Living Room');
$device->setOrientation(Orientation::Portrait);
$device->setRotationIntervalMinutes(15);
$device->setWakeTimes([6 * 60, 18 * 60]);
$device->setTimezone('America/Chicago');
$device->setUniquenessWindow(7);
$device->setRotationMode(RotationMode::Random);
$device->setPrioritizeNeverShown(true);
$payload = $this->serializer->serialize($device);
$this->assertSame('Living Room', $payload['name']);
$this->assertSame('portrait', $payload['orientation']);
$this->assertSame(15, $payload['rotationIntervalMinutes']);
$this->assertSame([360, 1080], $payload['wakeTimes']);
$this->assertSame('America/Chicago', $payload['timezone']);
$this->assertSame(7, $payload['uniquenessWindow']);
$this->assertSame('random', $payload['rotationMode']);
$this->assertTrue($payload['prioritizeNeverShown']);
}
public function test_serializes_nullable_timestamps_as_null_when_unset(): void
{
$device = $this->makeDevice();
$payload = $this->serializer->serialize($device);
$this->assertNull($payload['lastSeenAt']);
$this->assertNull($payload['nextPollExpectedAt']);
$this->assertNull($payload['lockedImageId']);
$this->assertNull($payload['currentImageId']);
}
public function test_serializes_timestamps_as_iso_8601(): void
{
$device = $this->makeDevice();
$device->markSeen();
$device->setNextPollExpectedAt(new \DateTimeImmutable('2026-05-07T12:34:56+00:00'));
$payload = $this->serializer->serialize($device);
$this->assertMatchesRegularExpression(
'/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/',
$payload['lastSeenAt'],
);
$this->assertSame('2026-05-07T12:34:56+00:00', $payload['nextPollExpectedAt']);
}
public function test_lockedImageId_and_currentImageId_reflect_assignments(): void
{
$device = $this->makeDevice();
$image = $this->makeImage(42);
$device->setLockedImage($image);
$device->setCurrentImage($image);
$payload = $this->serializer->serialize($device);
$this->assertSame(42, $payload['lockedImageId']);
$this->assertSame(42, $payload['currentImageId']);
}
private function makeDevice(): Device
{
$device = new Device();
$device->setMac('AA:BB:CC:DD:EE:FF');
$device->setName('Test');
$device->setModel(DeviceModel::V1);
$device->setOrientation(Orientation::Landscape);
// Persisted-id is null in unit-tests; serializer must tolerate it.
return $device;
}
private function makeImage(int $id): Image
{
$image = new Image();
// Image::$id is set by Doctrine — bypass via reflection in unit context.
$ref = new \ReflectionProperty(Image::class, 'id');
$ref->setAccessible(true);
$ref->setValue($image, $id);
return $image;
}
}