feat: ship as a real iOS-installable PWA, restructure bottom nav, fix safe-area
CI / test (push) Has been cancelled
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:
@@ -18,10 +18,7 @@ const { applyTheme } = useTheme()
|
||||
|
||||
onMounted(() => {
|
||||
const stamped = document.documentElement.dataset.theme
|
||||
if (stamped && auth.user) {
|
||||
auth.user.theme = stamped
|
||||
} else if (auth.user?.theme) {
|
||||
applyTheme(auth.user.theme)
|
||||
}
|
||||
const resolved = stamped || auth.user?.theme
|
||||
if (resolved) applyTheme(resolved)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -68,7 +68,7 @@ watch(() => props.modelValue, async (open) => {
|
||||
width: 100%;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
padding: var(--space-3) var(--space-4) var(--space-6);
|
||||
padding: var(--space-3) var(--space-4) calc(var(--space-6) + env(safe-area-inset-bottom));
|
||||
max-height: 90dvh;
|
||||
overflow-y: auto;
|
||||
outline: none;
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
v-for="tab in tabs"
|
||||
:key="tab.name"
|
||||
:to="tab.to"
|
||||
:class="['bottom-nav__tab', { 'bottom-nav__tab--active': isActive(tab.to) }]"
|
||||
:class="['bottom-nav__tab', { 'bottom-nav__tab--active': tab.isActive(route) }]"
|
||||
:aria-label="tab.label"
|
||||
:aria-current="isActive(tab.to) ? 'page' : undefined"
|
||||
:aria-current="tab.isActive(route) ? 'page' : undefined"
|
||||
>
|
||||
<span class="bottom-nav__icon-wrap" aria-hidden="true">
|
||||
<span class="bottom-nav__icon" v-html="tab.icon" />
|
||||
<span v-if="tab.name === 'shared' && imagesStore.pendingCount > 0" class="bottom-nav__badge">
|
||||
<span v-if="tab.name === 'library' && imagesStore.pendingCount > 0" class="bottom-nav__badge">
|
||||
{{ imagesStore.pendingCount > 9 ? '9+' : imagesStore.pendingCount }}
|
||||
</span>
|
||||
</span>
|
||||
@@ -20,44 +20,43 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, type RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
import { useImagesStore } from '@/stores/images'
|
||||
|
||||
const route = useRoute()
|
||||
const imagesStore = useImagesStore()
|
||||
|
||||
const tabs = [
|
||||
interface Tab {
|
||||
name: string
|
||||
label: string
|
||||
to: string
|
||||
icon: string
|
||||
isActive: (r: RouteLocationNormalizedLoaded) => boolean
|
||||
}
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{
|
||||
name: 'home',
|
||||
label: 'Home',
|
||||
to: '/',
|
||||
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9,22 9,12 15,12 15,22"/></svg>',
|
||||
isActive: r => r.path === '/',
|
||||
},
|
||||
{
|
||||
name: 'library',
|
||||
label: 'Library',
|
||||
to: '/library',
|
||||
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'shared',
|
||||
label: 'Shared',
|
||||
to: '/library?tab=shared',
|
||||
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>',
|
||||
isActive: r => r.path.startsWith('/library'),
|
||||
},
|
||||
{
|
||||
name: 'settings',
|
||||
label: 'Settings',
|
||||
to: '/settings',
|
||||
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
|
||||
isActive: r => r.path.startsWith('/settings'),
|
||||
},
|
||||
]
|
||||
|
||||
function isActive(to: string) {
|
||||
const path = to.split('?')[0]
|
||||
if (path === '/') return route.path === '/'
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -66,11 +65,11 @@ function isActive(to: string) {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 64px;
|
||||
background: var(--color-surface);
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
z-index: 50;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
|
||||
@media (min-width: 960px) {
|
||||
display: none;
|
||||
@@ -83,6 +82,7 @@ function isActive(to: string) {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
height: 64px;
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
min-height: var(--touch-min);
|
||||
|
||||
@@ -25,6 +25,12 @@ export function useTheme() {
|
||||
function applyTheme(themeId: string) {
|
||||
document.documentElement.dataset.theme = themeId
|
||||
if (auth.user) auth.user.theme = themeId
|
||||
|
||||
const theme = THEMES.find(t => t.id === themeId)
|
||||
if (theme) {
|
||||
const meta = document.querySelector('meta[name="theme-color"]')
|
||||
if (meta) meta.setAttribute('content', theme.bg)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTheme(themeId: string) {
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
|
||||
// Touch target minimum
|
||||
--touch-min: 44px;
|
||||
|
||||
// Bottom nav clearance — 64px row + iOS home-indicator inset (zero on devices without one)
|
||||
--bottom-nav-height: calc(64px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
// ─── Themes ──────────────────────────────────────────────────────────────────
|
||||
@@ -158,6 +161,7 @@ body {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
// ─── Focus visible ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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' }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -203,7 +203,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useImagesStore } from '@/stores/images'
|
||||
import { useDevicesStore } from '@/stores/devices'
|
||||
@@ -229,7 +229,18 @@ const TABS = [
|
||||
] as const
|
||||
type Tab = typeof TABS[number]['id']
|
||||
|
||||
const activeTab = ref<Tab>((route.query.tab as Tab) ?? 'all')
|
||||
function tabFromQuery(value: unknown): Tab {
|
||||
return TABS.some(t => t.id === value) ? (value as Tab) : 'all'
|
||||
}
|
||||
|
||||
const activeTab = ref<Tab>(tabFromQuery(route.query.tab))
|
||||
|
||||
watch(() => route.query.tab, (next) => {
|
||||
const tab = tabFromQuery(next)
|
||||
if (tab === activeTab.value) return
|
||||
activeTab.value = tab
|
||||
if (tab === 'shared') loadShared(sharedTab.value)
|
||||
})
|
||||
|
||||
const SHARED_TABS = [
|
||||
{ id: 'pending', label: 'Pending' },
|
||||
@@ -383,13 +394,13 @@ async function doDelete() {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.library {
|
||||
padding-bottom: calc(64px + var(--space-4));
|
||||
padding-bottom: calc(var(--bottom-nav-height) + var(--space-4));
|
||||
|
||||
&__tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
top: env(safe-area-inset-top);
|
||||
background: var(--color-bg);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ function select(themeId: string) {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.settings {
|
||||
padding: var(--space-4) var(--space-4) calc(64px + var(--space-6));
|
||||
padding: var(--space-4) var(--space-4) calc(var(--bottom-nav-height) + var(--space-6));
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
|
||||
|
||||
@@ -230,6 +230,8 @@ function finish() {
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
|
||||
&__header {
|
||||
flex-shrink: 0;
|
||||
|
||||
Reference in New Issue
Block a user