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:
@@ -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' },
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user