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:
@@ -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]])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user