// FileCaptionModal (Phase 12.2, D-09; UI-SPEC section 3, caption modal) // mounted on its own over a stub FileRoutes, so no HTTP is involved. Save // sends title and description through routes.update; a 422 stays in the // dialog under its inputs; any other failure keeps the input and posts a // danger toast. The language bundle has no fileupload strings, so the test // asserts on data attributes and events rather than texts. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import type { FileCallResult, FileRoutes } from '../../src/api/files' import type { FileItem } from '../../src/api/types' import FileCaptionModal from '../../src/components/form/fields/FileCaptionModal.vue' import { toasts } from '../../src/state/useToasts' import { resetState } from '../helpers' const FILE: FileItem = { id: 41, file_name: 'front.png', file_size: 120400, content_type: 'image/png', title: 'Front', description: 'The front side', sort_order: 41, pending: false, created_at: '2026-10-02T10:00:00Z', } function routesStub(update: FileRoutes['update']): FileRoutes { return { list: vi.fn(), upload: vi.fn(), update: vi.fn(update), remove: vi.fn(), reorder: vi.fn(), download: vi.fn(), thumb: vi.fn(), } } function ok(item: FileItem): FileCallResult { return { data: item, status: 200, error: null } } async function mountModal(routes: FileRoutes) { const wrapper = mount(FileCaptionModal, { props: { open: true, file: FILE, routes, preview: null, idPrefix: 'gadget' }, attachTo: document.body, }) await flushPromises() return wrapper } function el(selector: string): T { const found = document.body.querySelector(selector) if (!found) { throw new Error(`missing ${selector}`) } return found } function type(selector: string, value: string): void { const input = el(selector) input.value = value input.dispatchEvent(new Event('input', { bubbles: true })) } async function save(): Promise { el('[data-action="save-caption"]').click() await flushPromises() } beforeEach(() => { resetState() }) afterEach(() => { document.body.innerHTML = '' }) enableAutoUnmount(afterEach) describe('FileCaptionModal mounted directly', () => { it('prefills title and description from the file', async () => { await mountModal(routesStub(async () => ok(FILE))) expect(document.body.querySelector('[data-caption-modal]')).not.toBeNull() expect(el('[data-caption-title]').value).toBe('Front') expect(el('[data-caption-description]').value).toBe('The front side') expect(document.body.textContent).toContain(FILE.file_name) }) it('saves the edited caption through routes.update and closes', async () => { const saved: FileItem = { ...FILE, title: 'Front cover', description: 'Shown first' } const routes = routesStub(async () => ok(saved)) const wrapper = await mountModal(routes) type('[data-caption-title]', 'Front cover') type('[data-caption-description]', 'Shown first') await save() expect(routes.update).toHaveBeenCalledTimes(1) expect(routes.update).toHaveBeenCalledWith(FILE.id, { title: 'Front cover', description: 'Shown first' }) expect(wrapper.emitted('saved')).toEqual([[saved]]) expect(wrapper.emitted('update:open')).toEqual([[false]]) expect(toasts.value[0]?.tone).toBe('success') }) it('keeps the dialog open on a 422 and shows the field message', async () => { const routes = routesStub(async () => ({ data: null, status: 422, error: { code: 'validation_failed', message: 'Invalid', details: { title: ['Title is too long.'] } }, })) const wrapper = await mountModal(routes) type('[data-caption-title]', 'x'.repeat(300)) await save() expect(wrapper.emitted('saved')).toBeUndefined() expect(wrapper.emitted('update:open')).toBeUndefined() expect(document.body.querySelector('[data-caption-modal]')).not.toBeNull() expect(el('#gadget-caption-title-error').textContent).toContain('Title is too long.') expect(el('[data-caption-title]').getAttribute('aria-invalid')).toBe('true') expect(toasts.value).toHaveLength(0) }) it('posts a danger toast on any other failure and keeps the typed input', async () => { const routes = routesStub(async () => ({ data: null, status: 500, error: { code: 'internal', message: 'Server exploded', details: {} }, })) const wrapper = await mountModal(routes) type('[data-caption-title]', 'Kept title') await save() expect(wrapper.emitted('saved')).toBeUndefined() expect(wrapper.emitted('update:open')).toBeUndefined() expect(toasts.value[0]).toMatchObject({ tone: 'danger', text: 'Server exploded' }) expect(el('[data-caption-title]').value).toBe('Kept title') }) })