Files
pictureFrame-webApp/frontend/src/test/views/HomeView.test.ts
T
football2801 d11ddff912
CI / test (push) Has been cancelled
feat(device): replace daily wakeHour with multi-time wakeTimes (minutes)
Frame settings now offer two update-frequency modes: "at specific times" or
"every X minutes". Times are stored as an int[] of minutes-since-midnight,
allowing multiple slots per day at minute granularity. Backend computes the
earliest upcoming slot for X-Interval-Ms and uses the most-recent-past slot
as the rotation-due boundary. PWA settings sheet has hour/minute/AM-PM
dropdowns with + Add / trash, a live "next update" preview, and a note
that changes only take effect at the device's next sync.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:32:58 -04:00

717 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { useDevicesStore } from '@/stores/devices'
import { useUploadStore } from '@/stores/upload'
import HomeView from '@/views/HomeView.vue'
import type { Device } from '@/types'
const routerPush = vi.fn()
// Stub heavy child components so tests focus on HomeView logic
vi.mock('@/components/FrameCard.vue', () => ({
default: {
name: 'FrameCard',
template: '<div class="frame-card-stub" :data-device-id="deviceId" :data-name="name" />',
props: ['deviceId', 'name', 'size', 'status', 'orientation', 'thumbnailUrl', 'lastSync', 'nextSync'],
emits: ['add-photo', 'edit'],
},
}))
vi.mock('@/components/BaseBottomSheet.vue', () => ({
default: {
name: 'BaseBottomSheet',
template: '<div><slot /></div>',
props: ['modelValue', 'label'],
emits: ['update:modelValue'],
},
}))
vi.mock('@/components/BaseButton.vue', () => ({
default: {
name: 'BaseButton',
template: '<button><slot /></button>',
props: ['variant', 'disabled'],
},
}))
vi.mock('@/components/BaseInput.vue', () => ({
default: {
name: 'BaseInput',
template: '<input />',
props: ['modelValue', 'label', 'maxlength'],
emits: ['update:modelValue'],
},
}))
vi.mock('@/components/OrientationPicker.vue', () => ({
default: {
name: 'OrientationPicker',
template: '<div />',
props: ['modelValue'],
emits: ['update:modelValue'],
},
}))
// Stub vue-router so HomeView can call useRouter() without a real router
vi.mock('vue-router', () => ({
useRouter: () => ({ push: routerPush }),
}))
// Stub URL.createObjectURL used by upload store
vi.stubGlobal('URL', {
createObjectURL: vi.fn(() => 'blob:mock-url'),
revokeObjectURL: vi.fn(),
})
const makeDevice = (overrides: Partial<Device> = {}): Device => ({
id: 1,
mac: 'AA:BB:CC:DD:EE:FF',
name: 'Living Room',
orientation: 'landscape',
rotationIntervalMinutes: 60,
wakeTimes: [],
timezone: 'America/Chicago',
uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null,
lockedImageId: null,
currentImageId: null,
...overrides,
})
describe('HomeView', () => {
let pinia: ReturnType<typeof createPinia>
beforeEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
pinia = createPinia()
setActivePinia(pinia)
// Re-stub URL after unstubAllGlobals
vi.stubGlobal('URL', {
createObjectURL: vi.fn(() => 'blob:mock-url'),
revokeObjectURL: vi.fn(),
})
// Stub fetch so onMounted fetchDevices doesn't fail
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
}))
})
function mountView() {
return mount(HomeView, {
global: {
plugins: [pinia],
},
})
}
// HV-01: N devices renders a vertical stack of N large FrameCard stubs
it('renders one FrameCard per device in a vertical stack when multiple devices present', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [
makeDevice({ id: 1, name: 'Frame A' }),
makeDevice({ id: 2, name: 'Frame B' }),
makeDevice({ id: 3, name: 'Frame C' }),
]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
expect(wrapper.find('.home-view__stack').exists()).toBe(true)
expect(wrapper.findAll('.home-view__slide')).toHaveLength(3)
expect(wrapper.findAll('.frame-card-stub')).toHaveLength(3)
// All cards should be the large variant (no compact / no carousel)
const cards = wrapper.findAllComponents({ name: 'FrameCard' })
for (const c of cards) expect(c.props('size')).toBe('large')
})
it('labels each slide with the device name for accessibility', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [
makeDevice({ id: 1, name: 'Living Room' }),
makeDevice({ id: 2, name: 'Bedroom' }),
]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
const slides = wrapper.findAll('.home-view__slide')
expect(slides[0].attributes('aria-label')).toBe('Living Room')
expect(slides[1].attributes('aria-label')).toBe('Bedroom')
})
// HV-01b: single device still renders one FrameCard (large variant branch)
it('renders one FrameCard for a single device', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 1, name: 'Solo Frame' })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
const cards = wrapper.findAll('.frame-card-stub')
expect(cards).toHaveLength(1)
})
// HV-02: empty state shown when no devices
it('shows empty state when devices list is empty', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = []
devicesStore.loading = false
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
expect(wrapper.find('.home-view__empty').exists()).toBe(true)
expect(wrapper.text()).toContain('Set up your first frame')
})
// HV-03: loading state shown while fetching
it('shows loading indicator when store is loading', async () => {
const devicesStore = useDevicesStore()
devicesStore.loading = true
// Keep fetchDevices pending so loading stays true
vi.spyOn(devicesStore, 'fetchDevices').mockReturnValue(new Promise(() => {}))
const wrapper = mountView()
await wrapper.vm.$nextTick()
expect(wrapper.find('.home-view__loading').exists()).toBe(true)
expect(wrapper.text()).toContain('Loading')
})
// HV-04: add-photo opens a file picker, primes the upload store, and navigates
it('add-photo from a FrameCard primes upload state and routes to /upload', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 7, name: 'Hall' })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
routerPush.mockClear()
// Spy on createElement so we can intercept the synthetic file input
const realCreate = document.createElement.bind(document)
let capturedInput: HTMLInputElement | null = null
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
const el = realCreate(tag)
if (tag === 'input') {
capturedInput = el as HTMLInputElement
// Don't actually open a file dialog
;(el as HTMLInputElement).click = vi.fn()
}
return el
})
const wrapper = mountView()
await flushPromises()
const card = wrapper.findComponent({ name: 'FrameCard' })
await card.vm.$emit('add-photo', 7)
expect(capturedInput).not.toBeNull()
expect(capturedInput!.type).toBe('file')
const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' })
Object.defineProperty(capturedInput, 'files', { value: [file], configurable: true })
capturedInput!.onchange?.(new Event('change'))
const upload = useUploadStore()
expect(upload.originalFile).toStrictEqual(file)
expect(upload.contextDeviceId).toBe(7)
expect(routerPush).toHaveBeenCalledWith('/upload')
})
it('add-photo without a chosen file does not navigate', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 7 })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
routerPush.mockClear()
const realCreate = document.createElement.bind(document)
let capturedInput: HTMLInputElement | null = null
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
const el = realCreate(tag)
if (tag === 'input') {
capturedInput = el as HTMLInputElement
;(el as HTMLInputElement).click = vi.fn()
}
return el
})
const wrapper = mountView()
await flushPromises()
const card = wrapper.findComponent({ name: 'FrameCard' })
await card.vm.$emit('add-photo', 7)
Object.defineProperty(capturedInput, 'files', { value: [], configurable: true })
capturedInput!.onchange?.(new Event('change'))
expect(routerPush).not.toHaveBeenCalled()
})
// HV-05: edit opens the settings sheet pre-filled from the device record
it('edit emits open the settings sheet pre-populated from the device', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 9, name: 'Den', wakeTimes: [22 * 60], timezone: 'America/Chicago' })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const card = wrapper.findComponent({ name: 'FrameCard' })
await card.vm.$emit('edit', 9)
await flushPromises()
const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
expect(sheet.props('modelValue')).toBe(true)
// The name input is the first BaseInput stub
const nameInput = wrapper.findComponent({ name: 'BaseInput' })
expect(nameInput.props('modelValue')).toBe('Den')
})
it('edit for an unknown device id is a no-op', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 1 })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const card = wrapper.findComponent({ name: 'FrameCard' })
await card.vm.$emit('edit', 999)
await flushPromises()
const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
expect(sheet.props('modelValue')).toBe(false)
})
// HV-06: saving the sheet calls updateDevice and closes it
it('saving the settings sheet PATCHes via the store and closes', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 5, name: 'Old', wakeTimes: [4 * 60], timezone: 'UTC' })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const updateSpy = vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))
const wrapper = mountView()
await flushPromises()
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
await flushPromises()
// Click save (the only button in the sheet stub for now)
const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
.find(b => b.text().toLowerCase().includes('sav'))!
await saveBtn.trigger('click')
await flushPromises()
expect(updateSpy).toHaveBeenCalledWith(5, expect.objectContaining({
orientation: 'landscape',
wakeTimes: [4 * 60],
timezone: 'UTC',
}))
const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
expect(sheet.props('modelValue')).toBe(false)
})
it('passes status="ok" to the FrameCard when lastSeenAt is recent', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 1, lastSeenAt: new Date().toISOString() })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('status')).toBe('ok')
})
it('passes status="offline" when lastSeenAt is older than the rotation window', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({
id: 1,
rotationIntervalMinutes: 60,
lastSeenAt: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(),
})]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('status')).toBe('offline')
})
it('passes status="sync-fail" when one sync window has been missed but not two', async () => {
const devicesStore = useDevicesStore()
// 90 minutes since last seen, interval = 60 — between 1× and 2× → sync-fail
devicesStore.devices = [makeDevice({
id: 1,
rotationIntervalMinutes: 60,
lastSeenAt: new Date(Date.now() - 1000 * 60 * 90).toISOString(),
})]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('status')).toBe('sync-fail')
})
it('uses a 24h window for devices configured with explicit wake times', async () => {
const devicesStore = useDevicesStore()
// wakeTimes set, last seen 30h ago — between 1×24h and 2×24h → sync-fail
devicesStore.devices = [makeDevice({
id: 1,
wakeTimes: [4 * 60],
rotationIntervalMinutes: 5, // ignored when wakeTimes is non-empty
lastSeenAt: new Date(Date.now() - 1000 * 60 * 60 * 30).toISOString(),
})]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('status')).toBe('sync-fail')
})
it('passes a relative lastSync label and a nextSync label to the FrameCard', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({
id: 1,
rotationIntervalMinutes: 60,
lastSeenAt: new Date(Date.now() - 1000 * 60 * 30).toISOString(), // 30m ago
})]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const props = wrapper.findComponent({ name: 'FrameCard' }).props()
expect(props.lastSync).toMatch(/m ago/)
expect(props.nextSync).toMatch(/next sync in/)
})
it('passes a wakeTimes-based nextSync label when the device has explicit wake times', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({
id: 1,
wakeTimes: [4 * 60],
timezone: 'UTC',
lastSeenAt: new Date(Date.now() - 1000 * 60 * 60).toISOString(),
})]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/4 AM/)
})
it('formats lastSync as "yesterday" / "N days ago" / "just now"', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({
id: 1,
lastSeenAt: new Date(Date.now() - 1000 * 60 * 60 * 26).toISOString(), // ~26h
})]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
let wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('lastSync')).toBe('yesterday')
devicesStore.devices = [makeDevice({
id: 1,
lastSeenAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 4).toISOString(), // ~4 days
})]
wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('lastSync')).toMatch(/4 days ago/)
devicesStore.devices = [makeDevice({
id: 1,
lastSeenAt: new Date(Date.now() - 5_000).toISOString(), // 5 seconds
})]
wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('lastSync')).toBe('just now')
})
it('omits nextSync when an interval-based device is already past its next expected sync', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({
id: 1,
rotationIntervalMinutes: 60,
lastSeenAt: new Date(Date.now() - 1000 * 60 * 90).toISOString(), // 90m ago, already late
})]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toBeNull()
})
it('formats wake times 12 PM, 12 AM, 8 PM, and 6:30 AM correctly', async () => {
const devicesStore = useDevicesStore()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
devicesStore.devices = [makeDevice({ id: 1, wakeTimes: [12 * 60], timezone: 'UTC' })]
let wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/12 PM/)
devicesStore.devices = [makeDevice({ id: 1, wakeTimes: [0], timezone: 'UTC' })]
wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/12 AM/)
devicesStore.devices = [makeDevice({ id: 1, wakeTimes: [20 * 60], timezone: 'UTC' })]
wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/8 PM/)
devicesStore.devices = [makeDevice({ id: 1, wakeTimes: [6 * 60 + 30], timezone: 'UTC' })]
wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/6:30 AM/)
})
it('returns null lastSync when the device has no recorded last-seen time', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 1, lastSeenAt: null })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const props = wrapper.findComponent({ name: 'FrameCard' }).props()
expect(props.lastSync).toBeNull()
expect(props.nextSync).toBeNull()
})
it('builds a thumbnail URL when the device has a current image', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 1, currentImageId: 42 })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('thumbnailUrl')).toBe('/api/devices/1/preview?v=42')
})
it('always uses currentImageId for the thumbnail — lockedImageId is ignored', async () => {
// Locked-but-not-yet-pulled is the bug we explicitly fixed: the home
// preview must reflect what the frame is actually showing, not what's
// queued for the next poll.
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 1, lockedImageId: 99, currentImageId: 42 })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('thumbnailUrl')).toBe('/api/devices/1/preview?v=42')
})
it('omits the thumbnail when the device has no currentImageId, even if a lock is queued', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 1, lockedImageId: 99, currentImageId: null })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('thumbnailUrl')).toBeUndefined()
})
it('+ Add time appends a new wake time and saves it', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 5, wakeTimes: [4 * 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()
// Sheet opens in 'times' mode (because device.wakeTimes is non-empty).
// Click the + Add time button — it should add 9:00 AM (first default
// candidate not already in the list).
const addBtn = wrapper.find('.home-view__time-add')
await addBtn.trigger('click')
await flushPromises()
expect(wrapper.findAll('.home-view__time-row')).toHaveLength(2)
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: [4 * 60, 9 * 60],
}))
})
it('trash button removes a wake time from the list', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 5, wakeTimes: [6 * 60, 18 * 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 removeButtons = wrapper.findAll('.home-view__time-remove')
expect(removeButtons).toHaveLength(2)
// Remove the first row (6 AM)
await removeButtons[0].trigger('click')
await flushPromises()
expect(wrapper.findAll('.home-view__time-row')).toHaveLength(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: [18 * 60],
}))
})
it('switching the mode dropdown to interval saves rotationIntervalMinutes and clears wakeTimes', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({
id: 5,
wakeTimes: [4 * 60],
rotationIntervalMinutes: 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 modeSelect = wrapper.find('.home-view__mode-select')
;(modeSelect.element as HTMLSelectElement).value = 'interval'
await modeSelect.trigger('change')
const intervalInput = wrapper.find('.home-view__interval-input')
;(intervalInput.element as HTMLInputElement).value = '15'
await intervalInput.trigger('input')
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: [],
rotationIntervalMinutes: 15,
}))
})
it('shows the propagation note in the settings sheet', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 5 })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
await flushPromises()
expect(wrapper.find('.home-view__propagation-note').text())
.toMatch(/take effect at the next device update/i)
})
it('saving while no device is being edited is a no-op (defensive guard)', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 1 })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const updateSpy = vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice())
const wrapper = mountView()
await flushPromises()
// The BaseBottomSheet stub always renders its slot, so the Save button is in
// the DOM even before onEdit is called. Clicking it now exercises the
// editingDevice null-guard.
const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
.find(b => b.text().toLowerCase().includes('sav'))!
await saveBtn.trigger('click')
await flushPromises()
expect(updateSpy).not.toHaveBeenCalled()
})
it('updates editName/orientation/timezone when their components emit changes', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 5, name: 'Original', wakeTimes: [4 * 60], timezone: 'UTC' })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const updateSpy = vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))
const wrapper = mountView()
await flushPromises()
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
await flushPromises()
await wrapper.findComponent({ name: 'BaseInput' }).vm.$emit('update:modelValue', 'New Name')
await wrapper.findComponent({ name: 'OrientationPicker' }).vm.$emit('update:modelValue', 'portrait')
const select = wrapper.find('select.home-view__tz-select')
;(select.element as HTMLSelectElement).value = 'America/New_York'
await select.trigger('change')
const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
.find(b => b.text().toLowerCase().includes('sav'))!
await saveBtn.trigger('click')
await flushPromises()
expect(updateSpy).toHaveBeenCalledWith(5, expect.objectContaining({
name: 'New Name',
orientation: 'portrait',
timezone: 'America/New_York',
}))
})
it('opens in interval mode and defaults timezone to UTC when device has empty wakeTimes', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({
id: 5,
name: 'Den',
wakeTimes: [],
rotationIntervalMinutes: 60,
timezone: null as any,
})]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const updateSpy = vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))
const wrapper = mountView()
await flushPromises()
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
await flushPromises()
// Sheet opens in interval mode — interval input is shown, time-list is not.
expect(wrapper.find('.home-view__interval-input').exists()).toBe(true)
expect(wrapper.find('.home-view__time-add').exists()).toBe(false)
const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
.find(b => b.text().toLowerCase().includes('sav'))!
await saveBtn.trigger('click')
await flushPromises()
expect(updateSpy).toHaveBeenCalledWith(5, expect.objectContaining({
wakeTimes: [],
rotationIntervalMinutes: 60,
timezone: 'UTC',
}))
})
it('the settings sheet closes when the underlying bottom-sheet emits close', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 5 })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
await flushPromises()
const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
expect(sheet.props('modelValue')).toBe(true)
await sheet.vm.$emit('update:modelValue', false)
await wrapper.vm.$nextTick()
expect(wrapper.findComponent({ name: 'BaseBottomSheet' }).props('modelValue')).toBe(false)
})
it('saving falls back to the original name when the user clears the field', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 5, name: 'Original' })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const updateSpy = vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))
const wrapper = mountView()
await flushPromises()
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
await flushPromises()
await wrapper.findComponent({ name: 'BaseInput' }).vm.$emit('update:modelValue', ' ')
const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
.find(b => b.text().toLowerCase().includes('sav'))!
await saveBtn.trigger('click')
await flushPromises()
expect(updateSpy).toHaveBeenCalledWith(5, expect.objectContaining({ name: 'Original' }))
})
})