test: tighten coverage to 99.69% backend / 98.62% frontend
CI / test (push) Has been cancelled

Started: 89.08% backend / 97.01% frontend lines.
Landed: 99.69% backend / 98.62% frontend.

Closed gaps targeted at logic gates, branches, and assumption boundaries
that real users hit. Each test exercises a use case the production code
actually serves; nothing here is line-padding.

Backend additions:
  - DeviceModelTest: pin landscape vs portrait dimension swap, plus the
    nativeWidth/Height "ignore orientation" contract the firmware relies on.
  - DeviceApiControllerTest: validation branches the PWA forms can't
    even produce (raw API misuse) — non-array wakeTimes, non-int entries,
    invalid rotation mode, invalid timezone, empty name, invalid orientation,
    other-user PATCH returns 404. Plus full /preview coverage: 404 for
    other-user / no-current / no-asset / missing-file / soft-deleted, and
    happy paths for landscape AND portrait (the rotateImage(90) branch).
  - ImageApiControllerTest: cropOrientation now exercised on both upload
    and reprocess paths.
  - TokenActionControllerTest: TK-01c covers the bad-device-id "continue"
    branch in submit.
  - RenderImageMessageHandlerTest: explicit portrait test pins the
    rotateImage(-90) branch and the 192,000-byte EPD-native bin shape.
  - SeedFakeDevicesCommandTest: 4 cases covering missing-user, fresh
    create, idempotent re-run, and --remove path. The dev seed command
    is load-bearing for the multi-frame UI; a silent break would surface
    a week later.
  - RerenderAssetsCommandTest: reset + dispatch path, no-assets path.

Frontend additions:
  - FrameCardTest: lastSync-only and nextSync-only rendering branches.
  - HomeView.test:
    * + Add time fallback path when all 9 default candidates are taken.
    * Multi-day "in Nd" nextSync formatting (offline / huge-interval case).
    * Medium-horizon (5h) nextSync formats as clock-time + day label.
    * visibilitychange triggers a silent re-fetch.
    * add-photo handler creates input + navigates to /upload after pick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 14:22:46 -04:00
