081ca83613
CI / test (push) Has been cancelled
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>
181 lines
7.0 KiB
TypeScript
181 lines
7.0 KiB
TypeScript
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/)
|
||
})
|
||
})
|