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: '
', props: ['deviceId', 'name', 'size', 'status', 'orientation', 'thumbnailUrl', 'lastSync', 'nextSync'], emits: ['add-photo', 'edit'], }, })) vi.mock('@/components/BaseBottomSheet.vue', () => ({ default: { name: 'BaseBottomSheet', template: '
', props: ['modelValue', 'label'], emits: ['update:modelValue'], }, })) vi.mock('@/components/BaseButton.vue', () => ({ default: { name: 'BaseButton', template: '', props: ['variant', 'disabled'], }, })) vi.mock('@/components/BaseInput.vue', () => ({ default: { name: 'BaseInput', template: '', props: ['modelValue', 'label', 'maxlength'], emits: ['update:modelValue'], }, })) vi.mock('@/components/OrientationPicker.vue', () => ({ default: { name: 'OrientationPicker', template: '
', 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 => ({ 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 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' })) }) })