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
+188
View File
@@ -11,6 +11,7 @@ use App\Entity\RenderedAsset;
use App\Enum\DeviceModel;
use App\Enum\Orientation;
use App\Enum\RenderStatus;
use App\Enum\RotationMode;
use App\Service\RotationService;
use App\Tests\AppKernelTestCase;
@@ -265,4 +266,191 @@ class RotationServiceTest extends AppKernelTestCase
$this->assertNull($result);
}
/**
* Builds a device approved for N ready images with explicit uploadedAt
* timestamps. Returns [device, [image, ...]] with images in upload-time
* order (oldest first, newest last).
*
* @param string[] $uploadedAt ISO-8601 dates, oldest first
* @return array{0: Device, 1: Image[]}
*/
private function setupDeviceAndImages(string $email, array $uploadedAt): array
{
$user = $this->createUser($email);
$device = new Device();
$device->setMac(strtoupper(bin2hex(random_bytes(3)) . ':' . bin2hex(random_bytes(3)) . ':BB'));
$device->setName('Test Device');
$device->setUser($user);
// Loose window so the uniqueness filter never empties the candidate set
// unintentionally — we want each test to drive its own filter behavior.
$device->setUniquenessWindow(1);
self::em()->persist($device);
$images = [];
$ref = new \ReflectionProperty(Image::class, 'uploadedAt');
$ref->setAccessible(true);
foreach ($uploadedAt as $i => $ts) {
$img = new Image();
$img->setUser($user)
->setOriginalFilename("img{$i}.jpg")
->setStoragePath("p{$i}");
$ref->setValue($img, new \DateTimeImmutable($ts));
$img->approveForDevice($device);
self::em()->persist($img);
$asset = (new RenderedAsset())
->setImage($img)
->setDeviceModel(DeviceModel::V1)
->setOrientation(Orientation::Landscape)
->setStatus(RenderStatus::Ready)
->setFilePath("p{$i}.bin");
self::em()->persist($asset);
$images[] = $img;
}
self::em()->flush();
return [$device, $images];
}
/** Backdate a history row so least-recently-shown ordering is testable. */
private function recordHistoryAt(Device $device, Image $image, string $servedAt): void
{
$history = new DeviceImageHistory($device, $image);
$ref = new \ReflectionProperty(DeviceImageHistory::class, 'servedAt');
$ref->setAccessible(true);
$ref->setValue($history, new \DateTimeImmutable($servedAt));
self::em()->persist($history);
self::em()->flush();
}
// RM-01: NewestUpload picks the most recent upload.
public function test_newest_upload_mode_picks_newest(): void
{
[$device, $images] = $this->setupDeviceAndImages('newest@example.com', [
'2024-01-01', '2024-06-01', '2024-12-01',
]);
$device->setRotationMode(RotationMode::NewestUpload);
self::em()->flush();
$result = $this->rotation->advance($device);
$this->assertNotNull($result);
$this->assertSame($images[2]->getId(), $result->getId(), 'newest upload should win');
}
// RM-02: Random returns *some* eligible candidate. Run a few times so a
// freak coincidence with a deterministic mode would still be unlikely to
// pass. We can't assert exact distribution without a seedable RNG, but we
// can assert randomness produces more than one distinct outcome over a
// handful of calls (probabilistic; failure is ~1/3^7 = 0.05%).
public function test_random_mode_yields_variety(): void
{
[$device, $images] = $this->setupDeviceAndImages('random@example.com', [
'2024-01-01', '2024-06-01', '2024-12-01',
]);
$device->setRotationMode(RotationMode::Random);
$device->setUniquenessWindow(1); // only the very last is forbidden
self::em()->flush();
$imageIds = array_map(static fn(Image $i) => $i->getId(), $images);
$seen = [];
for ($i = 0; $i < 7; $i++) {
$result = $this->rotation->advance($device);
$this->assertNotNull($result);
$this->assertContains($result->getId(), $imageIds, 'random pick must come from the candidate pool');
$seen[$result->getId()] = true;
}
$this->assertGreaterThan(1, count($seen), 'random over 7 picks should yield more than 1 distinct image');
}
// RM-03: LeastRecentlyShown sorts by oldest most-recent serve.
public function test_least_recently_shown_picks_oldest_history(): void
{
[$device, $images] = $this->setupDeviceAndImages('lrs@example.com', [
'2024-01-01', '2024-06-01', '2024-12-01',
]);
$device->setRotationMode(RotationMode::LeastRecentlyShown);
$device->setUniquenessWindow(1); // allow image[0] back in the candidate pool
self::em()->flush();
// Serve history: image[0] longest ago, image[1] middle, image[2] most recent.
// We expect image[0] to be picked.
$this->recordHistoryAt($device, $images[0], '2025-01-01');
$this->recordHistoryAt($device, $images[1], '2025-06-01');
$this->recordHistoryAt($device, $images[2], '2025-12-01');
$result = $this->rotation->advance($device);
$this->assertNotNull($result);
$this->assertSame($images[0]->getId(), $result->getId(), 'oldest last-served should win');
}
// RM-04: LeastRecentlyShown — never-shown sorts before any shown image.
public function test_least_recently_shown_prefers_never_shown(): void
{
[$device, $images] = $this->setupDeviceAndImages('lrs-never@example.com', [
'2024-01-01', '2024-06-01',
]);
$device->setRotationMode(RotationMode::LeastRecentlyShown);
$device->setUniquenessWindow(1);
self::em()->flush();
// image[0] has been shown; image[1] never has.
$this->recordHistoryAt($device, $images[0], '2024-12-01');
$result = $this->rotation->advance($device);
$this->assertNotNull($result);
$this->assertSame($images[1]->getId(), $result->getId(), 'never-shown image beats any history');
}
// RM-05: prioritizeNeverShown narrows the candidate set to never-shown
// images before the mode runs, even when the mode would normally pick
// a shown image.
public function test_prioritize_never_shown_narrows_candidate_set(): void
{
[$device, $images] = $this->setupDeviceAndImages('prio@example.com', [
'2024-01-01', // oldest — Oldest mode would normally pick this
'2024-06-01',
'2024-12-01', // newest, but never-shown
]);
$device->setRotationMode(RotationMode::OldestUpload);
$device->setPrioritizeNeverShown(true);
$device->setUniquenessWindow(1);
self::em()->flush();
// Mark the older two as already shown. Only image[2] is never-shown.
$this->recordHistoryAt($device, $images[0], '2025-06-01');
$this->recordHistoryAt($device, $images[1], '2025-06-02');
$result = $this->rotation->advance($device);
$this->assertNotNull($result);
$this->assertSame(
$images[2]->getId(),
$result->getId(),
'never-shown narrowing must override the OldestUpload mode',
);
}
// RM-06: prioritizeNeverShown is a no-op when no never-shown images
// remain — falls through to the mode.
public function test_prioritize_never_shown_falls_through_when_all_shown(): void
{
[$device, $images] = $this->setupDeviceAndImages('prio-fall@example.com', [
'2024-01-01', '2024-12-01',
]);
$device->setRotationMode(RotationMode::NewestUpload);
$device->setPrioritizeNeverShown(true);
$device->setUniquenessWindow(1);
self::em()->flush();
// Both shown — never-shown set is empty, so mode (NewestUpload) takes over.
$this->recordHistoryAt($device, $images[0], '2025-06-01');
$this->recordHistoryAt($device, $images[1], '2025-06-02');
$result = $this->rotation->advance($device);
$this->assertNotNull($result);
$this->assertSame($images[1]->getId(), $result->getId(), 'falls through to NewestUpload mode');
}
}