- mlLocale: type predicates, localeRecord, the ML_LOCALE_CHANGE broadcast - MLField: locale switch, whole-map emit, broadcast follow, content-locale - FileCaptionModal: prefill, save via FileRoutes.update, 422 stays, danger toast - ApiTokensPanel and ApiTokenCreateDialog mounted directly next to the app tests
139 lines
4.9 KiB
TypeScript
139 lines
4.9 KiB
TypeScript
// 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<FileItem> {
|
|
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<T extends Element>(selector: string): T {
|
|
const found = document.body.querySelector<T>(selector)
|
|
if (!found) {
|
|
throw new Error(`missing ${selector}`)
|
|
}
|
|
return found
|
|
}
|
|
|
|
function type(selector: string, value: string): void {
|
|
const input = el<HTMLInputElement | HTMLTextAreaElement>(selector)
|
|
input.value = value
|
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
}
|
|
|
|
async function save(): Promise<void> {
|
|
el<HTMLButtonElement>('[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<HTMLInputElement>('[data-caption-title]').value).toBe('Front')
|
|
expect(el<HTMLTextAreaElement>('[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<HTMLInputElement>('[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<HTMLInputElement>('[data-caption-title]').value).toBe('Kept title')
|
|
})
|
|
})
|