chore: stage all in-progress work before repo split
CI / test (push) Has been cancelled

Web app: new entities (Image, RenderedAsset, SharedImage, Token,
DeviceImageHistory), enums, repositories, controllers, message handlers,
migrations, tests, frontend upload/library/sticker UI, Vue components.

Firmware: EPD background screen binaries + gen scripts, setup_bg header.

Infra: ddev config, test bundle, gitignore coverage dir.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 12:11:31 -04:00
parent 062c52eec7
commit 12245759ac
149 changed files with 14846 additions and 92 deletions
@@ -0,0 +1,109 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import BaseButton from '@/components/BaseButton.vue'
describe('BaseButton', () => {
it('renders slot content', () => {
const wrapper = mount(BaseButton, {
slots: { default: 'Click me' },
})
expect(wrapper.text()).toContain('Click me')
})
it('renders as a <button> by default', () => {
const wrapper = mount(BaseButton, {
slots: { default: 'OK' },
})
expect(wrapper.element.tagName).toBe('BUTTON')
})
it('applies primary variant class by default', () => {
const wrapper = mount(BaseButton, { slots: { default: 'OK' } })
expect(wrapper.classes()).toContain('btn--primary')
})
it('applies the given variant class', () => {
const wrapper = mount(BaseButton, {
props: { variant: 'destructive' },
slots: { default: 'Delete' },
})
expect(wrapper.classes()).toContain('btn--destructive')
})
it('shows spinner element when loading is true', () => {
const wrapper = mount(BaseButton, {
props: { loading: true },
slots: { default: 'Saving...' },
})
expect(wrapper.find('.btn__spinner').exists()).toBe(true)
})
it('does not show spinner when loading is false', () => {
const wrapper = mount(BaseButton, {
props: { loading: false },
slots: { default: 'Save' },
})
expect(wrapper.find('.btn__spinner').exists()).toBe(false)
})
it('applies btn--loading class when loading', () => {
const wrapper = mount(BaseButton, {
props: { loading: true },
slots: { default: 'Wait' },
})
expect(wrapper.classes()).toContain('btn--loading')
})
it('is disabled when disabled prop is true', () => {
const wrapper = mount(BaseButton, {
props: { disabled: true },
slots: { default: 'Blocked' },
})
expect((wrapper.element as HTMLButtonElement).disabled).toBe(true)
})
it('is disabled when loading prop is true', () => {
const wrapper = mount(BaseButton, {
props: { loading: true },
slots: { default: 'Loading' },
})
expect((wrapper.element as HTMLButtonElement).disabled).toBe(true)
})
it('is not disabled when neither disabled nor loading', () => {
const wrapper = mount(BaseButton, {
props: { disabled: false, loading: false },
slots: { default: 'Go' },
})
expect((wrapper.element as HTMLButtonElement).disabled).toBe(false)
})
it('emits click event when clicked and not disabled', async () => {
const wrapper = mount(BaseButton, { slots: { default: 'Go' } })
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeTruthy()
})
it('type attribute defaults to button', () => {
const wrapper = mount(BaseButton, { slots: { default: 'OK' } })
expect(wrapper.attributes('type')).toBe('button')
})
it('type attribute can be set to submit', () => {
const wrapper = mount(BaseButton, {
props: { type: 'submit' },
slots: { default: 'Submit' },
})
expect(wrapper.attributes('type')).toBe('submit')
})
it('renders as an anchor when tag is a', () => {
const wrapper = mount(BaseButton, {
props: { tag: 'a' },
slots: { default: 'Link' },
})
expect(wrapper.element.tagName).toBe('A')
// <a> should not have a type attribute
expect(wrapper.attributes('type')).toBeUndefined()
})
})
@@ -0,0 +1,112 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import DevicePicker from '@/components/DevicePicker.vue'
import type { Device } from '@/types'
// Stub child components DevicePicker wraps
vi.mock('@/components/BaseBottomSheet.vue', () => ({
default: {
name: 'BaseBottomSheet',
template: '<div class="bottom-sheet-stub"><slot /></div>',
props: ['modelValue', 'label'],
emits: ['update:modelValue'],
},
}))
vi.mock('@/components/BaseButton.vue', () => ({
default: {
name: 'BaseButton',
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
props: ['variant', 'disabled'],
emits: ['click'],
},
}))
const makeDevice = (overrides: Partial<Device> = {}): Device => ({
id: 1,
mac: 'AA:BB:CC:DD:EE:FF',
name: 'Living Room',
orientation: 'landscape',
rotationIntervalMinutes: 60,
wakeHour: null,
timezone: 'America/Chicago',
uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null,
lockedImageId: null,
...overrides,
})
describe('DevicePicker', () => {
const devices = [
makeDevice({ id: 1, name: 'Living Room' }),
makeDevice({ id: 2, name: 'Bedroom' }),
]
function mountPicker(selected: number[] = []) {
return mount(DevicePicker, {
props: {
modelValue: true,
devices,
selected,
},
})
}
// DP-01: Selecting a device emits update:selected with the device added
it('checking a device emits update:selected with device id added', async () => {
const wrapper = mountPicker([])
const checkboxes = wrapper.findAll('input[type="checkbox"]')
// Click the first checkbox (Living Room, id=1)
await checkboxes[0].trigger('change')
const emitted = wrapper.emitted('update:selected')
expect(emitted).toBeTruthy()
expect(emitted![0][0]).toEqual([1])
})
// DP-02: Deselecting a device emits update:selected with device id removed
it('unchecking a device emits update:selected with device id removed', async () => {
// Start with both selected
const wrapper = mountPicker([1, 2])
const checkboxes = wrapper.findAll('input[type="checkbox"]')
// Click the first checkbox (Living Room, id=1) — it's currently checked, so this deselects
await checkboxes[0].trigger('change')
const emitted = wrapper.emitted('update:selected')
expect(emitted).toBeTruthy()
// Should emit [2] — Living Room removed
expect(emitted![0][0]).toEqual([2])
})
// DP-03: Checkboxes reflect the selected prop
it('checkboxes are checked for ids in selected prop', async () => {
const wrapper = mountPicker([2])
const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect((checkboxes[0].element as HTMLInputElement).checked).toBe(false) // id=1 not selected
expect((checkboxes[1].element as HTMLInputElement).checked).toBe(true) // id=2 selected
})
// DP-04: Confirm button disabled when nothing selected
it('confirm button is disabled when selected is empty', async () => {
const wrapper = mountPicker([])
const btn = wrapper.find('button')
expect((btn.element as HTMLButtonElement).disabled).toBe(true)
})
// DP-05: Confirm button enabled when at least one device selected
it('confirm button is enabled when a device is selected', async () => {
const wrapper = mountPicker([1])
const btn = wrapper.find('button')
expect((btn.element as HTMLButtonElement).disabled).toBe(false)
})
// DP-06: Device names are rendered
it('renders all device names', () => {
const wrapper = mountPicker([])
expect(wrapper.text()).toContain('Living Room')
expect(wrapper.text()).toContain('Bedroom')
})
})
@@ -0,0 +1,131 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import FrameCard from '@/components/FrameCard.vue'
// Mock vue-konva to avoid canvas issues if transitively imported
vi.mock('vue-konva', () => ({}))
const defaultProps = {
deviceId: 1,
name: 'Living Room',
size: 'large' as const,
status: 'ok' as const,
orientation: 'landscape' as const,
}
describe('FrameCard', () => {
it('renders device name', () => {
const wrapper = mount(FrameCard, { props: defaultProps })
expect(wrapper.text()).toContain('Living Room')
})
it('does not show status badge when status is ok', () => {
const wrapper = mount(FrameCard, { props: defaultProps })
expect(wrapper.find('.frame-card__status-badge').exists()).toBe(false)
})
it('shows "Offline" badge when status is offline', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, status: 'offline' },
})
const badge = wrapper.find('.frame-card__status-badge')
expect(badge.exists()).toBe(true)
expect(badge.text()).toContain('Offline')
})
it('shows "Sync issue" badge when status is sync-fail', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, status: 'sync-fail' },
})
const badge = wrapper.find('.frame-card__status-badge')
expect(badge.exists()).toBe(true)
expect(badge.text()).toContain('Sync issue')
})
it('applies offline modifier class when status is offline', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, status: 'offline' },
})
expect(wrapper.classes()).toContain('frame-card--offline')
})
it('applies sync-fail modifier class when status is sync-fail', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, status: 'sync-fail' },
})
expect(wrapper.classes()).toContain('frame-card--sync-fail')
})
it('shows settings button in large size', () => {
const wrapper = mount(FrameCard, { props: { ...defaultProps, size: 'large' } })
expect(wrapper.find('.frame-card__settings-btn').exists()).toBe(true)
})
it('does not show settings button in compact size', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'compact' },
})
expect(wrapper.find('.frame-card__settings-btn').exists()).toBe(false)
})
it('shows img element when thumbnailUrl is provided', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, thumbnailUrl: '/thumb/test.jpg' },
})
const img = wrapper.find('img.frame-card__img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe('/thumb/test.jpg')
})
it('shows empty preview placeholder when no thumbnailUrl', () => {
const wrapper = mount(FrameCard, { props: defaultProps })
expect(wrapper.find('.frame-card__empty-preview').exists()).toBe(true)
expect(wrapper.find('img.frame-card__img').exists()).toBe(false)
})
it('shows photo count in compact size', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'compact', photoCount: 3 },
})
expect(wrapper.text()).toContain('3 photos')
})
it('uses singular "photo" when photoCount is 1', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'compact', photoCount: 1 },
})
expect(wrapper.text()).toContain('1 photo')
expect(wrapper.text()).not.toContain('1 photos')
})
it('emits add-photo with deviceId when add button clicked', async () => {
const wrapper = mount(FrameCard, { props: defaultProps })
await wrapper.find('.frame-card__add-btn').trigger('click')
expect(wrapper.emitted('add-photo')).toBeTruthy()
expect(wrapper.emitted('add-photo')![0]).toEqual([1])
})
it('emits edit with deviceId when settings button clicked (large)', async () => {
const wrapper = mount(FrameCard, { props: { ...defaultProps, size: 'large' } })
await wrapper.find('.frame-card__settings-btn').trigger('click')
expect(wrapper.emitted('edit')).toBeTruthy()
expect(wrapper.emitted('edit')![0]).toEqual([1])
})
it('sets landscape aspect ratio style in large mode', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'landscape' },
})
const preview = wrapper.find('.frame-card__preview')
// Vue serializes { aspectRatio: '5/3' } as 'aspect-ratio: 5 / 3;'
expect(preview.attributes('style')).toMatch(/aspect-ratio:\s*5\s*\/\s*3/)
})
it('sets portrait aspect ratio style in large mode', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'portrait' },
})
const preview = wrapper.find('.frame-card__preview')
expect(preview.attributes('style')).toMatch(/aspect-ratio:\s*3\s*\/\s*5/)
})
})
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import ShareSheet from '@/components/ShareSheet.vue'
import { useImagesStore } from '@/stores/images'
const BaseBottomSheetStub = {
template: '<div><slot /></div>',
props: ['modelValue', 'label'],
emits: ['update:modelValue'],
}
const BaseButtonStub = {
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
props: ['variant', 'disabled'],
emits: ['click'],
}
describe('ShareSheet', () => {
beforeEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
setActivePinia(createPinia())
})
function mountShareSheet(imageId = 1) {
return mount(ShareSheet, {
props: { modelValue: true, imageId },
global: {
stubs: {
BaseBottomSheet: BaseBottomSheetStub,
BaseButton: BaseButtonStub,
},
},
})
}
// SS-01: successful share shows success message and clears email field
it('shows success message and clears email on successful share', async () => {
const store = useImagesStore()
vi.spyOn(store, 'shareImage').mockResolvedValue(undefined)
const wrapper = mountShareSheet()
const input = wrapper.find('input[type="email"]')
await input.setValue('friend@example.com')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(store.shareImage).toHaveBeenCalledWith(1, 'friend@example.com')
expect(wrapper.text()).toContain('Invite sent to friend@example.com')
expect((input.element as HTMLInputElement).value).toBe('')
})
// SS-02: failed share shows error message
it('shows error message on failed share', async () => {
const store = useImagesStore()
vi.spyOn(store, 'shareImage').mockRejectedValue(new Error('Server error'))
const wrapper = mountShareSheet()
const input = wrapper.find('input[type="email"]')
await input.setValue('friend@example.com')
await wrapper.find('button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Server error')
expect(wrapper.find('.share-sheet__error').exists()).toBe(true)
})
// SS-03: button is disabled when email input is empty
it('button is disabled when email is empty', () => {
const wrapper = mountShareSheet()
const button = wrapper.find('button')
expect(button.attributes('disabled')).toBeDefined()
})
})