diff --git a/frontend/src/test/views/HomeView.test.ts b/frontend/src/test/views/HomeView.test.ts index 1c63572..3c019c4 100644 --- a/frontend/src/test/views/HomeView.test.ts +++ b/frontend/src/test/views/HomeView.test.ts @@ -472,13 +472,25 @@ describe('HomeView', () => { expect(wrapper.findComponent({ name: 'FrameCard' }).props('thumbnailUrl')).toBe('/api/devices/1/preview?v=42') }) - it('prefers lockedImageId over currentImageId for the thumbnail', async () => { + it('always uses currentImageId for the thumbnail — lockedImageId is ignored', async () => { + // Locked-but-not-yet-pulled is the bug we explicitly fixed: the home + // preview must reflect what the frame is actually showing, not what's + // queued for the next poll. const devicesStore = useDevicesStore() devicesStore.devices = [makeDevice({ id: 1, lockedImageId: 99, currentImageId: 42 })] vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() const wrapper = mountView() await flushPromises() - expect(wrapper.findComponent({ name: 'FrameCard' }).props('thumbnailUrl')).toBe('/api/devices/1/preview?v=99') + expect(wrapper.findComponent({ name: 'FrameCard' }).props('thumbnailUrl')).toBe('/api/devices/1/preview?v=42') + }) + + it('omits the thumbnail when the device has no currentImageId, even if a lock is queued', async () => { + const devicesStore = useDevicesStore() + devicesStore.devices = [makeDevice({ id: 1, lockedImageId: 99, currentImageId: null })] + vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() + const wrapper = mountView() + await flushPromises() + expect(wrapper.findComponent({ name: 'FrameCard' }).props('thumbnailUrl')).toBeUndefined() }) it('updates editWakeHour when the user picks a different hour chip', async () => { diff --git a/frontend/src/test/views/LibraryView.test.ts b/frontend/src/test/views/LibraryView.test.ts index cd2f850..8c01572 100644 --- a/frontend/src/test/views/LibraryView.test.ts +++ b/frontend/src/test/views/LibraryView.test.ts @@ -56,8 +56,9 @@ vi.mock('@/stores/toast', () => ({ useToastStore: () => ({ show: toastShow }), })) const uploadInitEdit = vi.fn() +const uploadInit = vi.fn() vi.mock('@/stores/upload', () => ({ - useUploadStore: () => ({ initEdit: uploadInitEdit }), + useUploadStore: () => ({ initEdit: uploadInitEdit, init: uploadInit }), })) const makeImage = (overrides: Partial = {}): Image => ({ @@ -100,6 +101,7 @@ describe('LibraryView', () => { mockRoute.query = {} toastShow.mockClear() uploadInitEdit.mockClear() + uploadInit.mockClear() routerPush.mockClear() // Default fetch stub — returns empty lists so onMounted doesn't error @@ -117,6 +119,56 @@ describe('LibraryView', () => { }) } + // LV-00: Add Photo button at the top + it('renders an Add Photo button at the top of the page', async () => { + const imagesStore = useImagesStore() + imagesStore.images = [] + imagesStore.loading = false + vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue() + vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue() + vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue() + + const wrapper = mountView() + await flushPromises() + + const addBtn = wrapper.find('.library__add-btn') + expect(addBtn.exists()).toBe(true) + expect(addBtn.text()).toContain('Add Photo') + }) + + it('clicking the Add Photo button primes the upload store and routes to /upload', async () => { + const imagesStore = useImagesStore() + imagesStore.images = [] + imagesStore.loading = false + vi.spyOn(imagesStore, 'fetchImages').mockResolvedValue() + vi.spyOn(imagesStore, 'fetchPendingCount').mockResolvedValue() + vi.spyOn(useDevicesStore(), 'fetchDevices').mockResolvedValue() + + // Spy on createElement so we can intercept the synthetic file input + const realCreate = document.createElement.bind(document) + let capturedInput: HTMLInputElement | null = null + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + const el = realCreate(tag) + if (tag === 'input') { + capturedInput = el as HTMLInputElement + ;(el as HTMLInputElement).click = vi.fn() + } + return el + }) + + const wrapper = mountView() + await flushPromises() + + await wrapper.find('.library__add-btn').trigger('click') + + const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' }) + Object.defineProperty(capturedInput, 'files', { value: [file], configurable: true }) + capturedInput!.onchange?.(new Event('change')) + + expect(uploadInit).toHaveBeenCalledWith(file) + expect(routerPush).toHaveBeenCalledWith('/upload') + }) + // LV-01: Default tab shows "All" tab active it('renders the All tab as active by default', async () => { const imagesStore = useImagesStore() diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index cad98f4..7b52211 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -177,9 +177,13 @@ function nextSyncLabel(device: Device): string | null { return `next sync in ${Math.round(fromNow / 3_600_000)}h` } +// Home shows what's actually on the frame right now — the last image the +// device pulled. Lock/queue state is intentionally ignored; the preview +// won't change until the frame next polls and switches to the locked image. function previewUrl(device: Device): string | undefined { - const imageId = device.lockedImageId ?? device.currentImageId - return imageId ? `/api/devices/${device.id}/preview?v=${imageId}` : undefined + return device.currentImageId + ? `/api/devices/${device.id}/preview?v=${device.currentImageId}` + : undefined } import FrameCard from '@/components/FrameCard.vue' import BaseBottomSheet from '@/components/BaseBottomSheet.vue' diff --git a/frontend/src/views/LibraryView.vue b/frontend/src/views/LibraryView.vue index fcee538..3b711d6 100644 --- a/frontend/src/views/LibraryView.vue +++ b/frontend/src/views/LibraryView.vue @@ -1,6 +1,12 @@