d11ddff912
CI / test (push) Has been cancelled
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>
717 lines
28 KiB
TypeScript
717 lines
28 KiB
TypeScript
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' }))
|
||
})
|
||
})
|