test: close coverage gaps from the recent rotation + Mercure work
CI / test (push) Has been cancelled
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:
@@ -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<typeof vi.fn>
|
||||||
|
}
|
||||||
|
|
||||||
|
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> = {}): 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 () => {
|
it('switching the mode dropdown to interval saves rotationIntervalMinutes and clears wakeTimes', async () => {
|
||||||
const devicesStore = useDevicesStore()
|
const devicesStore = useDevicesStore()
|
||||||
devicesStore.devices = [makeDevice({
|
devicesStore.devices = [makeDevice({
|
||||||
|
|||||||
@@ -23,6 +23,13 @@
|
|||||||
</testsuite>
|
</testsuite>
|
||||||
</testsuites>
|
</testsuites>
|
||||||
|
|
||||||
|
<!-- pcov ships with the php image; pass coverage flags on the CLI to enable. -->
|
||||||
|
<coverage pathCoverage="false">
|
||||||
|
<report>
|
||||||
|
<text outputFile="php://stdout" showOnlySummary="true"/>
|
||||||
|
</report>
|
||||||
|
</coverage>
|
||||||
|
|
||||||
<source ignoreSuppressionOfDeprecations="true"
|
<source ignoreSuppressionOfDeprecations="true"
|
||||||
ignoreIndirectDeprecations="true"
|
ignoreIndirectDeprecations="true"
|
||||||
restrictNotices="true"
|
restrictNotices="true"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Service;
|
||||||
|
|
||||||
|
use App\Service\MercurePublisher;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use Symfony\Component\Mercure\HubInterface;
|
||||||
|
use Symfony\Component\Mercure\Update;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MercurePublisher's contract is small but load-bearing:
|
||||||
|
* 1. The topic format includes the device id (the SPA subscribes by id).
|
||||||
|
* 2. The payload is published as JSON.
|
||||||
|
* 3. A throwing hub MUST NOT propagate — a flaky Mercure container can
|
||||||
|
* never break a poll response or a settings PATCH for the user.
|
||||||
|
*/
|
||||||
|
class MercurePublisherTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_publishes_to_device_topic_with_json_payload(): void
|
||||||
|
{
|
||||||
|
$captured = null;
|
||||||
|
$hub = $this->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ use App\Entity\RenderedAsset;
|
|||||||
use App\Enum\DeviceModel;
|
use App\Enum\DeviceModel;
|
||||||
use App\Enum\Orientation;
|
use App\Enum\Orientation;
|
||||||
use App\Enum\RenderStatus;
|
use App\Enum\RenderStatus;
|
||||||
|
use App\Enum\RotationMode;
|
||||||
use App\Service\RotationService;
|
use App\Service\RotationService;
|
||||||
use App\Tests\AppKernelTestCase;
|
use App\Tests\AppKernelTestCase;
|
||||||
|
|
||||||
@@ -265,4 +266,191 @@ class RotationServiceTest extends AppKernelTestCase
|
|||||||
|
|
||||||
$this->assertNull($result);
|
$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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user