parent 2a8bf3895f
commit a9ad014bd1
9 changed files with 712 additions and 0 deletions
@@ -53,6 +53,29 @@ describe('FrameCard', () => {
expect(wrapper.find('.frame-card__sync-line').exists()).toBe(false)
})
// Cover the lastSync-without-nextSync branch: only the "synced X ago" span
// renders, no separator, no nextSync.
it('renders only lastSync when nextSync is absent', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, lastSync: '5m ago' },
})
const sync = wrapper.find('.frame-card__sync-line')
expect(sync.text()).toContain('synced 5m ago')
expect(sync.find('.frame-card__sync-sep').exists()).toBe(false)
})
// Inverse: nextSync without lastSync (a never-seen device that already has
// wakeTimes configured — the card should still preview the next slot).
it('renders only nextSync when lastSync is absent', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, nextSync: 'next sync ~6 AM tomorrow' },
})
const sync = wrapper.find('.frame-card__sync-line')
expect(sync.exists()).toBe(true)
expect(sync.text()).toContain('next sync ~6 AM tomorrow')
expect(sync.text()).not.toContain('synced')
})
it('applies offline modifier class when status is offline', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, status: 'offline' },
+129
View File
@@ -533,6 +533,41 @@ describe('HomeView', () => {
// list and stay there during editing. Sorting only happens at save time
// (server-side via setWakeTimes), so the user can always see "the one I
// just added" without it jumping around.
// When all 9 DEFAULT_TIME_CANDIDATES are already in the list, addTime falls
// through to the 5-minute-step fallback loop. Pre-fill with all 9 defaults
// so the next +Add hits that branch.
it('+ Add time falls back to the next free 5-minute slot when defaults are exhausted', async () => {
const devicesStore = useDevicesStore()
const allDefaults = [
9 * 60, 18 * 60, 12 * 60, 21 * 60, 6 * 60,
15 * 60, 7 * 60 + 30, 19 * 60 + 30, 0,
]
devicesStore.devices = [makeDevice({ id: 5, wakeTimes: allDefaults })]
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 before = wrapper.findAll('.home-view__time-row').length
await wrapper.find('.home-view__time-add').trigger('click')
await flushPromises()
// Fallback added one more (the next free 5-min slot — first one not in
// the defaults set, which is `5` minutes since 0 is already there).
expect(wrapper.findAll('.home-view__time-row')).toHaveLength(before + 1)
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: [...allDefaults, 5],
}))
})
it('+ Add time appends to the end and does NOT reorder existing entries', async () => {
const devicesStore = useDevicesStore()
// 8 PM is later than the first default candidate (9 AM). If the list
@@ -686,6 +721,100 @@ describe('HomeView', () => {
// 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.
// Multi-day nextSync (> 24h) — covers the "in Nd" return at the bottom
// of nextSyncLabel. Realistically a device that's been offline / never
// configured a wake time and has a huge interval set.
it('nextSync formats multi-day delays as "in Nd"', async () => {
const devicesStore = useDevicesStore()
const threeDaysOut = new Date(Date.now() + 3 * 24 * 60 * 60_000).toISOString()
devicesStore.devices = [makeDevice({
id: 1,
wakeTimes: [],
rotationIntervalMinutes: 3 * 24 * 60,
lastSeenAt: new Date().toISOString(),
nextPollExpectedAt: threeDaysOut,
})]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/in 3d/)
})
// Long-horizon nextSync: 5 hours away — covers the 1h-to-24h branch in
// nextSyncLabel that picks clock-time formatting over "in 5h".
it('nextSync formats medium-horizon delays with a clock time + day label', async () => {
const devicesStore = useDevicesStore()
const fiveHoursOut = new Date(Date.now() + 5 * 60 * 60_000).toISOString()
devicesStore.devices = [makeDevice({
id: 1,
wakeTimes: [],
rotationIntervalMinutes: 5 * 60,
lastSeenAt: new Date().toISOString(),
nextPollExpectedAt: fiveHoursOut,
timezone: 'UTC',
})]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const next = wrapper.findComponent({ name: 'FrameCard' }).props('nextSync') as string
// Should NOT be the short-horizon "in Xm" form — we want clock-time.
expect(next).toMatch(/^next sync ~\d/)
// Day label is either today or tomorrow depending on wall-clock at run time.
expect(next).toMatch(/(today|tomorrow)/)
})
// Visibility-change handler triggers a silent fetchDevices when the PWA
// returns to the foreground.
it('foreground visibility-change re-fetches devices silently', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 1 })]
const fetchSpy = vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
mountView()
await flushPromises()
fetchSpy.mockClear() // ignore the onMounted call
Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true })
document.dispatchEvent(new Event('visibilitychange'))
await flushPromises()
expect(fetchSpy).toHaveBeenCalledWith({ silent: true })
})
// Add-photo handler creates a hidden file input and (on file pick) navigates
// to /upload with the staged file in the upload store.
it('add-photo opens a file picker and navigates after a file is chosen', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 7 })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
let capturedInput: HTMLInputElement | null = null
const origCreate = document.createElement.bind(document)
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
const el = origCreate(tag) as HTMLInputElement
if (tag === 'input') capturedInput = el
return el
})
const wrapper = mountView()
await flushPromises()
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('add-photo', 7)
expect(capturedInput).not.toBeNull()
expect(capturedInput!.type).toBe('file')
// Simulate the user picking a file.
const file = new File(['x'], 'pic.jpg', { type: 'image/jpeg' })
Object.defineProperty(capturedInput!, 'files', { value: [file], configurable: true })
capturedInput!.onchange?.(new Event('change'))
await flushPromises()
expect(routerPush).toHaveBeenCalledWith('/upload')
})
it('next-update preview falls back to lastSeenAt+interval when nextPollExpectedAt is null', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({