feat: ship as a real iOS-installable PWA, restructure bottom nav, fix safe-area
CI / test (push) Has been cancelled

- Add manifest.webmanifest with standalone display + warm-craft theme colors,
  apple-touch-icon, and 192/512/512-maskable icons (frame-with-sunset glyph).
- Add PWA meta tags + viewport-fit=cover so add-to-home-screen produces a
  true standalone app on iOS instead of a Safari bookmark.
- Drop the Shared bottom-nav tab; the in-page sub-tabs already cover that.
  Three nav tabs total (Home / Library / Settings); pending-share badge
  moves to the Library tab. Predicate-based isActive() now correctly
  disambiguates /library vs /library?tab=shared.
- Safe-area handling: bottom nav, bottom sheet, upload overlay, and #app
  respect env(safe-area-inset-*); sticky Library tabs anchor below the
  iPhone status bar. Introduces --bottom-nav-height token consumed by
  Settings, Library, and the toast.
- LibraryView reactively follows route.query.tab so deep-linking
  /library?tab=shared lands on the right sub-tab.
- Theme-color meta syncs client-side via useTheme.applyTheme so the
  user's chosen theme follows them into Android Chrome's chrome bar.

Test suite expanded to 278 tests / 100% line coverage (99.84% statements,
99.78% branches). Remaining gaps are unreachable defensive code.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 18:07:05 -04:00
parent e0bad975ec
commit 5fcfb806be
58 changed files with 2922 additions and 60 deletions
+79
View File
@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import App from '@/App.vue'
import { useAuthStore } from '@/stores/auth'
// Stub child components — we only care about App's mount-time behavior
vi.mock('@/components/BottomNav.vue', () => ({
default: { template: '<nav class="bottom-nav-stub" />' },
}))
vi.mock('@/components/BaseToast.vue', () => ({
default: { template: '<div class="toast-stub" />' },
}))
import { reactive } from 'vue'
const mockRoute = reactive<{ meta: Record<string, unknown> }>({ meta: {} })
vi.mock('vue-router', () => ({
useRoute: () => mockRoute,
RouterView: { name: 'RouterView', template: '<div class="router-view-stub" />' },
}))
describe('App', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockRoute.meta = {}
document.documentElement.removeAttribute('data-theme')
document.head.querySelectorAll('meta[name="theme-color"]').forEach(n => n.remove())
const meta = document.createElement('meta')
meta.setAttribute('name', 'theme-color')
meta.setAttribute('content', '#000000')
document.head.appendChild(meta)
})
afterEach(() => {
document.head.querySelectorAll('meta[name="theme-color"]').forEach(n => n.remove())
document.documentElement.removeAttribute('data-theme')
})
async function mountApp() {
const { RouterView } = await import('vue-router')
return mount(App, { global: { stubs: { RouterView } } })
}
it('uses the server-stamped <html data-theme> when present', async () => {
document.documentElement.dataset.theme = 'sage-cream'
await mountApp()
expect(document.documentElement.dataset.theme).toBe('sage-cream')
const meta = document.querySelector('meta[name="theme-color"]')
expect(meta?.getAttribute('content')).toBe('#f6f8f3')
})
it('falls back to auth.user.theme when no stamped theme is present', async () => {
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'a@b', roles: [], theme: 'ocean-dusk', timezone: 'UTC' })
await mountApp()
expect(document.documentElement.dataset.theme).toBe('ocean-dusk')
const meta = document.querySelector('meta[name="theme-color"]')
expect(meta?.getAttribute('content')).toBe('#eef3f8')
})
it('does nothing when neither stamped nor user theme exist', async () => {
await mountApp()
expect(document.documentElement.dataset.theme).toBeUndefined()
const meta = document.querySelector('meta[name="theme-color"]')
expect(meta?.getAttribute('content')).toBe('#000000')
})
it('renders BottomNav and BaseToast and RouterView', async () => {
const wrapper = await mountApp()
expect(wrapper.find('.router-view-stub').exists()).toBe(true)
expect(wrapper.find('.bottom-nav-stub').exists()).toBe(true)
expect(wrapper.find('.toast-stub').exists()).toBe(true)
})
it('hides BottomNav when route.meta.hideNav is true', async () => {
mockRoute.meta = { hideNav: true }
const wrapper = await mountApp()
expect(wrapper.find('.bottom-nav-stub').exists()).toBe(false)
})
})
@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import ApproveCard from '@/components/ApproveCard.vue'
import { useImagesStore } from '@/stores/images'
import { useDevicesStore } from '@/stores/devices'
import type { SharedImage, Device } from '@/types'
vi.mock('@/components/DevicePicker.vue', () => ({
default: {
name: 'DevicePicker',
template: '<div class="device-picker-stub"><slot /></div>',
props: ['modelValue', 'devices', 'selected', 'uploading', 'confirmLabel'],
emits: ['update:modelValue', 'update:selected', 'confirm'],
},
}))
vi.mock('@/components/BaseButton.vue', () => ({
default: {
name: 'BaseButton',
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
props: ['variant', 'size', 'disabled'],
emits: ['click'],
},
}))
const makeShared = (overrides: Partial<SharedImage> = {}): SharedImage => ({
id: 1,
thumbnailUrl: '/t/1.jpg',
sharedBy: 'alice@example.com',
sharedAt: '2026-01-15T00:00:00Z',
status: 'pending',
...overrides,
} as SharedImage)
const makeDevice = (overrides: Partial<Device> = {}): Device => ({
id: 10,
mac: 'AA',
name: 'Living Room',
orientation: 'landscape',
rotationIntervalMinutes: 60,
wakeHour: null,
timezone: 'UTC',
uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null,
lockedImageId: null,
currentImageId: null,
...overrides,
})
describe('ApproveCard', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('renders sharer email and a formatted date', () => {
const wrapper = mount(ApproveCard, { props: { item: makeShared() } })
expect(wrapper.text()).toContain('alice@example.com')
// date is locale-formatted; just confirm the year shows up
expect(wrapper.text()).toContain('2026')
})
it('shows "Add to frame" + "Decline" actions for a pending share', () => {
const wrapper = mount(ApproveCard, { props: { item: makeShared({ status: 'pending' }) } })
expect(wrapper.text()).toContain('Add to frame')
expect(wrapper.text()).toContain('Decline')
})
it('shows "Remove" but no add action for an approved share', () => {
const wrapper = mount(ApproveCard, { props: { item: makeShared({ status: 'approved' }) } })
expect(wrapper.text()).not.toContain('Add to frame')
expect(wrapper.text()).toContain('Remove')
})
it('shows "Add anyway" but no decline action for a declined share', () => {
const wrapper = mount(ApproveCard, { props: { item: makeShared({ status: 'declined' }) } })
expect(wrapper.text()).toContain('Add anyway')
expect(wrapper.text()).not.toContain('Decline')
})
it('renders a status badge for non-pending shares', () => {
const wrapper = mount(ApproveCard, { props: { item: makeShared({ status: 'approved' }) } })
expect(wrapper.find('.approve-card__badge--approved').exists()).toBe(true)
})
it('does not render a status badge for pending shares', () => {
const wrapper = mount(ApproveCard, { props: { item: makeShared({ status: 'pending' }) } })
expect(wrapper.find('.approve-card__badge').exists()).toBe(false)
})
it('opens the device picker when the add button is clicked', async () => {
const devices = useDevicesStore()
devices.devices = [makeDevice()]
const wrapper = mount(ApproveCard, { props: { item: makeShared() } })
const addBtn = wrapper.findAll('button').find(b => b.text() === 'Add to frame')!
await addBtn.trigger('click')
const picker = wrapper.findComponent({ name: 'DevicePicker' })
expect(picker.props('modelValue')).toBe(true)
})
it('calls approveShared and emits updated when picker confirms', async () => {
const images = useImagesStore()
const updated = makeShared({ status: 'approved' })
vi.spyOn(images, 'approveShared').mockResolvedValue(updated)
const devices = useDevicesStore()
devices.devices = [makeDevice()]
const wrapper = mount(ApproveCard, { props: { item: makeShared() } })
await wrapper.findAll('button').find(b => b.text() === 'Add to frame')!.trigger('click')
const picker = wrapper.findComponent({ name: 'DevicePicker' })
await picker.vm.$emit('update:selected', [10])
await picker.vm.$emit('confirm')
await flushPromises()
expect(images.approveShared).toHaveBeenCalledWith(1, [10])
expect(wrapper.emitted('updated')).toEqual([[updated]])
})
it('forwards update:modelValue from the picker so it can be closed', async () => {
const devices = useDevicesStore()
devices.devices = [makeDevice()]
const wrapper = mount(ApproveCard, { props: { item: makeShared() } })
await wrapper.findAll('button').find(b => b.text() === 'Add to frame')!.trigger('click')
const picker = wrapper.findComponent({ name: 'DevicePicker' })
expect(picker.props('modelValue')).toBe(true)
await picker.vm.$emit('update:modelValue', false)
await wrapper.vm.$nextTick()
expect(wrapper.findComponent({ name: 'DevicePicker' }).props('modelValue')).toBe(false)
})
it('calls declineShared and emits updated when decline is clicked', async () => {
const images = useImagesStore()
const updated = makeShared({ status: 'declined' })
vi.spyOn(images, 'declineShared').mockResolvedValue(updated)
const wrapper = mount(ApproveCard, { props: { item: makeShared() } })
await wrapper.findAll('button').find(b => b.text() === 'Decline')!.trigger('click')
await flushPromises()
expect(images.declineShared).toHaveBeenCalledWith(1)
expect(wrapper.emitted('updated')).toEqual([[updated]])
})
})
@@ -0,0 +1,73 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
describe('BaseBottomSheet', () => {
beforeEach(() => {
document.body.innerHTML = ''
})
it('does not render the overlay when modelValue is false', async () => {
mount(BaseBottomSheet, {
props: { modelValue: false, label: 'Test' },
slots: { default: '<p class="content">hi</p>' },
attachTo: document.body,
})
await new Promise(r => setTimeout(r, 0))
expect(document.querySelector('.sheet-overlay')).toBeNull()
})
it('renders the sheet teleported to body when open', async () => {
mount(BaseBottomSheet, {
props: { modelValue: true, label: 'Frame settings' },
slots: { default: '<p class="content">hi</p>' },
attachTo: document.body,
})
await new Promise(r => setTimeout(r, 0))
const overlay = document.querySelector('.sheet-overlay')
expect(overlay).not.toBeNull()
expect(overlay?.getAttribute('aria-label')).toBe('Frame settings')
expect(document.querySelector('.sheet .content')).not.toBeNull()
})
it('emits update:modelValue=false when the overlay is clicked', async () => {
const wrapper = mount(BaseBottomSheet, {
props: { modelValue: true, label: 'X' },
attachTo: document.body,
})
await new Promise(r => setTimeout(r, 0))
const overlay = document.querySelector('.sheet-overlay') as HTMLElement
overlay.click()
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
})
it('emits update:modelValue=false on Escape', async () => {
const wrapper = mount(BaseBottomSheet, {
props: { modelValue: true, label: 'X' },
attachTo: document.body,
})
await new Promise(r => setTimeout(r, 0))
const overlay = document.querySelector('.sheet-overlay') as HTMLElement
overlay.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
})
it('focuses the sheet when opened and restores focus to the trigger when closed', async () => {
const trigger = document.createElement('button')
document.body.appendChild(trigger)
trigger.focus()
const wrapper = mount(BaseBottomSheet, {
props: { modelValue: false, label: 'X' },
attachTo: document.body,
})
await wrapper.setProps({ modelValue: true })
await new Promise(r => setTimeout(r, 0))
const sheet = document.querySelector('.sheet') as HTMLElement
expect(document.activeElement).toBe(sheet)
await wrapper.setProps({ modelValue: false })
await new Promise(r => setTimeout(r, 0))
expect(document.activeElement).toBe(trigger)
})
})
@@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import BaseCard from '@/components/BaseCard.vue'
describe('BaseCard', () => {
it('renders slot content inside .card', () => {
const wrapper = mount(BaseCard, { slots: { default: 'hello' } })
expect(wrapper.find('.card').exists()).toBe(true)
expect(wrapper.text()).toContain('hello')
})
it('forwards $attrs onto the root element', () => {
const wrapper = mount(BaseCard, { attrs: { 'data-testid': 'x' } })
expect(wrapper.find('.card').attributes('data-testid')).toBe('x')
})
})
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import BaseChip from '@/components/BaseChip.vue'
describe('BaseChip', () => {
it('renders default variant when no prop is passed', () => {
const wrapper = mount(BaseChip, { slots: { default: 'tag' } })
expect(wrapper.classes()).toContain('chip')
expect(wrapper.classes()).toContain('chip--default')
expect(wrapper.text()).toBe('tag')
})
for (const variant of ['default', 'primary', 'success', 'warning', 'error'] as const) {
it(`renders ${variant} variant class`, () => {
const wrapper = mount(BaseChip, {
props: { variant },
slots: { default: 'x' },
})
expect(wrapper.classes()).toContain(`chip--${variant}`)
})
}
it('forwards $attrs onto the root span', () => {
const wrapper = mount(BaseChip, { attrs: { title: 'tip' } })
expect(wrapper.attributes('title')).toBe('tip')
})
})
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import BaseInput from '@/components/BaseInput.vue'
describe('BaseInput', () => {
it('renders with label and default empty value', () => {
const wrapper = mount(BaseInput, { props: { label: 'Name' } })
expect(wrapper.find('label').text()).toBe('Name')
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('')
expect(wrapper.find('.input-wrap--filled').exists()).toBe(false)
})
it('binds modelValue and emits update:modelValue on input', async () => {
const wrapper = mount(BaseInput, { props: { label: 'Name', modelValue: 'Bob' } })
const input = wrapper.find('input')
expect((input.element as HTMLInputElement).value).toBe('Bob')
await input.setValue('Alice')
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')![0]).toEqual(['Alice'])
})
it('marks .input-wrap--filled when modelValue is non-empty', () => {
const wrapper = mount(BaseInput, { props: { label: 'Name', modelValue: 'x' } })
expect(wrapper.find('.input-wrap--filled').exists()).toBe(true)
})
it('emits blur events', async () => {
const wrapper = mount(BaseInput, { props: { label: 'Name' } })
await wrapper.find('input').trigger('blur')
expect(wrapper.emitted('blur')).toBeTruthy()
})
it('renders error message and applies error class when error is set', () => {
const wrapper = mount(BaseInput, { props: { label: 'Name', error: 'Required' } })
expect(wrapper.find('.input-wrap--error').exists()).toBe(true)
expect(wrapper.find('.input-wrap__error').text()).toBe('Required')
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
})
it('honors a custom id and links the label via for=', () => {
const wrapper = mount(BaseInput, { props: { label: 'Email', id: 'email' } })
expect(wrapper.find('input').attributes('id')).toBe('email')
expect(wrapper.find('label').attributes('for')).toBe('email')
})
it('uses a generated id when none is provided', () => {
const wrapper = mount(BaseInput, { props: { label: 'Email' } })
const id = wrapper.find('input').attributes('id')
expect(id).toMatch(/^input-/)
expect(wrapper.find('label').attributes('for')).toBe(id)
})
it('passes type prop down to the underlying input', () => {
const wrapper = mount(BaseInput, { props: { label: 'Email', type: 'email' } })
expect(wrapper.find('input').attributes('type')).toBe('email')
})
})
@@ -0,0 +1,44 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import BaseToast from '@/components/BaseToast.vue'
import { useToastStore } from '@/stores/toast'
describe('BaseToast', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('renders nothing when there are no toasts', () => {
const wrapper = mount(BaseToast)
expect(wrapper.findAll('.toast')).toHaveLength(0)
})
it('renders an info toast pushed via the store', async () => {
const wrapper = mount(BaseToast)
const toast = useToastStore()
toast.show('hello', 'info')
await wrapper.vm.$nextTick()
const items = wrapper.findAll('.toast')
expect(items).toHaveLength(1)
expect(items[0].text()).toContain('hello')
expect(items[0].classes()).toContain('toast--info')
})
it('renders multiple toasts and dismisses on close button click', async () => {
vi.useFakeTimers()
const wrapper = mount(BaseToast)
const toast = useToastStore()
toast.show('first', 'success')
toast.show('second', 'error')
await wrapper.vm.$nextTick()
expect(wrapper.findAll('.toast')).toHaveLength(2)
await wrapper.findAll('.toast__close')[0].trigger('click')
expect(toast.toasts).toHaveLength(1)
expect(toast.toasts[0].message).toBe('second')
vi.runAllTimers()
vi.useRealTimers()
})
})
@@ -0,0 +1,106 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { createRouter, createMemoryHistory, type Router } from 'vue-router'
import BottomNav from '@/components/BottomNav.vue'
import { useImagesStore } from '@/stores/images'
const Stub = { template: '<div />' }
function makeRouter(): Router {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: Stub },
{ path: '/library', component: Stub },
{ path: '/settings', component: Stub },
],
})
}
async function mountAt(router: Router, path: string) {
await router.push(path)
await router.isReady()
return mount(BottomNav, { global: { plugins: [router] } })
}
function activeTabName(wrapper: ReturnType<typeof mount>): string | undefined {
const active = wrapper.find('.bottom-nav__tab--active')
if (!active.exists()) return undefined
return active.find('.bottom-nav__label').text()
}
describe('BottomNav', () => {
let router: Router
beforeEach(() => {
setActivePinia(createPinia())
router = makeRouter()
})
it('renders three tabs (Home, Library, Settings)', async () => {
const wrapper = await mountAt(router, '/')
const labels = wrapper.findAll('.bottom-nav__label').map(n => n.text())
expect(labels).toEqual(['Home', 'Library', 'Settings'])
})
it('marks Home active on /', async () => {
const wrapper = await mountAt(router, '/')
expect(activeTabName(wrapper)).toBe('Home')
})
it('marks Library active on /library', async () => {
const wrapper = await mountAt(router, '/library')
expect(activeTabName(wrapper)).toBe('Library')
})
it('marks Library active on /library?tab=shared', async () => {
const wrapper = await mountAt(router, '/library?tab=shared')
expect(activeTabName(wrapper)).toBe('Library')
})
it('marks Settings active on /settings', async () => {
const wrapper = await mountAt(router, '/settings')
expect(activeTabName(wrapper)).toBe('Settings')
})
it('does not mark Home active on /library (regression: startsWith bug)', async () => {
const wrapper = await mountAt(router, '/library')
const homeTab = wrapper.findAll('.bottom-nav__tab')
.find(t => t.find('.bottom-nav__label').text() === 'Home')
expect(homeTab?.classes()).not.toContain('bottom-nav__tab--active')
})
it('updates active tab when route changes', async () => {
const wrapper = await mountAt(router, '/')
expect(activeTabName(wrapper)).toBe('Home')
await router.push('/library')
await flushPromises()
expect(activeTabName(wrapper)).toBe('Library')
})
it('shows the pending-share count as a badge on the Library tab', async () => {
const images = useImagesStore()
images.pendingCount = 3
const wrapper = await mountAt(router, '/')
const libraryTab = wrapper.findAll('.bottom-nav__tab')
.find(t => t.find('.bottom-nav__label').text() === 'Library')!
const badge = libraryTab.find('.bottom-nav__badge')
expect(badge.exists()).toBe(true)
expect(badge.text()).toBe('3')
})
it('clamps the badge count to "9+" when 10 or more are pending', async () => {
const images = useImagesStore()
images.pendingCount = 12
const wrapper = await mountAt(router, '/')
expect(wrapper.find('.bottom-nav__badge').text()).toBe('9+')
})
it('hides the badge when no shares are pending', async () => {
const images = useImagesStore()
images.pendingCount = 0
const wrapper = await mountAt(router, '/')
expect(wrapper.find('.bottom-nav__badge').exists()).toBe(false)
})
})
@@ -110,4 +110,44 @@ describe('DevicePicker', () => {
expect(wrapper.text()).toContain('Living Room')
expect(wrapper.text()).toContain('Bedroom')
})
// DP-07: Clicking the enabled confirm button emits 'confirm'
it('emits confirm when the confirm button is clicked', async () => {
const wrapper = mountPicker([1])
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('confirm')).toBeTruthy()
})
// DP-08: Confirm label adapts to selection count (singular / plural / none)
it('renders the singular confirm label for one selected device', () => {
const wrapper = mountPicker([1])
expect(wrapper.find('button').text()).toBe('Add to 1 frame')
})
it('renders the plural confirm label for multiple selected devices', () => {
const wrapper = mountPicker([1, 2])
expect(wrapper.find('button').text()).toBe('Add to 2 frames')
})
it('renders the no-selection confirm label when nothing is picked', () => {
const wrapper = mountPicker([])
expect(wrapper.find('button').text()).toBe('Add to frame')
})
// DP-09: Uploading state — label changes and button is disabled
it('shows uploading label and disables the button while uploading', () => {
const wrapper = mount(DevicePicker, {
props: { modelValue: true, devices, selected: [1], uploading: true },
})
const btn = wrapper.find('button')
expect(btn.text()).toBe('Uploading…')
expect((btn.element as HTMLButtonElement).disabled).toBe(true)
})
// DP-10: Forwards update:modelValue from the wrapped sheet
it('forwards update:modelValue from the wrapped sheet', async () => {
const wrapper = mountPicker([])
await wrapper.findComponent({ name: 'BaseBottomSheet' }).vm.$emit('update:modelValue', false)
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
})
})
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import OrientationPicker from '@/components/OrientationPicker.vue'
describe('OrientationPicker', () => {
it('renders both landscape and portrait options', () => {
const wrapper = mount(OrientationPicker, { props: { modelValue: 'landscape' } })
const buttons = wrapper.findAll('[role="radio"]')
expect(buttons).toHaveLength(2)
const labels = buttons.map(b => b.find('.orientation-opt__label').text())
expect(labels).toEqual(['Landscape', 'Portrait'])
})
it('marks the active option with --active and aria-checked=true', () => {
const wrapper = mount(OrientationPicker, { props: { modelValue: 'portrait' } })
const buttons = wrapper.findAll('[role="radio"]')
const portrait = buttons.find(b => b.text().includes('Portrait'))!
const landscape = buttons.find(b => b.text().includes('Landscape'))!
expect(portrait.classes()).toContain('orientation-opt--active')
expect(portrait.attributes('aria-checked')).toBe('true')
expect(landscape.classes()).not.toContain('orientation-opt--active')
expect(landscape.attributes('aria-checked')).toBe('false')
})
it('emits update:modelValue when an option is clicked', async () => {
const wrapper = mount(OrientationPicker, { props: { modelValue: 'landscape' } })
const portrait = wrapper.findAll('[role="radio"]').find(b => b.text().includes('Portrait'))!
await portrait.trigger('click')
expect(wrapper.emitted('update:modelValue')).toEqual([['portrait']])
})
it('renders an SVG diagram per option', () => {
const wrapper = mount(OrientationPicker, { props: { modelValue: 'landscape' } })
expect(wrapper.findAll('svg.orientation-opt__diagram')).toHaveLength(2)
})
})
@@ -5,6 +5,7 @@ import ShareSheet from '@/components/ShareSheet.vue'
import { useImagesStore } from '@/stores/images'
const BaseBottomSheetStub = {
name: 'BaseBottomSheet',
template: '<div><slot /></div>',
props: ['modelValue', 'label'],
emits: ['update:modelValue'],
@@ -74,4 +75,48 @@ describe('ShareSheet', () => {
const button = wrapper.find('button')
expect(button.attributes('disabled')).toBeDefined()
})
// SS-04: Enter key on input triggers submit
it('submits when Enter is pressed inside the email input', 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 input.trigger('keydown.enter')
await flushPromises()
expect(store.shareImage).toHaveBeenCalledWith(1, 'friend@example.com')
})
// SS-05: empty/whitespace email is a no-op even if submit() is called directly
it('does not call shareImage when email is whitespace-only', async () => {
const store = useImagesStore()
const spy = vi.spyOn(store, 'shareImage').mockResolvedValue(undefined)
const wrapper = mountShareSheet()
const input = wrapper.find('input[type="email"]')
await input.setValue(' ')
await input.trigger('keydown.enter')
await flushPromises()
expect(spy).not.toHaveBeenCalled()
})
// SS-06: non-Error rejections fall back to a generic error message
it('renders a generic error when the rejection is not an Error', async () => {
const store = useImagesStore()
vi.spyOn(store, 'shareImage').mockRejectedValue('something weird')
const wrapper = mountShareSheet()
const input = wrapper.find('input[type="email"]')
await input.setValue('a@b')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Failed to send')
})
// SS-07: forwards update:modelValue from the underlying sheet
it('forwards update:modelValue from the wrapped sheet', async () => {
const wrapper = mountShareSheet()
await wrapper.findComponent({ name: 'BaseBottomSheet' }).vm.$emit('update:modelValue', false)
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
})
})
@@ -0,0 +1,53 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import StickerTray from '@/components/StickerTray.vue'
import { STICKERS, STICKER_CATEGORIES } from '@/assets/stickers/index'
// Stub the bottom sheet so the modal contents are always rendered inline
vi.mock('@/components/BaseBottomSheet.vue', () => ({
default: {
name: 'BaseBottomSheet',
template: '<div class="bottom-sheet-stub"><slot /></div>',
props: ['modelValue', 'label'],
emits: ['update:modelValue'],
},
}))
describe('StickerTray', () => {
it('renders one tab per sticker category', () => {
const wrapper = mount(StickerTray, { props: { modelValue: true } })
expect(wrapper.findAll('.sticker-tray__cat')).toHaveLength(STICKER_CATEGORIES.length)
})
it('starts on the seasonal category', () => {
const wrapper = mount(StickerTray, { props: { modelValue: true } })
const seasonal = wrapper.findAll('.sticker-tray__cat').find(b => b.text() === 'Seasonal')
expect(seasonal?.classes()).toContain('sticker-tray__cat--active')
const seasonalCount = STICKERS.filter(s => s.category === 'seasonal').length
expect(wrapper.findAll('.sticker-tray__item')).toHaveLength(seasonalCount)
})
it('switches the visible grid when a different category tab is clicked', async () => {
const wrapper = mount(StickerTray, { props: { modelValue: true } })
const fun = wrapper.findAll('.sticker-tray__cat').find(b => b.text() === 'Fun')!
await fun.trigger('click')
const funCount = STICKERS.filter(s => s.category === 'fun').length
expect(wrapper.findAll('.sticker-tray__item')).toHaveLength(funCount)
expect(fun.classes()).toContain('sticker-tray__cat--active')
})
it('emits "pick" with the sticker id when an item is clicked', async () => {
const wrapper = mount(StickerTray, { props: { modelValue: true } })
const firstItem = wrapper.find('.sticker-tray__item')
await firstItem.trigger('click')
const events = wrapper.emitted('pick')
expect(events).toBeTruthy()
expect(typeof events![0][0]).toBe('string')
})
it('forwards update:modelValue from the wrapped sheet', async () => {
const wrapper = mount(StickerTray, { props: { modelValue: true } })
await wrapper.findComponent({ name: 'BaseBottomSheet' }).vm.$emit('update:modelValue', false)
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
})
})
@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useTheme, THEMES } from '@/composables/useTheme'
import { useAuthStore } from '@/stores/auth'
describe('useTheme', () => {
beforeEach(() => {
setActivePinia(createPinia())
document.documentElement.removeAttribute('data-theme')
document.head.querySelectorAll('meta[name="theme-color"]').forEach(n => n.remove())
const meta = document.createElement('meta')
meta.setAttribute('name', 'theme-color')
meta.setAttribute('content', '#000000')
document.head.appendChild(meta)
})
afterEach(() => {
document.head.querySelectorAll('meta[name="theme-color"]').forEach(n => n.remove())
document.documentElement.removeAttribute('data-theme')
})
it('exports six themes with id, label, primary, bg, text', () => {
expect(THEMES).toHaveLength(6)
for (const t of THEMES) {
expect(t.id).toBeTypeOf('string')
expect(t.label).toBeTypeOf('string')
expect(t.primary).toMatch(/^#[0-9a-f]{6}$/i)
expect(t.bg).toMatch(/^#[0-9a-f]{6}$/i)
expect(t.text).toMatch(/^#[0-9a-f]{6}$/i)
}
})
it('applyTheme writes data-theme on <html>', () => {
const { applyTheme } = useTheme()
applyTheme('ocean-dusk')
expect(document.documentElement.dataset.theme).toBe('ocean-dusk')
})
it('applyTheme syncs auth.user.theme when a user is signed in', () => {
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'a@b', roles: [], theme: 'warm-craft', timezone: 'UTC' })
const { applyTheme } = useTheme()
applyTheme('sage-cream')
expect(auth.user?.theme).toBe('sage-cream')
})
it('applyTheme updates the theme-color meta tag with the theme bg', () => {
const { applyTheme } = useTheme()
applyTheme('playful-pop')
const meta = document.querySelector('meta[name="theme-color"]')
const bg = THEMES.find(t => t.id === 'playful-pop')!.bg
expect(meta?.getAttribute('content')).toBe(bg)
})
it('applyTheme is a no-op for unknown theme ids on the meta tag', () => {
const { applyTheme } = useTheme()
applyTheme('does-not-exist')
// data-theme still updates (we mirror the input as-is)
expect(document.documentElement.dataset.theme).toBe('does-not-exist')
// meta tag is left at its previous value
const meta = document.querySelector('meta[name="theme-color"]')
expect(meta?.getAttribute('content')).toBe('#000000')
})
it('applyTheme silently skips meta sync when no theme-color meta exists', () => {
document.head.querySelectorAll('meta[name="theme-color"]').forEach(n => n.remove())
const { applyTheme } = useTheme()
expect(() => applyTheme('warm-craft')).not.toThrow()
expect(document.documentElement.dataset.theme).toBe('warm-craft')
})
it('saveTheme applies and PATCHes /api/user/theme on success', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
const { saveTheme } = useTheme()
await saveTheme('honey-slate')
expect(document.documentElement.dataset.theme).toBe('honey-slate')
expect(fetchMock).toHaveBeenCalledWith(
'/api/user/theme',
expect.objectContaining({
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ theme: 'honey-slate' }),
}),
)
vi.unstubAllGlobals()
})
it('saveTheme shows a toast on non-ok response', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
const { saveTheme } = useTheme()
// Spy on the toast store via the module
const { useToastStore } = await import('@/stores/toast')
const toast = useToastStore()
const showSpy = vi.spyOn(toast, 'show')
await saveTheme('dusty-mauve')
expect(showSpy).toHaveBeenCalledWith(expect.stringContaining('Could not save'), 'error')
vi.unstubAllGlobals()
})
it('saveTheme shows a toast when fetch rejects', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')))
const { saveTheme } = useTheme()
const { useToastStore } = await import('@/stores/toast')
const toast = useToastStore()
const showSpy = vi.spyOn(toast, 'show')
await saveTheme('warm-craft')
expect(showSpy).toHaveBeenCalled()
vi.unstubAllGlobals()
})
})
+3
View File
@@ -1,4 +1,5 @@
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount } from '@vue/test-utils'
import { vi, beforeEach, afterEach } from 'vitest'
beforeEach(() => {
@@ -8,3 +9,5 @@ beforeEach(() => {
afterEach(() => {
vi.unstubAllGlobals()
})
enableAutoUnmount(afterEach)
+46
View File
@@ -156,4 +156,50 @@ describe('devices store', () => {
await expect(store.unlockImage(1)).rejects.toThrow('Failed to unlock')
})
// DS-06: non-Error rejections fall back to "Unknown error"
it('fetchDevices stores "Unknown error" when the rejection is not an Error', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue('boom'))
const store = useDevicesStore()
await store.fetchDevices()
expect(store.error).toBe('Unknown error')
})
// DS-07: returns the patched device even when it isn't in the local list
it('updateDevice no-ops the list when the id is not present', async () => {
const updated = makeDevice({ id: 99, name: 'New' })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(updated),
}))
const store = useDevicesStore()
store.devices = [makeDevice({ id: 1 })]
const result = await store.updateDevice(99, { name: 'New' })
expect(result.name).toBe('New')
expect(store.devices.find(d => d.id === 99)).toBeUndefined()
})
it('lockImage no-ops the list when the device id is not present', async () => {
const locked = makeDevice({ id: 99, lockedImageId: 42 })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(locked),
}))
const store = useDevicesStore()
store.devices = [makeDevice({ id: 1 })]
await store.lockImage(99, 42)
expect(store.devices.find(d => d.id === 99)).toBeUndefined()
})
it('unlockImage no-ops the list when the device id is not present', async () => {
const unlocked = makeDevice({ id: 99, lockedImageId: null })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(unlocked),
}))
const store = useDevicesStore()
store.devices = [makeDevice({ id: 1 })]
await store.unlockImage(99)
expect(store.devices.find(d => d.id === 99)).toBeUndefined()
})
})
+233
View File
@@ -163,4 +163,237 @@ describe('images store', () => {
expect(store.pendingCount).toBe(1)
})
it('fetchImages records "Unknown error" when the rejection is not an Error', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue('boom'))
const store = useImagesStore()
await store.fetchImages()
expect(store.error).toBe('Unknown error')
})
it('fetchImages sets error when the response is not ok', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
const store = useImagesStore()
await store.fetchImages()
expect(store.error).toBe('Failed to load images')
expect(store.loading).toBe(false)
})
it('uploadImage forwards optional extras as form fields', async () => {
const newImage = makeImage({ id: 5 })
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(newImage),
})
vi.stubGlobal('fetch', fetchMock)
const store = useImagesStore()
const file = new File(['x'], 'x.jpg')
const original = new File(['y'], 'orig.jpg')
await store.uploadImage(file, {
original,
cropParams: { natX: 0, natY: 0, natW: 50, natH: 50 },
stickerState: [{ id: 's', type: 'emoji', x: 1, y: 2, scale: 1, rotation: 0 } as any],
cropOrientation: 'portrait',
})
const sent = fetchMock.mock.calls[0][1].body as FormData
expect(sent.get('file')).toBeInstanceOf(File)
expect(sent.get('original')).toBeInstanceOf(File)
expect(sent.get('cropParams')).toBe(JSON.stringify({ natX: 0, natY: 0, natW: 50, natH: 50 }))
expect(sent.get('cropOrientation')).toBe('portrait')
})
it('uploadImage falls back to "Upload failed" when the server returns no error message', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
json: () => Promise.reject(new Error('not json')),
}))
const store = useImagesStore()
await expect(store.uploadImage(new File(['x'], 'x.jpg'))).rejects.toThrow('Upload failed')
})
it('reprocessImage replaces the matching image with the server response', async () => {
const updated = makeImage({ id: 9, approvedDeviceIds: [1] })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(updated),
}))
const store = useImagesStore()
store.images = [makeImage({ id: 9 })]
const result = await store.reprocessImage(9, new File(['x'], 'x.jpg'), {
cropParams: { natX: 0, natY: 0, natW: 1, natH: 1 },
stickerState: [],
cropOrientation: 'landscape',
})
expect(result).toEqual(updated)
expect(store.images[0].approvedDeviceIds).toEqual([1])
})
it('reprocessImage no-ops the list when the image id is not present', async () => {
const updated = makeImage({ id: 99 })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(updated),
}))
const store = useImagesStore()
store.images = [makeImage({ id: 1 })]
await store.reprocessImage(99, new File(['x'], 'x.jpg'))
expect(store.images.find(i => i.id === 99)).toBeUndefined()
})
it('reprocessImage throws Reprocess failed when the server omits an error message', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
json: () => Promise.reject(new Error('not json')),
}))
const store = useImagesStore()
await expect(store.reprocessImage(1, new File(['x'], 'x.jpg'))).rejects.toThrow('Reprocess failed')
})
it('reprocessImage surfaces server-provided error messages', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
json: () => Promise.resolve({ error: 'too big' }),
}))
const store = useImagesStore()
await expect(store.reprocessImage(1, new File(['x'], 'x.jpg'))).rejects.toThrow('too big')
})
it('setApproval uses DELETE when approved=false', async () => {
const updated = makeImage({ id: 1, approvedDeviceIds: [] })
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(updated),
})
vi.stubGlobal('fetch', fetchMock)
const store = useImagesStore()
store.images = [makeImage({ id: 1, approvedDeviceIds: [42] })]
await store.setApproval(1, 42, false)
expect(fetchMock.mock.calls[0][1].method).toBe('DELETE')
expect(store.images[0].approvedDeviceIds).toEqual([])
})
it('setApproval throws on failure', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
const store = useImagesStore()
await expect(store.setApproval(1, 1, true)).rejects.toThrow('Failed to update approval')
})
it('setApproval does nothing to the list when the image id is not present', async () => {
const updated = makeImage({ id: 100, approvedDeviceIds: [42] })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(updated),
}))
const store = useImagesStore()
store.images = [makeImage({ id: 1 })]
await store.setApproval(100, 42, true)
expect(store.images.find(i => i.id === 100)).toBeUndefined()
})
it('fetchSharedImages includes status in the query when provided', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: [], total: 0, page: 1, limit: 20, totalPages: 1 }),
})
vi.stubGlobal('fetch', fetchMock)
const store = useImagesStore()
await store.fetchSharedImages('approved', 2, 10)
const url = String(fetchMock.mock.calls[0][0])
expect(url).toContain('status=approved')
expect(url).toContain('page=2')
expect(url).toContain('limit=10')
})
it('fetchSharedImages omits status when not provided', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: [], total: 0, page: 1, limit: 20, totalPages: 1 }),
})
vi.stubGlobal('fetch', fetchMock)
const store = useImagesStore()
await store.fetchSharedImages()
expect(String(fetchMock.mock.calls[0][0])).not.toContain('status=')
})
it('fetchSharedImages throws on non-ok response', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
const store = useImagesStore()
await expect(store.fetchSharedImages()).rejects.toThrow('Failed to load shared images')
})
it('fetchPendingCount silently no-ops on a non-ok response', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
const store = useImagesStore()
store.pendingCount = 5
await store.fetchPendingCount()
expect(store.pendingCount).toBe(5)
})
it('approveShared does not go negative when pendingCount is already zero', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, status: 'approved' }),
}))
const store = useImagesStore()
store.pendingCount = 0
await store.approveShared(1, [42])
expect(store.pendingCount).toBe(0)
})
it('approveShared throws on failure', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
const store = useImagesStore()
await expect(store.approveShared(1, [42])).rejects.toThrow('Failed to approve')
})
it('declineShared does not go negative when pendingCount is already zero', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, status: 'declined' }),
}))
const store = useImagesStore()
store.pendingCount = 0
await store.declineShared(1)
expect(store.pendingCount).toBe(0)
})
it('declineShared throws on failure', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }))
const store = useImagesStore()
await expect(store.declineShared(1)).rejects.toThrow('Failed to decline')
})
it('shareImage POSTs to the share endpoint with the recipient email', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
const store = useImagesStore()
await store.shareImage(7, 'a@b.com')
expect(fetchMock).toHaveBeenCalledWith(
'/api/images/7/share',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ recipientEmail: 'a@b.com' }),
}),
)
})
it('shareImage surfaces server-provided error messages', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
json: () => Promise.resolve({ error: 'rate limited' }),
}))
const store = useImagesStore()
await expect(store.shareImage(1, 'a@b')).rejects.toThrow('rate limited')
})
it('shareImage falls back to a generic message when no body is parseable', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
json: () => Promise.reject(new Error('not json')),
}))
const store = useImagesStore()
await expect(store.shareImage(1, 'a@b')).rejects.toThrow('Failed to share')
})
})
+72
View File
@@ -125,6 +125,78 @@ describe('upload store', () => {
expect(store.stickers[0].id).toBe('b')
})
it('initEdit fetches the original blob and seeds edit state from the image', async () => {
const store = useUploadStore()
const blob = new Blob(['orig'], { type: 'image/jpeg' })
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
blob: () => Promise.resolve(blob),
}))
const image = {
id: 42,
originalFilename: 'orig.jpg',
originalUrl: '/o/42.jpg',
thumbnailUrl: '/t/42.jpg',
uploadedAt: '2026-01-01T00:00:00Z',
approvedDeviceIds: [3, 4],
cropParams: { natX: 0, natY: 0, natW: 100, natH: 100 },
cropOrientation: 'portrait',
stickerState: [makeSticker({ id: 's1' })],
} as any
await store.initEdit(image, 7)
expect(store.editingImageId).toBe(42)
expect(store.cropParams).toEqual(image.cropParams)
expect(store.cropOrientation).toBe('portrait')
expect(store.stickers).toHaveLength(1)
expect(store.stickers[0].id).toBe('s1')
expect(store.selectedDeviceIds).toEqual([3, 4])
expect(store.contextDeviceId).toBe(7)
expect(store.originalUrl).toBe('blob:mock-url')
})
it('initEdit handles images with no cropParams or stickers', async () => {
const store = useUploadStore()
const blob = new Blob(['orig'])
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
blob: () => Promise.resolve(blob),
}))
const image = {
id: 1,
originalFilename: 'a.jpg',
originalUrl: '/o/1.jpg',
thumbnailUrl: '/t/1.jpg',
uploadedAt: '',
approvedDeviceIds: [],
cropParams: null,
cropOrientation: null,
stickerState: null,
} as any
await store.initEdit(image)
expect(store.cropParams).toBeNull()
expect(store.cropOrientation).toBeNull()
expect(store.stickers).toEqual([])
expect(store.contextDeviceId).toBeNull()
})
it('setCrop revokes a previous croppedUrl before assigning a new one', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
store.init(file)
const params = { natX: 0, natY: 0, natW: 1, natH: 1 }
store.setCrop(new Blob(['a']), params, 'landscape')
const url = store.croppedUrl
expect(url).toBe('blob:mock-url')
// Second call must revoke the previous URL before assigning the new one
store.setCrop(new Blob(['b']), params, 'portrait')
expect(URL.revokeObjectURL).toHaveBeenCalledWith(url)
})
it('cleanup resets all state', () => {
const store = useUploadStore()
const file = new File(['data'], 'photo.jpg')
+305 -3
View File
@@ -1,16 +1,19 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { useDevicesStore } from '@/stores/devices'
import { useUploadStore } from '@/stores/upload'
import HomeView from '@/views/HomeView.vue'
import type { Device } from '@/types'
const routerPush = vi.fn()
// Stub heavy child components so tests focus on HomeView logic
vi.mock('@/components/FrameCard.vue', () => ({
default: {
name: 'FrameCard',
template: '<div class="frame-card-stub" :data-device-id="deviceId" :data-name="name" />',
props: ['deviceId', 'name', 'size', 'status', 'orientation'],
props: ['deviceId', 'name', 'size', 'status', 'orientation', 'thumbnailUrl'],
emits: ['add-photo', 'edit'],
},
}))
@@ -48,7 +51,7 @@ vi.mock('@/components/OrientationPicker.vue', () => ({
// Stub vue-router so HomeView can call useRouter() without a real router
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
useRouter: () => ({ push: routerPush }),
}))
// Stub URL.createObjectURL used by upload store
@@ -161,4 +164,303 @@ describe('HomeView', () => {
expect(wrapper.find('.home-view__loading').exists()).toBe(true)
expect(wrapper.text()).toContain('Loading')
})
// HV-04: add-photo opens a file picker, primes the upload store, and navigates
it('add-photo from a FrameCard primes upload state and routes to /upload', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 7, name: 'Hall' })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
routerPush.mockClear()
// Spy on createElement so we can intercept the synthetic file input
const realCreate = document.createElement.bind(document)
let capturedInput: HTMLInputElement | null = null
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
const el = realCreate(tag)
if (tag === 'input') {
capturedInput = el as HTMLInputElement
// Don't actually open a file dialog
;(el as HTMLInputElement).click = vi.fn()
}
return el
})
const wrapper = mountView()
await flushPromises()
const card = wrapper.findComponent({ name: 'FrameCard' })
await card.vm.$emit('add-photo', 7)
expect(capturedInput).not.toBeNull()
expect(capturedInput!.type).toBe('file')
const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' })
Object.defineProperty(capturedInput, 'files', { value: [file], configurable: true })
capturedInput!.onchange?.(new Event('change'))
const upload = useUploadStore()
expect(upload.originalFile).toStrictEqual(file)
expect(upload.contextDeviceId).toBe(7)
expect(routerPush).toHaveBeenCalledWith('/upload')
})
it('add-photo without a chosen file does not navigate', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 7 })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
routerPush.mockClear()
const realCreate = document.createElement.bind(document)
let capturedInput: HTMLInputElement | null = null
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
const el = realCreate(tag)
if (tag === 'input') {
capturedInput = el as HTMLInputElement
;(el as HTMLInputElement).click = vi.fn()
}
return el
})
const wrapper = mountView()
await flushPromises()
const card = wrapper.findComponent({ name: 'FrameCard' })
await card.vm.$emit('add-photo', 7)
Object.defineProperty(capturedInput, 'files', { value: [], configurable: true })
capturedInput!.onchange?.(new Event('change'))
expect(routerPush).not.toHaveBeenCalled()
})
// HV-05: edit opens the settings sheet pre-filled from the device record
it('edit emits open the settings sheet pre-populated from the device', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 9, name: 'Den', wakeHour: 22, timezone: 'America/Chicago' })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const card = wrapper.findComponent({ name: 'FrameCard' })
await card.vm.$emit('edit', 9)
await flushPromises()
const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
expect(sheet.props('modelValue')).toBe(true)
// The name input is the first BaseInput stub
const nameInput = wrapper.findComponent({ name: 'BaseInput' })
expect(nameInput.props('modelValue')).toBe('Den')
})
it('edit for an unknown device id is a no-op', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 1 })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const card = wrapper.findComponent({ name: 'FrameCard' })
await card.vm.$emit('edit', 999)
await flushPromises()
const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
expect(sheet.props('modelValue')).toBe(false)
})
// HV-06: saving the sheet calls updateDevice and closes it
it('saving the settings sheet PATCHes via the store and closes', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 5, name: 'Old', wakeHour: 4, timezone: 'UTC' })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const updateSpy = vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))
const wrapper = mountView()
await flushPromises()
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
await flushPromises()
// Click save (the only button in the sheet stub for now)
const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
.find(b => b.text().toLowerCase().includes('sav'))!
await saveBtn.trigger('click')
await flushPromises()
expect(updateSpy).toHaveBeenCalledWith(5, expect.objectContaining({
orientation: 'landscape',
wakeHour: 4,
timezone: 'UTC',
}))
const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
expect(sheet.props('modelValue')).toBe(false)
})
it('passes status="ok" to the FrameCard when lastSeenAt is recent', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 1, lastSeenAt: new Date().toISOString() })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('status')).toBe('ok')
})
it('passes status="offline" when lastSeenAt is older than the rotation window', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({
id: 1,
rotationIntervalMinutes: 60,
lastSeenAt: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(),
})]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('status')).toBe('offline')
})
it('builds a thumbnail URL when the device has a current image', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 1, currentImageId: 42 })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('thumbnailUrl')).toBe('/api/devices/1/preview?v=42')
})
it('prefers lockedImageId over currentImageId for the thumbnail', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 1, lockedImageId: 99, currentImageId: 42 })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.findComponent({ name: 'FrameCard' }).props('thumbnailUrl')).toBe('/api/devices/1/preview?v=99')
})
it('updates editWakeHour when the user picks a different hour chip', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 5, wakeHour: 4 })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))
const wrapper = mountView()
await flushPromises()
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
await flushPromises()
const chips = wrapper.findAll('.home-view__interval-chip')
const chip8pm = chips.find(c => c.text() === '8 PM')!
await chip8pm.trigger('click')
expect(chip8pm.classes()).toContain('home-view__interval-chip--on')
const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
.find(b => b.text().toLowerCase().includes('sav'))!
await saveBtn.trigger('click')
await flushPromises()
expect(devicesStore.updateDevice).toHaveBeenCalledWith(5, expect.objectContaining({ wakeHour: 20 }))
})
it('saving while no device is being edited is a no-op (defensive guard)', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 1 })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const updateSpy = vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice())
const wrapper = mountView()
await flushPromises()
// The BaseBottomSheet stub always renders its slot, so the Save button is in
// the DOM even before onEdit is called. Clicking it now exercises the
// editingDevice null-guard.
const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
.find(b => b.text().toLowerCase().includes('sav'))!
await saveBtn.trigger('click')
await flushPromises()
expect(updateSpy).not.toHaveBeenCalled()
})
it('updates editName/orientation/timezone when their components emit changes', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 5, name: 'Original', wakeHour: 4, timezone: 'UTC' })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const updateSpy = vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))
const wrapper = mountView()
await flushPromises()
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
await flushPromises()
await wrapper.findComponent({ name: 'BaseInput' }).vm.$emit('update:modelValue', 'New Name')
await wrapper.findComponent({ name: 'OrientationPicker' }).vm.$emit('update:modelValue', 'portrait')
const select = wrapper.find('select.home-view__tz-select')
;(select.element as HTMLSelectElement).value = 'America/New_York'
await select.trigger('change')
const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
.find(b => b.text().toLowerCase().includes('sav'))!
await saveBtn.trigger('click')
await flushPromises()
expect(updateSpy).toHaveBeenCalledWith(5, expect.objectContaining({
name: 'New Name',
orientation: 'portrait',
timezone: 'America/New_York',
}))
})
it('edit defaults wakeHour to 4 and timezone to UTC when the device has neither', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({
id: 5,
name: 'Den',
wakeHour: null,
timezone: null as any,
})]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const updateSpy = vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))
const wrapper = mountView()
await flushPromises()
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
await flushPromises()
const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
.find(b => b.text().toLowerCase().includes('sav'))!
await saveBtn.trigger('click')
await flushPromises()
expect(updateSpy).toHaveBeenCalledWith(5, expect.objectContaining({
wakeHour: 4,
timezone: 'UTC',
}))
})
it('the settings sheet closes when the underlying bottom-sheet emits close', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 5 })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
await flushPromises()
const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
expect(sheet.props('modelValue')).toBe(true)
await sheet.vm.$emit('update:modelValue', false)
await wrapper.vm.$nextTick()
expect(wrapper.findComponent({ name: 'BaseBottomSheet' }).props('modelValue')).toBe(false)
})
it('saving falls back to the original name when the user clears the field', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 5, name: 'Original' })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const updateSpy = vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))
const wrapper = mountView()
await flushPromises()
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
await flushPromises()
await wrapper.findComponent({ name: 'BaseInput' }).vm.$emit('update:modelValue', ' ')
const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
.find(b => b.text().toLowerCase().includes('sav'))!
await saveBtn.trigger('click')
await flushPromises()
expect(updateSpy).toHaveBeenCalledWith(5, expect.objectContaining({ name: 'Original' }))
})
})
+698 -9
View File
@@ -1,11 +1,14 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { mount, flushPromises } from '@vue/test-utils'
import { reactive } from 'vue'
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'
const routerPush = vi.fn()
// Stub complex child components
vi.mock('@/components/BaseBottomSheet.vue', () => ({
default: {
@@ -40,20 +43,21 @@ vi.mock('@/components/ShareSheet.vue', () => ({
},
}))
// Stub vue-router
// Stub vue-router with a reactive route so watchers fire when query changes
const mockRoute = reactive<{ query: Record<string, string | undefined> }>({ query: {} })
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
useRoute: () => ({ query: {} }),
useRouter: () => ({ push: routerPush }),
useRoute: () => mockRoute,
}))
// Stub toast store
// Stable mocks for toast + upload so individual tests can spy on them
const toastShow = vi.fn()
vi.mock('@/stores/toast', () => ({
useToastStore: () => ({ show: vi.fn() }),
useToastStore: () => ({ show: toastShow }),
}))
// Stub upload store
const uploadInitEdit = vi.fn()
vi.mock('@/stores/upload', () => ({
useUploadStore: () => ({ initEdit: vi.fn() }),
useUploadStore: () => ({ initEdit: uploadInitEdit }),
}))
const makeImage = (overrides: Partial<Image> = {}): Image => ({
@@ -93,6 +97,10 @@ describe('LibraryView', () => {
vi.restoreAllMocks()
pinia = createPinia()
setActivePinia(pinia)
mockRoute.query = {}
toastShow.mockClear()
uploadInitEdit.mockClear()
routerPush.mockClear()
// Default fetch stub — returns empty lists so onMounted doesn't error
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
@@ -236,6 +244,66 @@ describe('LibraryView', () => {
expect(wrapper.text()).toContain('No photos yet')
})
// LV-08: Deep-link via ?tab=shared — Shared sub-tab is initially active
it('starts on Shared tab when route enters with ?tab=shared', async () => {
mockRoute.query = { tab: 'shared' }
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)
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const tabs = wrapper.findAll('[role="tab"]')
const sharedTab = tabs.find(t => t.text() === 'Shared')
expect(sharedTab?.attributes('aria-selected')).toBe('true')
expect(wrapper.find('.library__subtabs').exists()).toBe(true)
})
// LV-09: Watcher — switching the route query swaps the active tab and loads shared
it('switches active tab when route.query.tab changes', async () => {
const sharedPage = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
const imagesStore = useImagesStore()
imagesStore.images = []
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const fetchSharedSpy = vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(sharedPage)
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const initialAll = wrapper.findAll('[role="tab"]').find(t => t.text() === 'All')
expect(initialAll?.attributes('aria-selected')).toBe('true')
expect(fetchSharedSpy).not.toHaveBeenCalled()
mockRoute.query = { tab: 'shared' }
await flushPromises()
const sharedTab = wrapper.findAll('[role="tab"]').find(t => t.text() === 'Shared')
expect(sharedTab?.attributes('aria-selected')).toBe('true')
expect(fetchSharedSpy).toHaveBeenCalled()
})
// LV-10: Unknown tab values fall back to 'all'
it('falls back to All when route.query.tab is an unknown value', async () => {
mockRoute.query = { tab: 'bogus' }
const imagesStore = useImagesStore()
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const allTab = wrapper.findAll('[role="tab"]').find(t => t.text() === 'All')
expect(allTab?.attributes('aria-selected')).toBe('true')
})
// 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 }
@@ -259,4 +327,625 @@ describe('LibraryView', () => {
expect(wrapper.find('.library__shared-empty').exists()).toBe(true)
})
// LV-11: Edit (loadShared cropped image) routes to /upload after initEdit
it('clicking edit calls upload.initEdit and navigates to /upload', async () => {
uploadInitEdit.mockResolvedValue(undefined)
const imagesStore = useImagesStore()
imagesStore.images = [makeImage({ id: 7 })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const editBtn = wrapper.findAll('.library__action-btn')
.find(b => b.attributes('aria-label')?.startsWith('Edit'))!
await editBtn.trigger('click')
await flushPromises()
expect(uploadInitEdit).toHaveBeenCalled()
expect(routerPush).toHaveBeenCalledWith('/upload')
})
it('shows a toast when edit fails to load', async () => {
uploadInitEdit.mockRejectedValue(new Error('boom'))
const imagesStore = useImagesStore()
imagesStore.images = [makeImage({ id: 7 })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
await wrapper.findAll('.library__action-btn')
.find(b => b.attributes('aria-label')?.startsWith('Edit'))!.trigger('click')
await flushPromises()
expect(toastShow).toHaveBeenCalledWith(expect.stringContaining('Could not load'), 'error')
})
it('ignores a second edit click while another edit is already in flight', async () => {
let resolveEdit: () => void = () => {}
uploadInitEdit.mockImplementation(() => new Promise<void>(r => { resolveEdit = r }))
const imagesStore = useImagesStore()
imagesStore.images = [makeImage({ id: 1 }), makeImage({ id: 2 })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const editButtons = wrapper.findAll('.library__action-btn')
.filter(b => b.attributes('aria-label')?.startsWith('Edit'))
expect(editButtons).toHaveLength(2)
await editButtons[0].trigger('click')
// Second click on a *different* photo's edit button — startEdit should
// see editingId is set and early-return without invoking initEdit again.
await editButtons[1].trigger('click')
expect(uploadInitEdit).toHaveBeenCalledTimes(1)
resolveEdit()
await flushPromises()
})
// LV-12: Delete confirmation flow (success + failure)
it('confirming delete calls deleteImage and shows success toast', async () => {
const imagesStore = useImagesStore()
imagesStore.images = [makeImage({ id: 9 })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const deleteSpy = vi.spyOn(imagesStore, 'deleteImage').mockResolvedValue()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const deleteBtn = wrapper.findAll('.library__action-btn')
.find(b => b.attributes('aria-label') === 'Delete photo')!
await deleteBtn.trigger('click')
await flushPromises()
// Sheet now open with a Delete button
const sheetDelete = wrapper.findAll('button').find(b => b.text() === 'Delete')!
await sheetDelete.trigger('click')
await flushPromises()
expect(deleteSpy).toHaveBeenCalledWith(9)
expect(toastShow).toHaveBeenCalledWith('Photo deleted', 'success')
})
it('shows an error toast when delete fails', async () => {
const imagesStore = useImagesStore()
imagesStore.images = [makeImage({ id: 9 })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(imagesStore, 'deleteImage').mockRejectedValue(new Error('nope'))
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
await wrapper.findAll('.library__action-btn')
.find(b => b.attributes('aria-label') === 'Delete photo')!.trigger('click')
await wrapper.findAll('button').find(b => b.text() === 'Delete')!.trigger('click')
await flushPromises()
expect(toastShow).toHaveBeenCalledWith('Delete failed', 'error')
})
// LV-13: Approval toggle success + failure
it('clicking an approval chip toggles approval via the store', async () => {
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 4, name: 'Hall' })]
imagesStore.images = [makeImage({ id: 1, approvedDeviceIds: [] })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const setApproval = vi.spyOn(imagesStore, 'setApproval').mockResolvedValue()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const chip = wrapper.find('.library__approval-chip')
await chip.trigger('click')
await flushPromises()
expect(setApproval).toHaveBeenCalledWith(1, 4, true)
})
it('toasts when approval toggle fails', async () => {
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 4 })]
imagesStore.images = [makeImage({ id: 1, approvedDeviceIds: [] })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(imagesStore, 'setApproval').mockRejectedValue(new Error('nope'))
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
await wrapper.find('.library__approval-chip').trigger('click')
await flushPromises()
expect(toastShow).toHaveBeenCalledWith('Failed to update frame approval', 'error')
})
// LV-14: Lock toggle — lock + unlock + failure
it('clicking a lock chip locks the image to the device', async () => {
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 4, name: 'Hall', lockedImageId: null })]
imagesStore.images = [makeImage({ id: 1, approvedDeviceIds: [4] })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const lockSpy = vi.spyOn(devicesStore, 'lockImage').mockResolvedValue(makeDevice({ id: 4, lockedImageId: 1 }))
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
await wrapper.find('.library__lock-chip').trigger('click')
await flushPromises()
expect(lockSpy).toHaveBeenCalledWith(4, 1)
})
it('clicking a lock chip on an already-locked photo unlocks it', async () => {
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 4, lockedImageId: 1 })]
imagesStore.images = [makeImage({ id: 1, approvedDeviceIds: [4] })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const unlockSpy = vi.spyOn(devicesStore, 'unlockImage').mockResolvedValue(makeDevice({ id: 4, lockedImageId: null }))
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
await wrapper.find('.library__lock-chip').trigger('click')
await flushPromises()
expect(unlockSpy).toHaveBeenCalledWith(4)
})
it('toasts when lock toggle fails', async () => {
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 4 })]
imagesStore.images = [makeImage({ id: 1, approvedDeviceIds: [4] })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(devicesStore, 'lockImage').mockRejectedValue(new Error('nope'))
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
await wrapper.find('.library__lock-chip').trigger('click')
await flushPromises()
expect(toastShow).toHaveBeenCalledWith('Failed to update lock', 'error')
})
// LV-15: Mismatch warning shown when photo orientation differs from device
it('shows the mismatch warning when an approved device orientation differs', async () => {
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 4, orientation: 'portrait' })]
imagesStore.images = [makeImage({
id: 1,
approvedDeviceIds: [4],
cropOrientation: 'landscape',
})]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.find('.library__action-btn--warn').exists()).toBe(true)
})
it('infers landscape from cropParams when natW >= natH', async () => {
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 4, orientation: 'portrait' })]
imagesStore.images = [makeImage({
id: 1,
approvedDeviceIds: [4],
cropOrientation: null,
cropParams: { natX: 0, natY: 0, natW: 200, natH: 100 } as any,
})]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
// photo inferred landscape, device portrait → mismatch warn
expect(wrapper.find('.library__action-btn--warn').exists()).toBe(true)
})
it('infers crop orientation from cropParams when cropOrientation is missing', async () => {
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 4, orientation: 'landscape' })]
imagesStore.images = [makeImage({
id: 1,
approvedDeviceIds: [4],
cropOrientation: null,
cropParams: { natX: 0, natY: 0, natW: 100, natH: 200 } as any,
})]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.find('.library__action-btn--warn').exists()).toBe(true)
})
// LV-16: Pagination buttons in the shared tab
it('shared pagination next button requests the following page', async () => {
const page1: any = { items: [{ id: 1, sharedBy: 'a@b', sharedAt: '2026-01-01T00:00:00Z', status: 'pending', thumbnailUrl: '/t/1.jpg' }], total: 30, page: 1, limit: 1, totalPages: 30 }
const imagesStore = useImagesStore()
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const fetchSharedSpy = vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(page1)
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
mockRoute.query = { tab: 'shared' }
const wrapper = mountView()
await flushPromises()
const next = wrapper.findAll('.library__page-btn').find(b => b.text().includes('Next'))!
await next.trigger('click')
expect(fetchSharedSpy).toHaveBeenLastCalledWith('pending', 2)
})
it('shared pagination prev button requests the previous page when not on page 1', async () => {
const page2: any = { items: [{ id: 1, sharedBy: 'a@b', sharedAt: '2026-01-01T00:00:00Z', status: 'pending', thumbnailUrl: '/t/1.jpg' }], total: 30, page: 2, limit: 1, totalPages: 30 }
const imagesStore = useImagesStore()
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const fetchSharedSpy = vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(page2)
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
mockRoute.query = { tab: 'shared' }
const wrapper = mountView()
await flushPromises()
const prev = wrapper.findAll('.library__page-btn').find(b => b.text().includes('Prev'))!
expect(prev.attributes('disabled')).toBeUndefined()
await prev.trigger('click')
expect(fetchSharedSpy).toHaveBeenLastCalledWith('pending', 1)
})
// LV-17: Switching between Pending/Approved/Declined sub-tabs reloads with new status
it('switching shared sub-tabs reloads with the new status', async () => {
const empty = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
const imagesStore = useImagesStore()
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const fetchSharedSpy = vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(empty)
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
mockRoute.query = { tab: 'shared' }
const wrapper = mountView()
await flushPromises()
const approved = wrapper.findAll('.library__subtab').find(b => b.text() === 'Approved')!
await approved.trigger('click')
expect(fetchSharedSpy).toHaveBeenLastCalledWith('approved', 1)
const declined = wrapper.findAll('.library__subtab').find(b => b.text() === 'Declined')!
await declined.trigger('click')
expect(fetchSharedSpy).toHaveBeenLastCalledWith('declined', 1)
})
// LV-18: onSharedUpdated patches the matching item in the rendered list
it('updated event from ApproveCard replaces the matching shared item', async () => {
const original: any = { id: 5, sharedBy: 'a@b', sharedAt: '2026-01-01T00:00:00Z', status: 'pending', thumbnailUrl: '/t/5.jpg' }
const updated: any = { ...original, status: 'approved' }
const imagesStore = useImagesStore()
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue({ items: [original], total: 1, page: 1, limit: 20, totalPages: 1 })
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
mockRoute.query = { tab: 'shared' }
const wrapper = mountView()
await flushPromises()
const approveCard = wrapper.findComponent({ name: 'ApproveCard' })
expect(approveCard.props('item')).toEqual(original)
await approveCard.vm.$emit('updated', updated)
await flushPromises()
expect(wrapper.findComponent({ name: 'ApproveCard' }).props('item')).toEqual(updated)
})
// LV-19: empty-state copy reflects the active sub-tab
it('renders the approved empty-state copy when on the approved sub-tab', async () => {
const empty = { 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(empty)
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
mockRoute.query = { tab: 'shared' }
const wrapper = mountView()
await flushPromises()
const approved = wrapper.findAll('.library__subtab').find(b => b.text() === 'Approved')!
await approved.trigger('click')
await flushPromises()
expect(wrapper.find('.library__shared-empty').text()).toContain('No approved photos')
expect(wrapper.find('.library__shared-empty').text()).toContain('added to a frame')
})
it('renders the declined empty-state copy when on the declined sub-tab', async () => {
const empty = { 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(empty)
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
mockRoute.query = { tab: 'shared' }
const wrapper = mountView()
await flushPromises()
const declined = wrapper.findAll('.library__subtab').find(b => b.text() === 'Declined')!
await declined.trigger('click')
await flushPromises()
expect(wrapper.find('.library__shared-empty').text()).toContain('No declined photos')
})
// LV-29: main loading state
it('shows the main loading row when imagesStore.loading is true', async () => {
const imagesStore = useImagesStore()
imagesStore.loading = true
vi.spyOn(imagesStore, 'fetchImages').mockReturnValue(new Promise(() => {}))
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.find('.library__loading').exists()).toBe(true)
expect(wrapper.text()).toContain('Loading')
})
// LV-30: route.query.tab transitions FROM shared back to all — watcher's tab !== 'shared' branch
it('switching the route query off "shared" updates active tab without reloading shared', async () => {
const empty = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
const imagesStore = useImagesStore()
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const fetchSharedSpy = vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(empty)
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
mockRoute.query = { tab: 'shared' }
const wrapper = mountView()
await flushPromises()
expect(fetchSharedSpy).toHaveBeenCalledTimes(1)
mockRoute.query = { tab: 'mine' }
await flushPromises()
// Query change: was 'shared', now 'mine' — watcher runs, activeTab is updated but no extra reload of shared
expect(fetchSharedSpy).toHaveBeenCalledTimes(1)
const mineTab = wrapper.findAll('[role="tab"]').find(t => t.text() === 'Mine')!
expect(mineTab.attributes('aria-selected')).toBe('true')
})
// LV-25b: query.tab change that resolves to the same active tab is a no-op
it('does not reload shared when route.query.tab changes but resolves to the same tab', async () => {
const empty = { items: [], total: 0, page: 1, limit: 20, totalPages: 1 }
const imagesStore = useImagesStore()
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const fetchSharedSpy = vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue(empty)
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(fetchSharedSpy).not.toHaveBeenCalled() // active tab is 'all'
// bogus and missing both resolve to 'all' — no reload, watcher returns early
mockRoute.query = { tab: 'bogus' }
await flushPromises()
expect(fetchSharedSpy).not.toHaveBeenCalled()
expect(wrapper.findAll('[role="tab"]').find(t => t.text() === 'All')!.attributes('aria-selected')).toBe('true')
})
// LV-26: ApproveCard updates with an unknown id are ignored
it('updated event with an unknown id leaves the shared list untouched', async () => {
const item: any = { id: 5, sharedBy: 'a@b', sharedAt: '2026-01-01T00:00:00Z', status: 'pending', thumbnailUrl: '/t/5.jpg' }
const imagesStore = useImagesStore()
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchSharedImages').mockResolvedValue({ items: [item], total: 1, page: 1, limit: 20, totalPages: 1 })
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
mockRoute.query = { tab: 'shared' }
const wrapper = mountView()
await flushPromises()
const approveCard = wrapper.findComponent({ name: 'ApproveCard' })
await approveCard.vm.$emit('updated', { ...item, id: 999, status: 'approved' })
await flushPromises()
expect(wrapper.findAllComponents({ name: 'ApproveCard' })).toHaveLength(1)
expect(wrapper.findComponent({ name: 'ApproveCard' }).props('item').id).toBe(5)
})
// LV-27: photoCropOrientation falls through to null when cropParams has zero dims
it('does not render warn when cropOrientation is null and cropParams has zero dims', async () => {
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 4, orientation: 'landscape' })]
imagesStore.images = [makeImage({
id: 1,
approvedDeviceIds: [4],
cropOrientation: null,
cropParams: { natX: 0, natY: 0, natW: 0, natH: 0 } as any,
})]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.find('.library__action-btn--warn').exists()).toBe(false)
})
// LV-25c: defensive guard — clicking the sheet Delete button before confirmDelete is a no-op
it('clicking sheet Delete with no pending id is a no-op (defensive guard)', async () => {
const imagesStore = useImagesStore()
imagesStore.images = [makeImage({ id: 9 })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
const deleteSpy = vi.spyOn(imagesStore, 'deleteImage').mockResolvedValue()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
// The BaseBottomSheet stub always renders its slot, so the sheet Delete
// button is in the DOM. Click it without first triggering confirmDelete
// — exercises the !deletingId.value guard in doDelete.
const sheetDelete = wrapper.findAll('button').find(b => b.text() === 'Delete')!
await sheetDelete.trigger('click')
await flushPromises()
expect(deleteSpy).not.toHaveBeenCalled()
})
// LV-25: closing the delete sheet via the underlying bottom-sheet
it('closes the delete sheet when the bottom-sheet emits update:modelValue=false', async () => {
const imagesStore = useImagesStore()
imagesStore.images = [makeImage({ id: 9 })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
await wrapper.findAll('.library__action-btn')
.find(b => b.attributes('aria-label') === 'Delete photo')!.trigger('click')
await flushPromises()
const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
expect(sheet.props('modelValue')).toBe(true)
await sheet.vm.$emit('update:modelValue', false)
await wrapper.vm.$nextTick()
expect(wrapper.findComponent({ name: 'BaseBottomSheet' }).props('modelValue')).toBe(false)
})
// LV-21: clicking the orientation-mismatch warning starts an edit for that device
it('clicking the warn button calls initEdit with the mismatched device id', async () => {
uploadInitEdit.mockResolvedValue(undefined)
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 4, name: 'Hall', orientation: 'portrait' })]
imagesStore.images = [makeImage({ id: 1, approvedDeviceIds: [4], cropOrientation: 'landscape' })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const warn = wrapper.find('.library__action-btn--warn')
await warn.trigger('click')
await flushPromises()
expect(uploadInitEdit).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }), 4)
expect(routerPush).toHaveBeenCalledWith('/upload')
})
// LV-22: ShareSheet emits close — v-model setter runs
it('closes the share sheet when ShareSheet emits update:modelValue=false', async () => {
const imagesStore = useImagesStore()
imagesStore.images = [makeImage({ id: 5 })]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const shareBtn = wrapper.findAll('.library__action-btn').find(b => b.attributes('aria-label')?.includes('Share'))!
await shareBtn.trigger('click')
await flushPromises()
const sheet = wrapper.findComponent({ name: 'ShareSheet' })
expect(sheet.exists()).toBe(true)
await sheet.vm.$emit('update:modelValue', false)
await flushPromises()
// shareSheetOpen flips back to false; image id remains, so the component is still mounted
expect(wrapper.findComponent({ name: 'ShareSheet' }).props('modelValue')).toBe(false)
})
// LV-23: photo whose approved devices all have matching orientation — no warn
it('does not render the warn button when all approved devices match the photo orientation', async () => {
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
devicesStore.devices = [
makeDevice({ id: 4, orientation: 'landscape' }),
makeDevice({ id: 5, orientation: 'landscape' }),
]
imagesStore.images = [makeImage({
id: 1,
approvedDeviceIds: [4, 5],
cropOrientation: 'landscape',
})]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.find('.library__action-btn--warn').exists()).toBe(false)
})
// LV-24: image with no crop info → mismatchedDevice returns null up front
it('does not render the warn button when the image has no crop info', async () => {
const imagesStore = useImagesStore()
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 4 })]
imagesStore.images = [makeImage({
id: 1,
approvedDeviceIds: [4],
cropOrientation: null,
cropParams: null,
})]
imagesStore.loading = false
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
expect(wrapper.find('.library__action-btn--warn').exists()).toBe(false)
})
// LV-20: shared-loading state
it('shows a loading row while shared items are being fetched', async () => {
const imagesStore = useImagesStore()
vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue()
vi.spyOn(imagesStore, 'fetchSharedImages').mockReturnValue(new Promise(() => {}))
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
mockRoute.query = { tab: 'shared' }
const wrapper = mountView()
await flushPromises()
expect(wrapper.find('.library__loading').exists()).toBe(true)
})
})
@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import SettingsView from '@/views/SettingsView.vue'
import { useAuthStore } from '@/stores/auth'
import { THEMES } from '@/composables/useTheme'
describe('SettingsView', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('renders one swatch per theme and the user email', () => {
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'matt@example.com', roles: [], theme: 'warm-craft', timezone: 'UTC' })
const wrapper = mount(SettingsView)
expect(wrapper.findAll('.theme-swatch')).toHaveLength(THEMES.length)
expect(wrapper.text()).toContain('matt@example.com')
})
it('marks the user current theme as the active swatch', () => {
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'ocean-dusk', timezone: 'UTC' })
const wrapper = mount(SettingsView)
const active = wrapper.find('.theme-swatch--active')
expect(active.attributes('aria-label')).toBe('Ocean Dusk')
expect(active.attributes('aria-checked')).toBe('true')
})
it('falls back to warm-craft as active when user has no theme set', () => {
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: null, timezone: 'UTC' })
const wrapper = mount(SettingsView)
expect(wrapper.find('.theme-swatch--active').attributes('aria-label')).toBe('Warm Craft')
})
it('clicking a swatch saves the theme via PATCH /api/user/theme', async () => {
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
const wrapper = mount(SettingsView)
const swatch = wrapper.findAll('.theme-swatch').find(s => s.attributes('aria-label') === 'Sage & Cream')!
await swatch.trigger('click')
expect(fetchMock).toHaveBeenCalledWith(
'/api/user/theme',
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({ theme: 'sage-cream' }),
}),
)
vi.unstubAllGlobals()
})
it('renders a Sign out link to /logout', () => {
const auth = useAuthStore()
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
const wrapper = mount(SettingsView)
const logout = wrapper.find('a.settings__logout')
expect(logout.text()).toBe('Sign out')
expect(logout.attributes('href')).toBe('/logout')
})
})
+512
View File
@@ -0,0 +1,512 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import UploadView from '@/views/UploadView.vue'
import { useUploadStore } from '@/stores/upload'
import { useDevicesStore } from '@/stores/devices'
import { useImagesStore } from '@/stores/images'
import type { Device } from '@/types'
const routerReplace = vi.fn()
vi.mock('@/components/CropEditor.vue', () => ({
default: {
name: 'CropEditor',
template: '<div class="crop-editor-stub" />',
props: ['src', 'orientation', 'deviceName', 'initialParams', 'initialOrientation'],
emits: ['crop'],
},
}))
vi.mock('@/components/StickerCanvas.vue', () => ({
default: {
name: 'StickerCanvas',
template: '<div class="sticker-canvas-stub" />',
props: ['croppedUrl', 'orientation', 'stickers'],
emits: ['add-sticker', 'update-sticker', 'remove-sticker', 'done'],
},
}))
vi.mock('@/components/DevicePicker.vue', () => ({
default: {
name: 'DevicePicker',
template: '<div class="device-picker-stub" />',
props: ['modelValue', 'devices', 'selected', 'uploading'],
emits: ['update:modelValue', 'update:selected', 'confirm'],
},
}))
vi.mock('@/components/BaseButton.vue', () => ({
default: {
name: 'BaseButton',
template: '<button @click="$emit(\'click\')"><slot /></button>',
props: ['variant', 'disabled'],
emits: ['click'],
},
}))
vi.mock('vue-router', () => ({
useRouter: () => ({ replace: routerReplace, push: vi.fn() }),
}))
const toastShow = vi.fn()
vi.mock('@/stores/toast', () => ({
useToastStore: () => ({ show: toastShow }),
}))
const makeDevice = (overrides: Partial<Device> = {}): Device => ({
id: 1,
mac: 'AA',
name: 'Living Room',
orientation: 'landscape',
rotationIntervalMinutes: 60,
wakeHour: null,
timezone: 'UTC',
uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null,
lockedImageId: null,
currentImageId: null,
...overrides,
})
function primeUploadStore(file?: File) {
const upload = useUploadStore()
upload.originalFile = file ?? new File(['x'], 'orig.jpg', { type: 'image/jpeg' })
upload.originalUrl = 'blob:original'
return upload
}
describe('UploadView', () => {
beforeEach(() => {
setActivePinia(createPinia())
routerReplace.mockClear()
toastShow.mockClear()
vi.unstubAllGlobals()
vi.stubGlobal('URL', {
createObjectURL: vi.fn(() => 'blob:mock'),
revokeObjectURL: vi.fn(),
})
})
it('redirects to / when no upload is in progress', async () => {
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
mount(UploadView)
await flushPromises()
expect(routerReplace).toHaveBeenCalledWith('/')
})
it('starts on the crop step with the original URL passed to CropEditor', async () => {
primeUploadStore()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
const crop = wrapper.findComponent({ name: 'CropEditor' })
expect(crop.exists()).toBe(true)
expect(crop.props('src')).toBe('blob:original')
expect(wrapper.text()).toContain('Crop photo')
})
it('uses "Edit crop" as the step label when editing', async () => {
const upload = primeUploadStore()
upload.editingImageId = 7
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
expect(wrapper.text()).toContain('Edit crop')
})
it('falls back to landscape orientation when no devices are loaded', async () => {
primeUploadStore()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
expect(wrapper.findComponent({ name: 'CropEditor' }).props('orientation')).toBe('landscape')
})
it('uses the context device orientation when contextDeviceId matches', async () => {
const upload = primeUploadStore()
upload.contextDeviceId = 2
const devices = useDevicesStore()
devices.devices = [makeDevice({ id: 1, orientation: 'landscape' }), makeDevice({ id: 2, orientation: 'portrait' })]
vi.spyOn(devices, 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
expect(wrapper.findComponent({ name: 'CropEditor' }).props('orientation')).toBe('portrait')
expect(wrapper.findComponent({ name: 'CropEditor' }).props('deviceName')).toBe(devices.devices[1].name)
})
it('uses the first device as the context when no contextDeviceId is set', async () => {
primeUploadStore()
const devices = useDevicesStore()
devices.devices = [makeDevice({ id: 1, orientation: 'portrait' })]
vi.spyOn(devices, 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
expect(wrapper.findComponent({ name: 'CropEditor' }).props('orientation')).toBe('portrait')
})
it('crop emit advances to stickers step and stores the crop on the upload store', async () => {
primeUploadStore()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
const blob = new Blob(['x'])
const params = { natX: 0, natY: 0, natW: 100, natH: 50 }
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', { blob, params, orientation: 'landscape' })
await flushPromises()
expect(wrapper.findComponent({ name: 'StickerCanvas' }).exists()).toBe(true)
expect(wrapper.text()).toContain('Add stickers')
})
it('skip on stickers step opens the device picker for new uploads', async () => {
const upload = primeUploadStore()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
const blob = new Blob(['x'])
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', { blob, params: { natX: 0, natY: 0, natW: 1, natH: 1 }, orientation: 'landscape' })
upload.croppedBlob = blob
await flushPromises()
const skip = wrapper.find('.upload-view__skip')
await skip.trigger('click')
await flushPromises()
expect(wrapper.findComponent({ name: 'DevicePicker' }).props('modelValue')).toBe(true)
})
it('skip on stickers triggers reprocess directly when editing', async () => {
const upload = primeUploadStore()
upload.editingImageId = 11
const devices = useDevicesStore()
vi.spyOn(devices, 'fetchDevices').mockResolvedValue()
const images = useImagesStore()
const reprocess = vi.spyOn(images, 'reprocessImage').mockResolvedValue({ id: 11 } as any)
const wrapper = mount(UploadView)
await flushPromises()
const blob = new Blob(['x'])
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', { blob, params: { natX: 0, natY: 0, natW: 1, natH: 1 }, orientation: 'landscape' })
upload.croppedBlob = blob
await flushPromises()
await wrapper.find('.upload-view__skip').trigger('click')
await flushPromises()
expect(reprocess).toHaveBeenCalled()
expect(wrapper.text()).toContain('Photo updated!')
})
it('skip is a no-op when there is no cropped blob yet', async () => {
primeUploadStore()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
// Force into stickers without having a croppedBlob
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', { blob: null as any, params: null as any, orientation: 'landscape' })
// Even if we click skip, no DevicePicker should open
const skip = wrapper.find('.upload-view__skip')
if (skip.exists()) await skip.trigger('click')
expect(wrapper.findComponent({ name: 'DevicePicker' }).props('modelValue')).toBe(false)
})
it('stickers done opens the picker for new uploads', async () => {
primeUploadStore()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', {
blob: new Blob(['x']),
params: { natX: 0, natY: 0, natW: 1, natH: 1 },
orientation: 'landscape',
})
await flushPromises()
await wrapper.findComponent({ name: 'StickerCanvas' }).vm.$emit('done', new Blob(['final']))
await flushPromises()
expect(wrapper.findComponent({ name: 'DevicePicker' }).props('modelValue')).toBe(true)
})
it('stickers done triggers reprocess directly when editing', async () => {
const upload = primeUploadStore()
upload.editingImageId = 22
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const reprocess = vi.spyOn(useImagesStore(), 'reprocessImage').mockResolvedValue({ id: 22 } as any)
const wrapper = mount(UploadView)
await flushPromises()
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', {
blob: new Blob(['x']),
params: { natX: 0, natY: 0, natW: 1, natH: 1 },
orientation: 'landscape',
})
await flushPromises()
await wrapper.findComponent({ name: 'StickerCanvas' }).vm.$emit('done', new Blob(['final']))
await flushPromises()
expect(reprocess).toHaveBeenCalled()
expect(wrapper.text()).toContain('Photo updated!')
})
it('back from crop cleans up and routes to /library', async () => {
primeUploadStore()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
await wrapper.find('.upload-view__back').trigger('click')
expect(routerReplace).toHaveBeenCalledWith('/library')
})
it('back from stickers returns to the crop step', async () => {
primeUploadStore()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', {
blob: new Blob(['x']),
params: { natX: 0, natY: 0, natW: 1, natH: 1 },
orientation: 'landscape',
})
await flushPromises()
expect(wrapper.text()).toContain('Add stickers')
await wrapper.find('.upload-view__back').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Crop photo')
})
it('confirm in the device picker uploads, sets approvals, and shows the done step', async () => {
const upload = primeUploadStore()
upload.selectedDeviceIds = [1, 2]
const images = useImagesStore()
const uploadSpy = vi.spyOn(images, 'uploadImage').mockResolvedValue({ id: 100 } as any)
const approvalSpy = vi.spyOn(images, 'setApproval').mockResolvedValue()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', {
blob: new Blob(['x']),
params: { natX: 0, natY: 0, natW: 1, natH: 1 },
orientation: 'landscape',
})
await flushPromises()
await wrapper.findComponent({ name: 'StickerCanvas' }).vm.$emit('done', new Blob(['final']))
await flushPromises()
await wrapper.findComponent({ name: 'DevicePicker' }).vm.$emit('confirm')
await flushPromises()
expect(uploadSpy).toHaveBeenCalled()
expect(approvalSpy).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('Photo added!')
})
it('upload errors surface as a toast', async () => {
primeUploadStore()
vi.spyOn(useImagesStore(), 'uploadImage').mockRejectedValue(new Error('disk full'))
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', {
blob: new Blob(['x']),
params: { natX: 0, natY: 0, natW: 1, natH: 1 },
orientation: 'landscape',
})
await flushPromises()
await wrapper.findComponent({ name: 'StickerCanvas' }).vm.$emit('done', new Blob(['final']))
await flushPromises()
await wrapper.findComponent({ name: 'DevicePicker' }).vm.$emit('confirm')
await flushPromises()
expect(toastShow).toHaveBeenCalledWith('disk full', 'error')
})
it('falls back to a generic message for non-Error rejections', async () => {
primeUploadStore()
vi.spyOn(useImagesStore(), 'uploadImage').mockRejectedValue('weird')
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', {
blob: new Blob(['x']),
params: { natX: 0, natY: 0, natW: 1, natH: 1 },
orientation: 'landscape',
})
await flushPromises()
await wrapper.findComponent({ name: 'StickerCanvas' }).vm.$emit('done', new Blob(['final']))
await flushPromises()
await wrapper.findComponent({ name: 'DevicePicker' }).vm.$emit('confirm')
await flushPromises()
expect(toastShow).toHaveBeenCalledWith('Upload failed', 'error')
})
it('Done button on the success step routes to /library', async () => {
primeUploadStore()
vi.spyOn(useImagesStore(), 'uploadImage').mockResolvedValue({ id: 1 } as any)
vi.spyOn(useImagesStore(), 'setApproval').mockResolvedValue()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', {
blob: new Blob(['x']),
params: { natX: 0, natY: 0, natW: 1, natH: 1 },
orientation: 'landscape',
})
await flushPromises()
await wrapper.findComponent({ name: 'StickerCanvas' }).vm.$emit('done', new Blob(['final']))
await flushPromises()
await wrapper.findComponent({ name: 'DevicePicker' }).vm.$emit('confirm')
await flushPromises()
routerReplace.mockClear()
await wrapper.find('.upload-view__done-btn').trigger('click')
expect(routerReplace).toHaveBeenCalledWith('/library')
})
it('updates selectedDeviceIds when the picker emits update:selected', async () => {
const upload = primeUploadStore()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
await wrapper.findComponent({ name: 'DevicePicker' }).vm.$emit('update:selected', [3, 4])
expect(upload.selectedDeviceIds).toEqual([3, 4])
})
it('forwards sticker emits to the upload store', async () => {
const upload = primeUploadStore()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', {
blob: new Blob(['x']),
params: { natX: 0, natY: 0, natW: 1, natH: 1 },
orientation: 'landscape',
})
await flushPromises()
const canvas = wrapper.findComponent({ name: 'StickerCanvas' })
const sticker = { id: 's1', type: 'emoji', x: 0, y: 0, scale: 1, rotation: 0 } as any
await canvas.vm.$emit('add-sticker', sticker)
expect(upload.stickers).toHaveLength(1)
await canvas.vm.$emit('update-sticker', 's1', { x: 9 })
expect(upload.stickers[0].x).toBe(9)
await canvas.vm.$emit('remove-sticker', 's1')
expect(upload.stickers).toHaveLength(0)
})
it('closes the device picker when it emits update:modelValue=false', async () => {
primeUploadStore()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
const picker = wrapper.findComponent({ name: 'DevicePicker' })
expect(picker.props('modelValue')).toBe(false)
await picker.vm.$emit('update:modelValue', true)
await wrapper.vm.$nextTick()
expect(wrapper.findComponent({ name: 'DevicePicker' }).props('modelValue')).toBe(true)
await picker.vm.$emit('update:modelValue', false)
await wrapper.vm.$nextTick()
expect(wrapper.findComponent({ name: 'DevicePicker' }).props('modelValue')).toBe(false)
})
it('confirm fired before any stickers/crop is a no-op (defensive guard)', async () => {
primeUploadStore()
const uploadSpy = vi.spyOn(useImagesStore(), 'uploadImage').mockResolvedValue({ id: 1 } as any)
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
// Picker exists but finalBlob is null — confirm should early-return
await wrapper.findComponent({ name: 'DevicePicker' }).vm.$emit('confirm')
await flushPromises()
expect(uploadSpy).not.toHaveBeenCalled()
})
it('reprocess on edit forwards undefined when cropParams/cropOrientation are null', async () => {
const upload = primeUploadStore()
upload.editingImageId = 33
upload.cropParams = null
upload.cropOrientation = null
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const reprocess = vi.spyOn(useImagesStore(), 'reprocessImage').mockResolvedValue({ id: 33 } as any)
const wrapper = mount(UploadView)
await flushPromises()
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', {
blob: new Blob(['x']),
params: { natX: 0, natY: 0, natW: 1, natH: 1 },
orientation: 'landscape',
})
await flushPromises()
// After crop, upload.cropParams is set by setCrop. Reset to null to exercise null branch.
upload.cropParams = null
upload.cropOrientation = null
await wrapper.findComponent({ name: 'StickerCanvas' }).vm.$emit('done', new Blob(['final']))
await flushPromises()
expect(reprocess).toHaveBeenCalledWith(33, expect.any(File), expect.objectContaining({
cropParams: undefined,
cropOrientation: undefined,
}))
})
it('uploadImage on a new upload forwards undefined for null optional fields', async () => {
const upload = primeUploadStore()
upload.originalFile = null
upload.cropParams = null
upload.cropOrientation = null
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const uploadSpy = vi.spyOn(useImagesStore(), 'uploadImage').mockResolvedValue({ id: 1 } as any)
// No originalFile → onMounted will redirect; instead set it after the redirect would have run
upload.originalFile = new File(['x'], 'x.jpg')
upload.originalUrl = 'blob:x'
const wrapper = mount(UploadView)
await flushPromises()
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', {
blob: new Blob(['x']),
params: { natX: 0, natY: 0, natW: 1, natH: 1 },
orientation: 'landscape',
})
await flushPromises()
// Reset to null after onCrop sets these via the store's setCrop
upload.cropParams = null
upload.cropOrientation = null
upload.originalFile = null
await wrapper.findComponent({ name: 'StickerCanvas' }).vm.$emit('done', new Blob(['final']))
await flushPromises()
await wrapper.findComponent({ name: 'DevicePicker' }).vm.$emit('confirm')
await flushPromises()
expect(uploadSpy).toHaveBeenCalledWith(expect.any(File), expect.objectContaining({
original: undefined,
cropParams: undefined,
cropOrientation: undefined,
}))
})
it('does not render the device picker when in edit mode', async () => {
const upload = primeUploadStore()
upload.editingImageId = 12
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
expect(wrapper.findComponent({ name: 'DevicePicker' }).exists()).toBe(false)
})
it('hides the back button on the done step', async () => {
primeUploadStore()
vi.spyOn(useImagesStore(), 'uploadImage').mockResolvedValue({ id: 1 } as any)
vi.spyOn(useImagesStore(), 'setApproval').mockResolvedValue()
vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue()
const wrapper = mount(UploadView)
await flushPromises()
await wrapper.findComponent({ name: 'CropEditor' }).vm.$emit('crop', {
blob: new Blob(['x']),
params: { natX: 0, natY: 0, natW: 1, natH: 1 },
orientation: 'landscape',
})
await flushPromises()
await wrapper.findComponent({ name: 'StickerCanvas' }).vm.$emit('done', new Blob(['final']))
await flushPromises()
await wrapper.findComponent({ name: 'DevicePicker' }).vm.$emit('confirm')
await flushPromises()
expect(wrapper.find('.upload-view__back').exists()).toBe(false)
expect(wrapper.text()).toContain('Added')
})
})