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

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

Test suite expanded to 278 tests / 100% line coverage (99.84% statements,
99.78% branches). Remaining gaps are unreachable defensive code.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 18:07:05 -04:00
parent e0bad975ec
commit 5fcfb806be
58 changed files with 2922 additions and 60 deletions
@@ -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]])
})
})