Files
pictureFrame-webApp/frontend/src/test/components/FrameCard.test.ts
T
football2801 081ca83613
CI / test (push) Has been cancelled
fix(v2): preview rotation + crop aspect for 13.3" hardware
Two related bugs that surfaced on the first 13.3" device's first photo:

1) Web-UI portrait preview was 90° sideways. DeviceApiController::
   renderBinToPng rotated whenever the device was Portrait — correct
   for V1 (landscape-native, Portrait => renderer rotated, so preview
   un-rotates) but wrong for V2 (portrait-native — the renderer
   doesn't rotate, so the preview shouldn't either). Now mirrors the
   render-pipeline check: rotate only when `orientation !==
   model->nativeOrientation()`. Two new functional tests pin the V2
   portrait and V2 landscape PNG dimensions to guard against
   regressions.

2) Cropped photo letterboxed on the 13.3" panel. CropEditor /
   StickerCanvas / FrameCard had V1 dimensions hardcoded (1600×960
   = 5:3 aspect). V2 is 4:3 (1200×1600 portrait / 1600×1200
   landscape), so a "full crop" came out the wrong shape and the
   server's white-canvas composite added bars. New `panelDims(model,
   orientation)` helper in @/types is the single source of truth on
   the frontend; matches DeviceModel::width/height on the server.
   Threaded `model` through Device serializer → Device type →
   UploadView → CropEditor / StickerCanvas, and HomeView → FrameCard.
   FrameCard tests updated to cover all four model × orientation
   placeholders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:02:39 -04:00

181 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('shows "Online" status when status is ok', () => {
const wrapper = mount(FrameCard, { props: defaultProps })
expect(wrapper.find('.frame-card__status-line').text()).toContain('Online')
})
it('shows "Offline" status when status is offline', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, status: 'offline' },
})
expect(wrapper.find('.frame-card__status-line').text()).toContain('Offline')
})
it('shows "Sync issue" status when status is sync-fail', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, status: 'sync-fail' },
})
expect(wrapper.find('.frame-card__status-line').text()).toContain('Sync issue')
})
it('renders lastSync and nextSync lines on the large card', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, lastSync: '2h ago', nextSync: 'next sync in 4h' },
})
const sync = wrapper.find('.frame-card__sync-line')
expect(sync.exists()).toBe(true)
expect(sync.text()).toContain('synced 2h ago')
expect(sync.text()).toContain('next sync in 4h')
})
it('omits the sync line on the large card when no sync info is provided', () => {
const wrapper = mount(FrameCard, { props: defaultProps })
expect(wrapper.find('.frame-card__sync-line').exists()).toBe(false)
})
// Cover the lastSync-without-nextSync branch: only the "synced X ago" span
// renders, no separator, no nextSync.
it('renders only lastSync when nextSync is absent', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, lastSync: '5m ago' },
})
const sync = wrapper.find('.frame-card__sync-line')
expect(sync.text()).toContain('synced 5m ago')
expect(sync.find('.frame-card__sync-sep').exists()).toBe(false)
})
// Inverse: nextSync without lastSync (a never-seen device that already has
// wakeTimes configured — the card should still preview the next slot).
it('renders only nextSync when lastSync is absent', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, nextSync: 'next sync ~6 AM tomorrow' },
})
const sync = wrapper.find('.frame-card__sync-line')
expect(sync.exists()).toBe(true)
expect(sync.text()).toContain('next sync ~6 AM tomorrow')
expect(sync.text()).not.toContain('synced')
})
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('reserves a V1 landscape (1600 × 960) placeholder when no thumbnail is present', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'landscape', model: 'v1' },
})
const empty = wrapper.find('.frame-card__empty-preview')
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*1600\s*\/\s*960/)
})
it('reserves a V1 portrait (960 × 1600) placeholder when no thumbnail is present', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'portrait', model: 'v1' },
})
const empty = wrapper.find('.frame-card__empty-preview')
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*960\s*\/\s*1600/)
})
it('reserves a V2 portrait (1200 × 1600) placeholder when no thumbnail is present', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'portrait', model: 'v2' },
})
const empty = wrapper.find('.frame-card__empty-preview')
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*1200\s*\/\s*1600/)
})
it('reserves a V2 landscape (1600 × 1200) placeholder when no thumbnail is present', () => {
const wrapper = mount(FrameCard, {
props: { ...defaultProps, size: 'large', orientation: 'landscape', model: 'v2' },
})
const empty = wrapper.find('.frame-card__empty-preview')
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*1600\s*\/\s*1200/)
})
})