chore: stage all in-progress work before repo split
CI / test (push) Has been cancelled

Web app: new entities (Image, RenderedAsset, SharedImage, Token,
DeviceImageHistory), enums, repositories, controllers, message handlers,
migrations, tests, frontend upload/library/sticker UI, Vue components.

Firmware: EPD background screen binaries + gen scripts, setup_bg header.

Infra: ddev config, test bundle, gitignore coverage dir.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 12:11:31 -04:00
parent 062c52eec7
commit 12245759ac
149 changed files with 14846 additions and 92 deletions
@@ -0,0 +1,109 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import BaseButton from '@/components/BaseButton.vue'
describe('BaseButton', () => {
it('renders slot content', () => {
const wrapper = mount(BaseButton, {
slots: { default: 'Click me' },
})
expect(wrapper.text()).toContain('Click me')
})
it('renders as a <button> by default', () => {
const wrapper = mount(BaseButton, {
slots: { default: 'OK' },
})
expect(wrapper.element.tagName).toBe('BUTTON')
})
it('applies primary variant class by default', () => {
const wrapper = mount(BaseButton, { slots: { default: 'OK' } })
expect(wrapper.classes()).toContain('btn--primary')
})
it('applies the given variant class', () => {
const wrapper = mount(BaseButton, {
props: { variant: 'destructive' },
slots: { default: 'Delete' },
})
expect(wrapper.classes()).toContain('btn--destructive')
})
it('shows spinner element when loading is true', () => {
const wrapper = mount(BaseButton, {
props: { loading: true },
slots: { default: 'Saving...' },
})
expect(wrapper.find('.btn__spinner').exists()).toBe(true)
})
it('does not show spinner when loading is false', () => {
const wrapper = mount(BaseButton, {
props: { loading: false },
slots: { default: 'Save' },
})
expect(wrapper.find('.btn__spinner').exists()).toBe(false)
})
it('applies btn--loading class when loading', () => {
const wrapper = mount(BaseButton, {
props: { loading: true },
slots: { default: 'Wait' },
})
expect(wrapper.classes()).toContain('btn--loading')
})
it('is disabled when disabled prop is true', () => {
const wrapper = mount(BaseButton, {
props: { disabled: true },
slots: { default: 'Blocked' },
})
expect((wrapper.element as HTMLButtonElement).disabled).toBe(true)
})
it('is disabled when loading prop is true', () => {
const wrapper = mount(BaseButton, {
props: { loading: true },
slots: { default: 'Loading' },
})
expect((wrapper.element as HTMLButtonElement).disabled).toBe(true)
})
it('is not disabled when neither disabled nor loading', () => {
const wrapper = mount(BaseButton, {
props: { disabled: false, loading: false },
slots: { default: 'Go' },
})
expect((wrapper.element as HTMLButtonElement).disabled).toBe(false)
})
it('emits click event when clicked and not disabled', async () => {
const wrapper = mount(BaseButton, { slots: { default: 'Go' } })
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeTruthy()
})
it('type attribute defaults to button', () => {
const wrapper = mount(BaseButton, { slots: { default: 'OK' } })
expect(wrapper.attributes('type')).toBe('button')
})
it('type attribute can be set to submit', () => {
const wrapper = mount(BaseButton, {
props: { type: 'submit' },
slots: { default: 'Submit' },
})
expect(wrapper.attributes('type')).toBe('submit')
})
it('renders as an anchor when tag is a', () => {
const wrapper = mount(BaseButton, {
props: { tag: 'a' },
slots: { default: 'Link' },
})
expect(wrapper.element.tagName).toBe('A')
// <a> should not have a type attribute
expect(wrapper.attributes('type')).toBeUndefined()
})
})
@@ -0,0 +1,112 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import DevicePicker from '@/components/DevicePicker.vue'
import type { Device } from '@/types'
// Stub child components DevicePicker wraps
vi.mock('@/components/BaseBottomSheet.vue', () => ({
default: {
name: 'BaseBottomSheet',
template: '<div class="bottom-sheet-stub"><slot /></div>',
props: ['modelValue', 'label'],
emits: ['update:modelValue'],
},
}))
vi.mock('@/components/BaseButton.vue', () => ({
default: {
name: 'BaseButton',
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
props: ['variant', 'disabled'],
emits: ['click'],
},
}))
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,
...overrides,
})
describe('DevicePicker', () => {
const devices = [
makeDevice({ id: 1, name: 'Living Room' }),
makeDevice({ id: 2, name: 'Bedroom' }),
]
function mountPicker(selected: number[] = []) {
return mount(DevicePicker, {
props: {
modelValue: true,
devices,
selected,
},
})
}
// DP-01: Selecting a device emits update:selected with the device added
it('checking a device emits update:selected with device id added', async () => {
const wrapper = mountPicker([])
const checkboxes = wrapper.findAll('input[type="checkbox"]')
// Click the first checkbox (Living Room, id=1)
await checkboxes[0].trigger('change')
const emitted = wrapper.emitted('update:selected')
expect(emitted).toBeTruthy()
expect(emitted![0][0]).toEqual([1])
})
// DP-02: Deselecting a device emits update:selected with device id removed
it('unchecking a device emits update:selected with device id removed', async () => {
// Start with both selected
const wrapper = mountPicker([1, 2])
const checkboxes = wrapper.findAll('input[type="checkbox"]')
// Click the first checkbox (Living Room, id=1) — it's currently checked, so this deselects
await checkboxes[0].trigger('change')
const emitted = wrapper.emitted('update:selected')
expect(emitted).toBeTruthy()
// Should emit [2] — Living Room removed
expect(emitted![0][0]).toEqual([2])
})
// DP-03: Checkboxes reflect the selected prop
it('checkboxes are checked for ids in selected prop', async () => {
const wrapper = mountPicker([2])
const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect((checkboxes[0].element as HTMLInputElement).checked).toBe(false) // id=1 not selected
expect((checkboxes[1].element as HTMLInputElement).checked).toBe(true) // id=2 selected
})
// DP-04: Confirm button disabled when nothing selected
it('confirm button is disabled when selected is empty', async () => {
const wrapper = mountPicker([])
const btn = wrapper.find('button')
expect((btn.element as HTMLButtonElement).disabled).toBe(true)
})
// DP-05: Confirm button enabled when at least one device selected
it('confirm button is enabled when a device is selected', async () => {
const wrapper = mountPicker([1])
const btn = wrapper.find('button')
expect((btn.element as HTMLButtonElement).disabled).toBe(false)
})
// DP-06: Device names are rendered
it('renders all device names', () => {
const wrapper = mountPicker([])
expect(wrapper.text()).toContain('Living Room')
expect(wrapper.text()).toContain('Bedroom')
})
})
@@ -0,0 +1,131 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import FrameCard from '@/components/FrameCard.vue'
// Mock vue-konva to avoid canvas issues if transitively imported
vi.mock('vue-konva', () => ({}))
const defaultProps = {
deviceId: 1,
name: 'Living Room',
size: 'large' as const,
status: 'ok' as const,
orientation: 'landscape' as const,
}
describe('FrameCard', () => {
it('renders device name', () => {
const wrapper = mount(FrameCard, { props: defaultProps })
expect(wrapper.text()).toContain('Living Room')
})
it('does not show status badge when status is ok', () => {
const wrapper = mount(FrameCard, { props: defaultProps })
expect(wrapper.find('.frame-card__status-badge').exists()).toBe(false)
})
it('shows "Offline" badge when status is offline', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, status: 'offline' },
})
const badge = wrapper.find('.frame-card__status-badge')
expect(badge.exists()).toBe(true)
expect(badge.text()).toContain('Offline')
})
it('shows "Sync issue" badge when status is sync-fail', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, status: 'sync-fail' },
})
const badge = wrapper.find('.frame-card__status-badge')
expect(badge.exists()).toBe(true)
expect(badge.text()).toContain('Sync issue')
})
it('applies offline modifier class when status is offline', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, status: 'offline' },
})
expect(wrapper.classes()).toContain('frame-card--offline')
})
it('applies sync-fail modifier class when status is sync-fail', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, status: 'sync-fail' },
})
expect(wrapper.classes()).toContain('frame-card--sync-fail')
})
it('shows settings button in large size', () => {
const wrapper = mount(FrameCard, { props: { ...defaultProps, size: 'large' } })
expect(wrapper.find('.frame-card__settings-btn').exists()).toBe(true)
})
it('does not show settings button in compact size', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'compact' },
})
expect(wrapper.find('.frame-card__settings-btn').exists()).toBe(false)
})
it('shows img element when thumbnailUrl is provided', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, thumbnailUrl: '/thumb/test.jpg' },
})
const img = wrapper.find('img.frame-card__img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe('/thumb/test.jpg')
})
it('shows empty preview placeholder when no thumbnailUrl', () => {
const wrapper = mount(FrameCard, { props: defaultProps })
expect(wrapper.find('.frame-card__empty-preview').exists()).toBe(true)
expect(wrapper.find('img.frame-card__img').exists()).toBe(false)
})
it('shows photo count in compact size', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'compact', photoCount: 3 },
})
expect(wrapper.text()).toContain('3 photos')
})
it('uses singular "photo" when photoCount is 1', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'compact', photoCount: 1 },
})
expect(wrapper.text()).toContain('1 photo')
expect(wrapper.text()).not.toContain('1 photos')
})
it('emits add-photo with deviceId when add button clicked', async () => {
const wrapper = mount(FrameCard, { props: defaultProps })
await wrapper.find('.frame-card__add-btn').trigger('click')
expect(wrapper.emitted('add-photo')).toBeTruthy()
expect(wrapper.emitted('add-photo')![0]).toEqual([1])
})
it('emits edit with deviceId when settings button clicked (large)', async () => {
const wrapper = mount(FrameCard, { props: { ...defaultProps, size: 'large' } })
await wrapper.find('.frame-card__settings-btn').trigger('click')
expect(wrapper.emitted('edit')).toBeTruthy()
expect(wrapper.emitted('edit')![0]).toEqual([1])
})
it('sets landscape aspect ratio style in large mode', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'landscape' },
})
const preview = wrapper.find('.frame-card__preview')
// Vue serializes { aspectRatio: '5/3' } as 'aspect-ratio: 5 / 3;'
expect(preview.attributes('style')).toMatch(/aspect-ratio:\s*5\s*\/\s*3/)
})
it('sets portrait aspect ratio style in large mode', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'portrait' },
})
const preview = wrapper.find('.frame-card__preview')
expect(preview.attributes('style')).toMatch(/aspect-ratio:\s*3\s*\/\s*5/)
})
})
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import ShareSheet from '@/components/ShareSheet.vue'
import { useImagesStore } from '@/stores/images'
const BaseBottomSheetStub = {
template: '<div><slot /></div>',
props: ['modelValue', 'label'],
emits: ['update:modelValue'],
}
const BaseButtonStub = {
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
props: ['variant', 'disabled'],
emits: ['click'],
}
describe('ShareSheet', () => {
beforeEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
setActivePinia(createPinia())
})
function mountShareSheet(imageId = 1) {
return mount(ShareSheet, {
props: { modelValue: true, imageId },
global: {
stubs: {
BaseBottomSheet: BaseBottomSheetStub,
BaseButton: BaseButtonStub,
},
},
})
}
// SS-01: successful share shows success message and clears email field
it('shows success message and clears email on successful share', async () => {
const store = useImagesStore()
vi.spyOn(store, 'shareImage').mockResolvedValue(undefined)
const wrapper = mountShareSheet()
const input = wrapper.find('input[type="email"]')
await input.setValue('friend@example.com')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(store.shareImage).toHaveBeenCalledWith(1, 'friend@example.com')
expect(wrapper.text()).toContain('Invite sent to friend@example.com')
expect((input.element as HTMLInputElement).value).toBe('')
})
// SS-02: failed share shows error message
it('shows error message on failed share', async () => {
const store = useImagesStore()
vi.spyOn(store, 'shareImage').mockRejectedValue(new Error('Server error'))
const wrapper = mountShareSheet()
const input = wrapper.find('input[type="email"]')
await input.setValue('friend@example.com')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Server error')
expect(wrapper.find('.share-sheet__error').exists()).toBe(true)
})
// SS-03: button is disabled when email input is empty
it('button is disabled when email is empty', () => {
const wrapper = mountShareSheet()
const button = wrapper.find('button')
expect(button.attributes('disabled')).toBeDefined()
})
})
+10
View File
@@ -0,0 +1,10 @@
import { createPinia, setActivePinia } from 'pinia'
import { vi, beforeEach, afterEach } from 'vitest'
beforeEach(() => {
setActivePinia(createPinia())
})
afterEach(() => {
vi.unstubAllGlobals()
})
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import type { User } from '@/types'
const makeUser = (overrides: Partial<User> = {}): User => ({
id: 1,
email: 'test@example.com',
roles: ['ROLE_USER'],
theme: null,
timezone: 'America/Chicago',
...overrides,
})
describe('auth store', () => {
beforeEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
setActivePinia(createPinia())
})
afterEach(() => {
// unstubAllGlobals is handled in beforeEach and globally in setup.ts
})
it('isAuthenticated is false when __PF_USER__ is not set', async () => {
// No __PF_USER__ on window — should be null
vi.stubGlobal('__PF_USER__', undefined)
const { useAuthStore } = await import('@/stores/auth')
const store = useAuthStore()
expect(store.isAuthenticated).toBe(false)
expect(store.user).toBeNull()
})
it('isAuthenticated is true when user is set via setUser', async () => {
vi.stubGlobal('__PF_USER__', undefined)
const { useAuthStore } = await import('@/stores/auth')
const store = useAuthStore()
store.setUser(makeUser())
expect(store.isAuthenticated).toBe(true)
expect(store.user?.email).toBe('test@example.com')
})
it('setUser(null) clears user and isAuthenticated becomes false', async () => {
vi.stubGlobal('__PF_USER__', undefined)
const { useAuthStore } = await import('@/stores/auth')
const store = useAuthStore()
store.setUser(makeUser())
expect(store.isAuthenticated).toBe(true)
store.setUser(null)
expect(store.isAuthenticated).toBe(false)
expect(store.user).toBeNull()
})
it('bootstraps user from window.__PF_USER__ when present', async () => {
const user = makeUser({ id: 99, email: 'bootstrapped@example.com' })
// Stub window.__PF_USER__ before the store module is evaluated
vi.stubGlobal('__PF_USER__', user)
// Dynamically re-import so the store sees the stub
vi.resetModules()
const { useAuthStore } = await import('@/stores/auth')
setActivePinia(createPinia())
const store = useAuthStore()
expect(store.isAuthenticated).toBe(true)
expect(store.user?.id).toBe(99)
})
})
+158
View File
@@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useDevicesStore } from '@/stores/devices'
import type { Device } from '@/types'
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,
...overrides,
})
describe('devices store', () => {
beforeEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
setActivePinia(createPinia())
})
afterEach(() => {
// unstubAllGlobals is handled in beforeEach so stubs don't leak
// even if a test throws before afterEach runs
})
// DS-01
it('fetchDevices success populates devices and clears loading', async () => {
const mockDevices = [makeDevice()]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockDevices),
}))
const store = useDevicesStore()
await store.fetchDevices()
expect(store.devices).toEqual(mockDevices)
expect(store.loading).toBe(false)
expect(store.error).toBeNull()
})
// DS-02
it('fetchDevices network error sets error state', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network failure')))
const store = useDevicesStore()
await store.fetchDevices()
expect(store.devices).toEqual([])
expect(store.loading).toBe(false)
expect(store.error).toBe('Network failure')
})
// DS-02b — non-ok response
it('fetchDevices non-ok response sets error state', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
const store = useDevicesStore()
await store.fetchDevices()
expect(store.error).toBe('Failed to load devices')
expect(store.loading).toBe(false)
})
// DS-03
it('updateDevice patches local array entry', async () => {
const original = makeDevice({ id: 1, name: 'Old Name' })
const updated = makeDevice({ id: 1, name: 'New Name' })
const store = useDevicesStore()
store.devices = [original]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(updated),
}))
const result = await store.updateDevice(1, { name: 'New Name' })
expect(result.name).toBe('New Name')
expect(store.devices[0].name).toBe('New Name')
})
// DS-03b — updateDevice throws on failure
it('updateDevice throws on non-ok response', async () => {
const store = useDevicesStore()
store.devices = [makeDevice()]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
await expect(store.updateDevice(1, { name: 'x' })).rejects.toThrow('Failed to update device')
})
// DS-04
it('lockImage sets lockedImageId on local device', async () => {
const device = makeDevice({ id: 1, lockedImageId: null })
const locked = makeDevice({ id: 1, lockedImageId: 42 })
const store = useDevicesStore()
store.devices = [device]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(locked),
}))
const result = await store.lockImage(1, 42)
expect(result.lockedImageId).toBe(42)
expect(store.devices[0].lockedImageId).toBe(42)
})
// DS-05
it('unlockImage clears lockedImageId', async () => {
const device = makeDevice({ id: 1, lockedImageId: 42 })
const unlocked = makeDevice({ id: 1, lockedImageId: null })
const store = useDevicesStore()
store.devices = [device]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(unlocked),
}))
const result = await store.unlockImage(1)
expect(result.lockedImageId).toBeNull()
expect(store.devices[0].lockedImageId).toBeNull()
})
// DS-05b — lockImage throws on failure
it('lockImage throws on non-ok response', async () => {
const store = useDevicesStore()
store.devices = [makeDevice()]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
await expect(store.lockImage(1, 42)).rejects.toThrow('Failed to lock image')
})
// DS-05c — unlockImage throws on failure
it('unlockImage throws on non-ok response', async () => {
const store = useDevicesStore()
store.devices = [makeDevice()]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
await expect(store.unlockImage(1)).rejects.toThrow('Failed to unlock')
})
})
+165
View File
@@ -0,0 +1,165 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useImagesStore } from '@/stores/images'
import type { Image } from '@/types'
const makeImage = (overrides: Partial<Image> = {}): Image => ({
id: 1,
originalFilename: 'photo.jpg',
thumbnailUrl: '/thumb/1.jpg',
originalUrl: '/orig/1.jpg',
uploadedAt: '2026-01-01T00:00:00Z',
approvedDeviceIds: [],
cropParams: null,
stickerState: null,
...overrides,
})
describe('images store', () => {
beforeEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
setActivePinia(createPinia())
})
afterEach(() => {
// unstubAllGlobals is handled in beforeEach so stubs don't leak
// even if a test throws before afterEach runs
})
it('fetchImages success populates images and clears loading', async () => {
const mockImages = [makeImage(), makeImage({ id: 2 })]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockImages),
}))
const store = useImagesStore()
await store.fetchImages()
expect(store.images).toEqual(mockImages)
expect(store.loading).toBe(false)
expect(store.error).toBeNull()
})
it('fetchImages network error sets error state', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Net error')))
const store = useImagesStore()
await store.fetchImages()
expect(store.images).toEqual([])
expect(store.error).toBe('Net error')
expect(store.loading).toBe(false)
})
it('uploadImage prepends to images list on success', async () => {
const existing = makeImage({ id: 1 })
const newImage = makeImage({ id: 2 })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(newImage),
}))
const store = useImagesStore()
store.images = [existing]
const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' })
const result = await store.uploadImage(file)
expect(result).toEqual(newImage)
expect(store.images[0]).toEqual(newImage)
expect(store.images).toHaveLength(2)
})
it('uploadImage throws with error message on failure', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
json: () => Promise.resolve({ error: 'File too large' }),
}))
const store = useImagesStore()
const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' })
await expect(store.uploadImage(file)).rejects.toThrow('File too large')
})
it('deleteImage removes image from list', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }))
const store = useImagesStore()
store.images = [makeImage({ id: 1 }), makeImage({ id: 2 })]
await store.deleteImage(1)
expect(store.images).toHaveLength(1)
expect(store.images[0].id).toBe(2)
})
it('deleteImage throws on failure', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
const store = useImagesStore()
store.images = [makeImage()]
await expect(store.deleteImage(1)).rejects.toThrow('Delete failed')
})
it('setApproval updates image in list', async () => {
const original = makeImage({ id: 1, approvedDeviceIds: [] })
const updated = makeImage({ id: 1, approvedDeviceIds: [42] })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(updated),
}))
const store = useImagesStore()
store.images = [original]
await store.setApproval(1, 42, true)
expect(store.images[0].approvedDeviceIds).toEqual([42])
})
it('fetchPendingCount stores the count', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ count: 5 }),
}))
const store = useImagesStore()
await store.fetchPendingCount()
expect(store.pendingCount).toBe(5)
})
it('approveShared decrements pendingCount', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, status: 'approved' }),
}))
const store = useImagesStore()
store.pendingCount = 3
await store.approveShared(1, [42])
expect(store.pendingCount).toBe(2)
})
it('declineShared decrements pendingCount', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, status: 'declined' }),
}))
const store = useImagesStore()
store.pendingCount = 2
await store.declineShared(1)
expect(store.pendingCount).toBe(1)
})
})
+96
View File
@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useToastStore } from '@/stores/toast'
describe('toast store', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('show adds a message to toasts', () => {
const store = useToastStore()
store.show('Hello!')
expect(store.toasts).toHaveLength(1)
expect(store.toasts[0].message).toBe('Hello!')
expect(store.toasts[0].type).toBe('info')
})
it('show with explicit type sets correct type', () => {
const store = useToastStore()
store.show('Saved', 'success')
expect(store.toasts[0].type).toBe('success')
})
it('show with error type sets error type', () => {
const store = useToastStore()
store.show('Something broke', 'error')
expect(store.toasts[0].type).toBe('error')
})
it('multiple show calls add multiple toasts', () => {
const store = useToastStore()
store.show('First')
store.show('Second')
expect(store.toasts).toHaveLength(2)
})
it('auto-dismisses after 2500ms', () => {
const store = useToastStore()
store.show('Temporary')
expect(store.toasts).toHaveLength(1)
vi.advanceTimersByTime(2500)
expect(store.toasts).toHaveLength(0)
})
it('does not dismiss before 2500ms', () => {
const store = useToastStore()
store.show('Temporary')
vi.advanceTimersByTime(2499)
expect(store.toasts).toHaveLength(1)
})
it('dismiss removes a specific toast by id', () => {
const store = useToastStore()
store.show('First')
store.show('Second')
const id = store.toasts[0].id
store.dismiss(id)
expect(store.toasts).toHaveLength(1)
expect(store.toasts[0].message).toBe('Second')
})
it('dismiss with unknown id does nothing', () => {
const store = useToastStore()
store.show('Msg')
store.dismiss(99999)
expect(store.toasts).toHaveLength(1)
})
it('each toast gets a unique id', () => {
const store = useToastStore()
store.show('A')
store.show('B')
store.show('C')
const ids = store.toasts.map(t => t.id)
const uniqueIds = new Set(ids)
expect(uniqueIds.size).toBe(3)
})
})
+145
View File
@@ -0,0 +1,145 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useUploadStore } from '@/stores/upload'
import type { StickerLayer } from '@/types'
const makeSticker = (overrides: Partial<StickerLayer> = {}): StickerLayer => ({
id: 'sticker-1',
type: 'emoji',
x: 100,
y: 100,
scale: 1,
rotation: 0,
...overrides,
})
describe('upload store', () => {
beforeEach(() => {
vi.unstubAllGlobals()
// happy-dom has URL.createObjectURL as a stub; ensure it returns something predictable
vi.stubGlobal('URL', {
createObjectURL: vi.fn(() => 'blob:mock-url'),
revokeObjectURL: vi.fn(),
})
setActivePinia(createPinia())
})
afterEach(() => {
// unstubAllGlobals is handled in beforeEach and globally in setup.ts
})
it('init sets originalFile and originalUrl', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' })
store.init(file)
// Pinia wraps refs in a reactive proxy, use toStrictEqual for value equality
expect(store.originalFile).toStrictEqual(file)
expect(store.originalUrl).toBe('blob:mock-url')
})
it('init with deviceId sets contextDeviceId and selectedDeviceIds', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' })
store.init(file, 7)
expect(store.contextDeviceId).toBe(7)
expect(store.selectedDeviceIds).toEqual([7])
})
it('init without deviceId leaves selectedDeviceIds empty', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file)
expect(store.contextDeviceId).toBeNull()
expect(store.selectedDeviceIds).toEqual([])
})
it('setCrop stores croppedBlob and cropParams', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file)
const blob = new Blob(['crop'], { type: 'image/jpeg' })
const params = { natX: 0, natY: 0, natW: 200, natH: 200 }
store.setCrop(blob, params)
// Pinia wraps refs in a reactive proxy, use toStrictEqual for value equality
expect(store.croppedBlob).toStrictEqual(blob)
expect(store.croppedUrl).toBe('blob:mock-url')
expect(store.cropParams).toEqual(params)
})
it('addSticker appends to stickers', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file)
store.addSticker(makeSticker({ id: 'a' }))
store.addSticker(makeSticker({ id: 'b' }))
expect(store.stickers).toHaveLength(2)
expect(store.stickers[0].id).toBe('a')
expect(store.stickers[1].id).toBe('b')
})
it('updateSticker patches matching sticker', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file)
store.addSticker(makeSticker({ id: 'a', x: 10 }))
store.updateSticker('a', { x: 99 })
expect(store.stickers[0].x).toBe(99)
})
it('updateSticker leaves non-matching stickers unchanged', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file)
store.addSticker(makeSticker({ id: 'a', x: 10 }))
store.addSticker(makeSticker({ id: 'b', x: 20 }))
store.updateSticker('a', { x: 99 })
expect(store.stickers[1].x).toBe(20)
})
it('removeSticker removes by id', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file)
store.addSticker(makeSticker({ id: 'a' }))
store.addSticker(makeSticker({ id: 'b' }))
store.removeSticker('a')
expect(store.stickers).toHaveLength(1)
expect(store.stickers[0].id).toBe('b')
})
it('cleanup resets all state', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file, 5)
store.addSticker(makeSticker())
store.cleanup()
expect(store.originalFile).toBeNull()
expect(store.originalUrl).toBeNull()
expect(store.croppedBlob).toBeNull()
expect(store.croppedUrl).toBeNull()
expect(store.cropParams).toBeNull()
expect(store.stickers).toHaveLength(0)
expect(store.contextDeviceId).toBeNull()
expect(store.selectedDeviceIds).toEqual([])
expect(store.editingImageId).toBeNull()
})
})
+164
View File
@@ -0,0 +1,164 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } 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'
// 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'],
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: vi.fn() }),
}))
// 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,
...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 N FrameCard stubs
it('renders one FrameCard per device when devices are 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' }),
]
// Mock fetchDevices so onMounted doesn't overwrite devices
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
const cards = wrapper.findAll('.frame-card-stub')
expect(cards).toHaveLength(3)
})
// 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')
})
})
+260
View File
@@ -0,0 +1,260 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { useImagesStore } from '@/stores/images'
import { useDevicesStore } from '@/stores/devices'
import LibraryView from '@/views/LibraryView.vue'
import type { Image, Device } from '@/types'
// Stub complex child components
vi.mock('@/components/BaseBottomSheet.vue', () => ({
default: {
name: 'BaseBottomSheet',
template: '<div class="bottom-sheet-stub"><slot /></div>',
props: ['modelValue', 'label'],
emits: ['update:modelValue'],
},
}))
vi.mock('@/components/BaseButton.vue', () => ({
default: {
name: 'BaseButton',
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
props: ['variant', 'disabled'],
emits: ['click'],
},
}))
vi.mock('@/components/ApproveCard.vue', () => ({
default: {
name: 'ApproveCard',
template: '<div class="approve-card-stub" />',
props: ['item'],
emits: ['updated'],
},
}))
vi.mock('@/components/ShareSheet.vue', () => ({
default: {
name: 'ShareSheet',
template: '<div class="share-sheet-stub" />',
props: ['modelValue', 'imageId'],
emits: ['update:modelValue'],
},
}))
// Stub vue-router
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
useRoute: () => ({ query: {} }),
}))
// Stub toast store
vi.mock('@/stores/toast', () => ({
useToastStore: () => ({ show: vi.fn() }),
}))
// Stub upload store
vi.mock('@/stores/upload', () => ({
useUploadStore: () => ({ initEdit: vi.fn() }),
}))
const makeImage = (overrides: Partial<Image> = {}): Image => ({
id: 1,
originalFilename: 'photo.jpg',
thumbnailUrl: '/thumb/1.jpg',
originalUrl: '/orig/1.jpg',
uploadedAt: '2026-01-01T00:00:00Z',
approvedDeviceIds: [],
cropParams: null,
stickerState: null,
...overrides,
})
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,
...overrides,
})
describe('LibraryView', () => {
let pinia: ReturnType<typeof createPinia>
beforeEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
pinia = createPinia()
setActivePinia(pinia)
// Default fetch stub — returns empty lists so onMounted doesn't error
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
}))
})
function mountView() {
return mount(LibraryView, {
global: {
plugins: [pinia],
},
})
}
// LV-01: Default tab shows "All" tab active
it('renders the All tab as active by default', async () => {
const imagesStore = useImagesStore()
imagesStore.images = [makeImage({ id: 1 }), makeImage({ id: 2 })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const devicesStore = useDevicesStore()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
// The "All" tab button should have aria-selected=true
const tabs = wrapper.findAll('[role="tab"]')
const allTab = tabs.find(t => t.text() === 'All')
expect(allTab?.attributes('aria-selected')).toBe('true')
})
// LV-01b: Images from imagesStore are rendered in the grid
it('renders image grid when images exist', async () => {
const imagesStore = useImagesStore()
imagesStore.images = [makeImage({ id: 1 }), makeImage({ id: 2 }), makeImage({ id: 3 })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const devicesStore = useDevicesStore()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
const grid = wrapper.find('.library__grid')
expect(grid.exists()).toBe(true)
expect(wrapper.findAll('.library__item')).toHaveLength(3)
})
// LV-02: Switching to Shared tab shows the shared sub-tabs UI
it('switching to Shared tab shows shared sub-tabs and triggers a fetch', async () => {
const sharedPage = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
const imagesStore = useImagesStore()
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const devicesStore = useDevicesStore()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
// Set up fetch so fetchSharedImages network call resolves
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(sharedPage),
}))
const wrapper = mountView()
await wrapper.vm.$nextTick()
const tabs = wrapper.findAll('[role="tab"]')
const sharedTab = tabs.find(t => t.text() === 'Shared')
await sharedTab?.trigger('click')
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
// After clicking Shared, the sub-tabs (Pending/Approved/Declined) should appear
expect(wrapper.find('.library__subtabs').exists()).toBe(true)
})
// LV-03: Lock chip shown for device when image is approved for it
it('renders lock chip for device when image is approved for that device', async () => {
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 10, name: 'Bedroom' })]
imagesStore.images = [makeImage({ id: 1, approvedDeviceIds: [10] })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
// Lock chips are rendered only for approved devices
const lockChips = wrapper.findAll('.library__lock-chip')
expect(lockChips.length).toBeGreaterThan(0)
expect(lockChips[0].text()).toContain('Bedroom')
})
// LV-06: Share button click renders the ShareSheet
it('clicking share button renders the ShareSheet', async () => {
const imagesStore = useImagesStore()
imagesStore.images = [makeImage({ id: 5 })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const devicesStore = useDevicesStore()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
// Find the share action button (aria-label contains "Share")
const shareBtn = wrapper.findAll('.library__action-btn')
.find(b => b.attributes('aria-label')?.includes('Share'))
expect(shareBtn).toBeTruthy()
await shareBtn!.trigger('click')
await wrapper.vm.$nextTick()
// After clicking, the ShareSheet stub should be rendered
expect(wrapper.find('.share-sheet-stub').exists()).toBe(true)
})
// LV-07: Empty state shown when no images (All tab)
it('shows empty state when no images exist', async () => {
const imagesStore = useImagesStore()
imagesStore.images = []
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const devicesStore = useDevicesStore()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
expect(wrapper.find('.library__empty').exists()).toBe(true)
expect(wrapper.text()).toContain('No photos yet')
})
// LV-07b: Empty state on shared sub-tab (pending)
it('shows shared empty state when no shared items exist', async () => {
const sharedPage = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
const imagesStore = useImagesStore()
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(sharedPage)
const devicesStore = useDevicesStore()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await wrapper.vm.$nextTick()
// Switch to Shared tab
const tabs = wrapper.findAll('[role="tab"]')
const sharedTab = tabs.find(t => t.text() === 'Shared')
await sharedTab?.trigger('click')
// Wait for async loadShared to complete
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
expect(wrapper.find('.library__shared-empty').exists()).toBe(true)
})
})