From bf9d4ebc5898a458cc745e503e20bfc53a313764 Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Thu, 7 May 2026 17:25:25 -0400 Subject: [PATCH] test: close coverage gaps from the recent rotation + Mercure work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- .../test/composables/useDeviceMercure.test.ts | 220 ++++++++++++++++++ frontend/src/test/views/HomeView.test.ts | 100 ++++++++ phpunit.dist.xml | 7 + tests/Unit/Service/DeviceSerializerTest.php | 130 +++++++++++ tests/Unit/Service/MercurePublisherTest.php | 55 +++++ tests/Unit/Service/RotationServiceTest.php | 188 +++++++++++++++ 6 files changed, 700 insertions(+) create mode 100644 frontend/src/test/composables/useDeviceMercure.test.ts create mode 100644 tests/Unit/Service/DeviceSerializerTest.php create mode 100644 tests/Unit/Service/MercurePublisherTest.php diff --git a/frontend/src/test/composables/useDeviceMercure.test.ts b/frontend/src/test/composables/useDeviceMercure.test.ts new file mode 100644 index 0000000..6e6445f --- /dev/null +++ b/frontend/src/test/composables/useDeviceMercure.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { defineComponent, h } from 'vue' +import { useDeviceMercure } from '@/composables/useDeviceMercure' +import { useDevicesStore } from '@/stores/devices' +import type { Device } from '@/types' + +// ── EventSource fake ───────────────────────────────────────────────────────── +// happy-dom doesn't ship one, so we install a small fake on `window` for these +// tests. Each instance is captured so individual tests can assert open/close, +// inject onmessage payloads, and trigger reconnect-on-error flows. + +interface FakeEventSource { + url: string + withCredentials: boolean + readyState: number + onmessage: ((ev: MessageEvent) => void) | null + onerror: ((ev: Event) => void) | null + close: ReturnType +} + +let instances: FakeEventSource[] = [] + +class FakeES { + url: string + withCredentials: boolean + readyState = 1 // OPEN + onmessage: ((ev: MessageEvent) => void) | null = null + onerror: ((ev: Event) => void) | null = null + close = vi.fn(() => { this.readyState = 2 /* CLOSED */ }) + constructor(url: string, init?: { withCredentials?: boolean }) { + this.url = url + this.withCredentials = init?.withCredentials ?? false + instances.push(this as unknown as FakeEventSource) + } + static readonly CLOSED = 2 +} + +const makeDevice = (overrides: Partial = {}): Device => ({ + id: 1, + mac: 'AA', + name: 'Den', + orientation: 'landscape', + rotationIntervalMinutes: 60, + wakeTimes: [], + timezone: 'UTC', + uniquenessWindow: 30, + rotationMode: 'oldest_upload', + prioritizeNeverShown: false, + linkedAt: '2026-01-01T00:00:00Z', + lastSeenAt: null, + nextPollExpectedAt: null, + lockedImageId: null, + currentImageId: null, + ...overrides, +}) + +// Mounts a tiny test component that calls the composable in setup so onMounted +// hooks fire and onUnmounted cleanup is wired through @vue/test-utils. +function mountWithComposable() { + const Comp = defineComponent({ + setup() { useDeviceMercure() }, + render() { return h('div') }, + }) + return mount(Comp) +} + +describe('useDeviceMercure', () => { + beforeEach(() => { + instances = [] + vi.useFakeTimers() + vi.stubGlobal('EventSource', FakeES) + vi.stubGlobal('__PF_MERCURE_URL__' as never, undefined as never) + Object.defineProperty(window, '__PF_MERCURE_URL__', { + value: 'https://hub.example/.well-known/mercure', + configurable: true, + writable: true, + }) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('opens one EventSource per device, with the device topic in the URL', async () => { + const store = useDevicesStore() + store.devices = [makeDevice({ id: 7 }), makeDevice({ id: 9 })] + + mountWithComposable() + await flushPromises() + + expect(instances).toHaveLength(2) + const urls = instances.map(i => i.url) + expect(urls.some(u => u.includes('topic=https%3A%2F%2Fpictureframe.edholm.me%2Fdevices%2F7'))).toBe(true) + expect(urls.some(u => u.includes('topic=https%3A%2F%2Fpictureframe.edholm.me%2Fdevices%2F9'))).toBe(true) + expect(instances[0].withCredentials).toBe(true) + }) + + it('merges incoming messages into the matching device store entry', async () => { + const store = useDevicesStore() + store.devices = [makeDevice({ id: 7, name: 'Old Name' })] + + mountWithComposable() + await flushPromises() + + expect(instances).toHaveLength(1) + instances[0].onmessage?.(new MessageEvent('message', { + data: JSON.stringify(makeDevice({ id: 7, name: 'New Name' })), + })) + await flushPromises() + + expect(store.devices[0].name).toBe('New Name') + }) + + it('ignores messages for a device id not in the store (stale subscription)', async () => { + const store = useDevicesStore() + store.devices = [makeDevice({ id: 7, name: 'Original' })] + + mountWithComposable() + await flushPromises() + + instances[0].onmessage?.(new MessageEvent('message', { + data: JSON.stringify(makeDevice({ id: 999, name: 'Phantom' })), + })) + await flushPromises() + + expect(store.devices[0].name).toBe('Original') + expect(store.devices).toHaveLength(1) + }) + + it('warns instead of throwing on malformed JSON', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const store = useDevicesStore() + store.devices = [makeDevice({ id: 7 })] + + mountWithComposable() + await flushPromises() + + instances[0].onmessage?.(new MessageEvent('message', { data: 'not json' })) + await flushPromises() + + expect(warn).toHaveBeenCalled() + }) + + it('reconnects after a CLOSED error event', async () => { + const store = useDevicesStore() + store.devices = [makeDevice({ id: 7 })] + + mountWithComposable() + await flushPromises() + expect(instances).toHaveLength(1) + + // Simulate the hub closing the stream — onerror fires with readyState=CLOSED. + instances[0].readyState = FakeES.CLOSED + instances[0].onerror?.(new Event('error')) + await flushPromises() + + // Composable schedules a 5s reconnect. + vi.advanceTimersByTime(5000) + await flushPromises() + + expect(instances).toHaveLength(2) // a fresh connection was opened + }) + + it('closes EventSources for devices that disappear from the store', async () => { + const store = useDevicesStore() + store.devices = [makeDevice({ id: 7 }), makeDevice({ id: 9 })] + + mountWithComposable() + await flushPromises() + expect(instances).toHaveLength(2) + + // Remove device 9 — the watch should close that EventSource. + store.devices = store.devices.filter(d => d.id !== 9) + await flushPromises() + + const closed = instances.filter(i => i.close.mock.calls.length > 0) + expect(closed).toHaveLength(1) + }) + + it('opens new EventSources when a device is added to the store', async () => { + const store = useDevicesStore() + store.devices = [makeDevice({ id: 7 })] + + mountWithComposable() + await flushPromises() + expect(instances).toHaveLength(1) + + store.devices = [...store.devices, makeDevice({ id: 9 })] + await flushPromises() + + expect(instances).toHaveLength(2) + }) + + it('no-ops when MERCURE_URL is not configured', async () => { + Object.defineProperty(window, '__PF_MERCURE_URL__', { value: undefined, configurable: true }) + const store = useDevicesStore() + store.devices = [makeDevice({ id: 7 })] + + mountWithComposable() + await flushPromises() + + expect(instances).toHaveLength(0) + }) + + it('closes all EventSources on unmount', async () => { + const store = useDevicesStore() + store.devices = [makeDevice({ id: 7 }), makeDevice({ id: 9 })] + + const wrapper = mountWithComposable() + await flushPromises() + expect(instances).toHaveLength(2) + + wrapper.unmount() + await flushPromises() + + expect(instances.every(i => i.close.mock.calls.length > 0)).toBe(true) + }) +}) diff --git a/frontend/src/test/views/HomeView.test.ts b/frontend/src/test/views/HomeView.test.ts index 05a5229..409c5a1 100644 --- a/frontend/src/test/views/HomeView.test.ts +++ b/frontend/src/test/views/HomeView.test.ts @@ -556,6 +556,106 @@ describe('HomeView', () => { })) }) + // Editing the AM/PM dropdown for a wake time exercises onTimePart's + // 'p' branch — flips the stored minutes-since-midnight by 12 hours. + it('switching AM to PM on a wake time updates the stored time by +12h', async () => { + const devicesStore = useDevicesStore() + // Start with 6 AM (= 360 min). Flipping AM→PM should make it 6 PM (= 1080). + devicesStore.devices = [makeDevice({ id: 5, wakeTimes: [6 * 60] })] + vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() + vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 })) + + const wrapper = mountView() + await flushPromises() + await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5) + await flushPromises() + + const ampmSelect = wrapper.find('select[aria-label="AM or PM"]') + ;(ampmSelect.element as HTMLSelectElement).value = 'PM' + await ampmSelect.trigger('change') + + const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' }) + .find(b => b.text().toLowerCase().includes('sav'))! + await saveBtn.trigger('click') + await flushPromises() + expect(devicesStore.updateDevice).toHaveBeenCalledWith(5, expect.objectContaining({ + wakeTimes: [18 * 60], + })) + }) + + // Editing the minute dropdown for a wake time exercises onTimePart's + // 'mm' branch — preserves the hour and AM/PM, only minutes change. + it('changing the minutes dropdown updates the stored time without changing the hour', async () => { + const devicesStore = useDevicesStore() + // Start at 6:00 AM. Set minute to 30 → 6:30 AM = 390. + devicesStore.devices = [makeDevice({ id: 5, wakeTimes: [6 * 60] })] + vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() + vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 })) + + const wrapper = mountView() + await flushPromises() + await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5) + await flushPromises() + + const minuteSelect = wrapper.find('select[aria-label="Minutes"]') + ;(minuteSelect.element as HTMLSelectElement).value = '30' + await minuteSelect.trigger('change') + + const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' }) + .find(b => b.text().toLowerCase().includes('sav'))! + await saveBtn.trigger('click') + await flushPromises() + expect(devicesStore.updateDevice).toHaveBeenCalledWith(5, expect.objectContaining({ + wakeTimes: [6 * 60 + 30], + })) + }) + + // The "next update" preview's legacy fallback path: device has lastSeenAt + // but no nextPollExpectedAt yet (e.g. polled once before that column was + // added). Should still produce a sensible label using lastSeenAt + interval. + it('next-update preview falls back to lastSeenAt+interval when nextPollExpectedAt is null', async () => { + const devicesStore = useDevicesStore() + devicesStore.devices = [makeDevice({ + id: 5, + wakeTimes: [], + rotationIntervalMinutes: 5, + // 1 minute ago — fallback path computes lastSeen+5min, so ~4 min away. + lastSeenAt: new Date(Date.now() - 60_000).toISOString(), + nextPollExpectedAt: null, + })] + vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() + + const wrapper = mountView() + await flushPromises() + await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5) + await flushPromises() + + const preview = wrapper.find('.home-view__next-update').text() + expect(preview).toMatch(/in ~?[34] min/) + }) + + // Same fallback for wakeTimes-mode devices on the legacy path. + it('next-update preview falls back to wakeTimes-after-lastSeen when nextPollExpectedAt is null', async () => { + const devicesStore = useDevicesStore() + devicesStore.devices = [makeDevice({ + id: 5, + wakeTimes: [4 * 60], + timezone: 'UTC', + // 1h ago means "today's 4 AM has passed" (most days), so next is tomorrow's 4 AM. + lastSeenAt: new Date(Date.now() - 60 * 60_000).toISOString(), + nextPollExpectedAt: null, + })] + vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() + + const wrapper = mountView() + await flushPromises() + await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5) + await flushPromises() + + const preview = wrapper.find('.home-view__next-update').text() + expect(preview).toMatch(/4 AM/) + }) + it('switching the mode dropdown to interval saves rotationIntervalMinutes and clears wakeTimes', async () => { const devicesStore = useDevicesStore() devicesStore.devices = [makeDevice({ diff --git a/phpunit.dist.xml b/phpunit.dist.xml index 8e4b789..d9a4b43 100644 --- a/phpunit.dist.xml +++ b/phpunit.dist.xml @@ -23,6 +23,13 @@ + + + + + + + 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; + } +} diff --git a/tests/Unit/Service/MercurePublisherTest.php b/tests/Unit/Service/MercurePublisherTest.php new file mode 100644 index 0000000..9939046 --- /dev/null +++ b/tests/Unit/Service/MercurePublisherTest.php @@ -0,0 +1,55 @@ +createMock(HubInterface::class); + $hub->expects($this->once()) + ->method('publish') + ->willReturnCallback(function (Update $u) use (&$captured) { + $captured = $u; + return 'urn:uuid:test'; + }); + + $publisher = new MercurePublisher($hub, new NullLogger()); + $publisher->publishDevice(42, ['id' => 42, 'name' => 'Living Room']); + + $this->assertNotNull($captured); + $this->assertSame( + ['https://pictureframe.edholm.me/devices/42'], + $captured->getTopics(), + ); + $this->assertSame('{"id":42,"name":"Living Room"}', $captured->getData()); + } + + public function test_swallows_hub_exceptions_so_callers_never_blow_up(): void + { + $hub = $this->createMock(HubInterface::class); + $hub->method('publish')->willThrowException(new \RuntimeException('hub down')); + + $publisher = new MercurePublisher($hub, new NullLogger()); + + // No exception should escape — if this throws, the test fails. + $publisher->publishDevice(42, ['id' => 42]); + $this->expectNotToPerformAssertions(); + } +} diff --git a/tests/Unit/Service/RotationServiceTest.php b/tests/Unit/Service/RotationServiceTest.php index a6c42f9..9254f17 100644 --- a/tests/Unit/Service/RotationServiceTest.php +++ b/tests/Unit/Service/RotationServiceTest.php @@ -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'); + } }