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:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user