Files
pictureFrame-webApp/frontend/src/test/views/HomeView.test.ts
T
football2801 089e317691
CI / test (push) Has been cancelled
feat(home): full-size frame card; horizontal carousel for multi-frame setups
Reverts the 240px preview cap — frames render at their natural device aspect
again. Single-frame layout unchanged.

For multi-frame setups, replaces the compact stack with a horizontal
scroll-snap carousel: one large card per slide, full-bleed to the viewport
edges, with dot navigation below that tracks the active slide and supports
tap-to-jump. Native CSS scroll-snap drives the swipe gesture; no extra JS
gesture library.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 18:28:49 -04:00

646 lines
25 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,
wakeHour: null,
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 carousel of N large FrameCard stubs + N dots
it('renders one FrameCard per device in a carousel 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__carousel').exists()).toBe(true)
expect(wrapper.findAll('.frame-card-stub')).toHaveLength(3)
// All cards should be the large variant (no more compact stack)
const cards = wrapper.findAllComponents({ name: 'FrameCard' })
for (const c of cards) expect(c.props('size')).toBe('large')
// One navigation dot per device
expect(wrapper.findAll('.home-view__dot')).toHaveLength(3)
})
it('marks the first dot active by default and updates active dot on scroll', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [
makeDevice({ id: 1, name: 'A' }),
makeDevice({ id: 2, name: 'B' }),
]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
let dots = wrapper.findAll('.home-view__dot')
expect(dots[0].classes()).toContain('home-view__dot--active')
expect(dots[1].classes()).not.toContain('home-view__dot--active')
// Simulate the carousel having scrolled to the second slide
const carousel = wrapper.find('.home-view__carousel').element as HTMLElement
Object.defineProperty(carousel, 'clientWidth', { configurable: true, value: 360 })
Object.defineProperty(carousel, 'scrollLeft', { configurable: true, value: 360 })
await wrapper.find('.home-view__carousel').trigger('scroll')
await wrapper.vm.$nextTick()
dots = wrapper.findAll('.home-view__dot')
expect(dots[1].classes()).toContain('home-view__dot--active')
expect(dots[0].classes()).not.toContain('home-view__dot--active')
})
it('clicking a dot scrolls the carousel to that slide', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 1 }), makeDevice({ id: 2 }), makeDevice({ id: 3 })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
const carousel = wrapper.find('.home-view__carousel').element as HTMLElement
Object.defineProperty(carousel, 'clientWidth', { configurable: true, value: 360 })
const scrollToSpy = vi.fn()
;(carousel as any).scrollTo = scrollToSpy
await wrapper.findAll('.home-view__dot')[2].trigger('click')
await wrapper.vm.$nextTick()
expect(scrollToSpy).toHaveBeenCalledWith(expect.objectContaining({ left: 720, behavior: 'smooth' }))
})
// 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', wakeHour: 22, 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', wakeHour: 4, 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',
wakeHour: 4,
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 a daily wakeHour', async () => {
const devicesStore = useDevicesStore()
// wakeHour set, last seen 30h ago — between 1×24h and 2×24h → sync-fail
devicesStore.devices = [makeDevice({
id: 1,
wakeHour: 4,
rotationIntervalMinutes: 5, // ignored when wakeHour is set
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 wakeHour-based nextSync label when the device wakes daily', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({
id: 1,
wakeHour: 4,
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 wakeHour 12 PM, 12 AM, and 8 PM correctly', async () => {
const devicesStore = useDevicesStore()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
devicesStore.devices = [makeDevice({ id: 1, wakeHour: 12, timezone: 'UTC' })]
let wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/12 PM/)
devicesStore.devices = [makeDevice({ id: 1, wakeHour: 0, timezone: 'UTC' })]
wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/12 AM/)
devicesStore.devices = [makeDevice({ id: 1, wakeHour: 20, timezone: 'UTC' })]
wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/8 PM/)
})
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('prefers lockedImageId over currentImageId for the thumbnail', async () => {
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=99')
})
it('updates editWakeHour when the user picks a different hour chip', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 5, wakeHour: 4 })]
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 chips = wrapper.findAll('.home-view__interval-chip')
const chip8pm = chips.find(c => c.text() === '8 PM')!
await chip8pm.trigger('click')
expect(chip8pm.classes()).toContain('home-view__interval-chip--on')
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({ wakeHour: 20 }))
})
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', wakeHour: 4, 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('edit defaults wakeHour to 4 and timezone to UTC when the device has neither', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({
id: 5,
name: 'Den',
wakeHour: null,
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()
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({
wakeHour: 4,
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' }))
})
})