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 () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({
|
||||
|
||||
Reference in New Issue
Block a user