import { describe, expect, it } from 'vitest' import type { ContentType } from '@/composables/useContentTypes' import type { MediaItem } from '@/composables/useMedia' import type { ContentEntry } from '@/lib/contentTypes' import { cardFields, describeCell, plainCellText, thumbnailColumn } from '@/lib/entryCells' import { ENTRY_EDITORS } from '@/lib/entryEditors' import { buildData, entryTitle, initialValues, validateEntry } from '@/lib/entryForm' import { ruleMessage } from '@/lib/entryMessages' import { groupFields } from '@/lib/entryLayout' import { ApiError } from '@/lib/apiError' import { classifyRefusal, parseEntryRefusal } from '@/lib/entryServerError' import { FIELD_TYPES, fieldTypeSpec } from '@/lib/fieldTypes' import { imageButtonName, imageMeta, imageOfItem, isMediaId, isMissingImage, mediaOf, } from '@/lib/mediaField' import { diffFields, summaryLine } from '@/lib/schemaBuilderDiff' import { baselinePayload, fieldFromApi, formFromType } from '@/lib/schemaBuilderLoad' import { changeFieldType, createField } from '@/lib/schemaBuilderModel' import { buildPayload, payloadField } from '@/lib/schemaBuilderPayload' import { sampleEntry } from '@/lib/schemaBuilderSample' import { apiField, eventsFields } from './schemaFixtures' const LIME = { id: 7, filename: 'lime-plaster-workshop.jpg', width: 1600, height: 900, size: 245760, thumbnail_url: '/media/lime-plaster-workshop.jpg', } const VECTOR = { id: 4, filename: 'logo-renew.svg', width: null, height: null, size: 8192, thumbnail_url: '/media/logo-renew.svg', } const MISSING = { id: 7, missing: true as const } function mediaField(rest: object = {}) { return apiField('Thumbnail', 'thumbnail', 'media', { options: [], ...rest }) } const media = ENTRY_EDITORS.media describe('media in the registry', () => { it('is the seventh type, after select and before relation', () => { expect(FIELD_TYPES).toHaveLength(8) expect(FIELD_TYPES[6]).toBe('media') expect(FIELD_TYPES[7]).toBe('relation') expect(FIELD_TYPES[5]).toBe('select') }) it('describes itself', () => { const spec = fieldTypeSpec('media') expect(spec.label()).toBe('Media') expect(spec.description()).toBe('One image from your media library') expect(spec.icon).toBe('i-lucide-image') expect(spec.editor.control).toBe('media') expect(spec.cell).toBe('thumbnail') expect(spec.narrowColumn).toBe(true) expect(spec.alignEnd).toBe(false) expect(spec.pairs).toBe(false) }) it('supports no unique, limits, default or choices', () => { const spec = fieldTypeSpec('media') expect(spec.supportsUnique).toBe(false) expect(spec.supportsLimits).toBe(false) expect(spec.supportsDefault).toBe(false) expect(spec.supportsChoices).toBe(false) expect(spec.showsUnsupportedOptions).toBe(false) expect(spec.builderHint?.()).toBe( 'Holds one image from the media library. Entries link to the image instead of keeping a copy, so they always show its current version.', ) }) it.each(FIELD_TYPES.filter((type) => type !== 'media' && type !== 'relation'))( '%s keeps its builder options and has no hint', (type) => { const spec = fieldTypeSpec(type) expect(spec.supportsDefault).toBe(true) expect(spec.showsUnsupportedOptions).toBe(true) expect(spec.builderHint).toBeNull() expect(spec.supportsChoices).toBe(type === 'select') }, ) it('is not the text fallback for a loaded field', () => { expect(fieldTypeSpec('media')).not.toBe(fieldTypeSpec('text')) const field = fieldFromApi(mediaField(), 1) expect(field.type).toBe('media') }) it('starts empty on create and keeps the stored id on edit', () => { const field = mediaField() expect(media.initial(field, undefined, true)).toBeNull() expect(media.initial(mediaField({ default: 7 }), 3, true)).toBeNull() expect(media.initial(field, 7, false)).toBe(7) expect(media.initial(field, null, false)).toBeNull() expect(media.initial(field, undefined, false)).toBeNull() expect(media.initial(field, 'x', false)).toBeNull() }) it('has one help line and no counter', () => { expect(media.help(mediaField())).toBe('One image from your library.') expect(media.counter(mediaField(), 7)).toBeNull() }) }) describe('client validation of a media value', () => { it.each([ ['required', null, 'required'], ['required', undefined, 'required'], ['optional', null, null], ['optional', 7, null], ['required', 7, null], ['optional', 0, 'type'], ['optional', -1, 'type'], ['optional', 7.5, 'type'], ['optional', '7', 'type'], ['optional', true, 'type'], ['optional', 4294967296, 'type'], ['optional', 4294967295, null], ] as const)('%s holding %j gives %s', (required, value, rule) => { const field = mediaField({ required: required === 'required' }) expect(media.validate(field, value)).toBe(rule) }) it('reads a required field whose image is gone as required', () => { const field = mediaField({ required: true }) expect(media.validate(field, 7, { image: MISSING })).toBe('required') expect(media.validate(field, 7, { image: LIME })).toBeNull() }) it('lets an optional field keep a missing image', () => { expect(media.validate(mediaField(), 7, { image: MISSING })).toBeNull() }) it('hands the image of each field to the validation of the form', () => { const field = mediaField({ required: true }) expect(validateEntry([field], { thumbnail: 7 }, { thumbnail: MISSING })).toMatchObject({ thumbnail: { rule: 'required', message: 'Pick an image for Thumbnail.' }, }) expect(validateEntry([field], { thumbnail: 7 }, { thumbnail: LIME })).toEqual({}) expect(validateEntry([field], { thumbnail: null })).toMatchObject({ thumbnail: { rule: 'required' }, }) }) }) describe('messages of a media field', () => { const field = mediaField() it.each([ ['required', 'Pick an image for Thumbnail.'], ['type', 'Thumbnail must be an image from the library.'], ] as const)('the rule %s reads "%s"', (rule, message) => { expect(ruleMessage(rule, field)).toBe(message) }) it.each([ ['invalid_entry: thumbnail: required', 'required'], ['invalid_entry: thumbnail: type', 'type'], ])('maps the server answer %s to the field', (body, rule) => { const refusal = parseEntryRefusal(new ApiError(422, undefined, body)) expect(classifyRefusal([field], refusal)).toMatchObject({ kind: 'field', field, rule }) }) it('maps an unknown media key to a schema change', () => { const refusal = parseEntryRefusal( new ApiError(422, undefined, 'invalid_entry: thumbnail: unknown_field'), ) expect(classifyRefusal([field], refusal)).toEqual({ kind: 'schema', key: 'thumbnail' }) }) }) describe('payload of a media value', () => { it.each([ [7, 7], [null, null], [undefined, null], ] as const)('sends %j as %j', (value, sent) => { expect(media.payload(mediaField(), value)).toBe(sent) }) it('keeps the stored id of a missing image and never drops the key', () => { const fields = [apiField('Title', 'title', 'text'), mediaField()] const entry = { id: 1, content_type: 'events', data: { title: 'x', thumbnail: 7 }, media: { thumbnail: MISSING }, } as unknown as ContentEntry const values = initialValues(fields, entry) expect(buildData(fields, values)).toEqual({ title: 'x', thumbnail: 7 }) expect(buildData(fields, initialValues(fields, null))).toEqual({ title: null, thumbnail: null, }) }) it('sends the picked id as a JSON number', () => { const sent = JSON.stringify(buildData([mediaField()], { thumbnail: 8 })) expect(sent).toBe('{"thumbnail":8}') }) }) describe('media ids', () => { it.each([ [1, true], [4294967295, true], [0, false], [-1, false], [7.5, false], ['7', false], [null, false], [4294967296, false], ])('%j is an id: %s', (value, expected) => { expect(isMediaId(value)).toBe(expected) }) }) describe('media summaries', () => { it('reads the map of an entry by field key', () => { const entry = { media: { thumbnail: LIME, cover: MISSING } } as unknown as ContentEntry expect(mediaOf(entry, 'thumbnail')).toEqual(LIME) expect(isMissingImage(mediaOf(entry, 'cover')!)).toBe(true) expect(mediaOf(entry, 'absent')).toBeNull() expect(mediaOf({} as ContentEntry, 'thumbnail')).toBeNull() expect(mediaOf(null, 'thumbnail')).toBeNull() }) it('describes an image', () => { expect(imageMeta(LIME)).toBe('1600 × 900 · 240 KB') expect(imageMeta(VECTOR)).toBe('Vector · 8 KB') expect(imageButtonName(LIME)).toBe( 'Change image, lime-plaster-workshop.jpg, 1600 by 900, 240 KB', ) expect(imageButtonName(VECTOR)).toBe('Change image, logo-renew.svg, Vector, 8 KB') }) it('builds a summary from a library item without a request', () => { const item = { id: 8, filename: 'bathroom-tiles.png', url: '/media/bathroom-tiles.png', size: 51200, width: 800, height: 600, } as MediaItem expect(imageOfItem(item)).toEqual({ id: 8, filename: 'bathroom-tiles.png', width: 800, height: 600, size: 51200, thumbnail_url: '/media/bathroom-tiles.png', }) expect(imageMeta(imageOfItem(item))).toBe('800 × 600 · 50 KB') }) }) describe('media in the list', () => { const field = mediaField() it('describes a cell from the item own media object', () => { expect(describeCell(field, 7, LIME)).toEqual({ kind: 'thumbnail', text: LIME.filename, image: LIME, }) expect(describeCell(field, 7, MISSING)).toEqual({ kind: 'thumbnail', text: '', image: MISSING, }) expect(describeCell(field, 7)).toEqual({ kind: 'thumbnail', text: '', image: MISSING }) expect(describeCell(field, null).kind).toBe('empty') expect(describeCell(field, undefined).kind).toBe('empty') }) it('reads a plain text of dash for none and never the id', () => { expect(plainCellText(describeCell(field, null))).toBe('–') expect(plainCellText(describeCell(field, 7, MISSING))).toBe('–') expect(plainCellText(describeCell(field, 7, MISSING))).not.toContain('7') expect(plainCellText(describeCell(field, 7, LIME))).toBe(LIME.filename) }) it('picks a leading thumbnail and keeps the card text fields', () => { const title = apiField('Title', 'title', 'text') const starts = apiField('Starts on', 'starts_on', 'date') const category = apiField('Category', 'category', 'select') expect(cardFields([title, field, starts, category])).toEqual({ first: title, second: starts, choice: category, }) expect(cardFields([field, starts])).toMatchObject({ first: field, second: starts }) expect(thumbnailColumn([title, field, starts])).toBe(field) expect(thumbnailColumn([title, starts])).toBeUndefined() }) }) describe('titles and layout', () => { const entry = { id: 42, content_type: 'events', data: { thumbnail: 7 }, media: { thumbnail: LIME }, } as unknown as ContentEntry it('never uses a media value as the title of an entry', () => { expect(entryTitle([mediaField()], entry)).toBe('Entry 42') expect(entryTitle([mediaField(), apiField('Title', 'title', 'text')], entry)).toBe( 'Entry 42', ) }) it('never pairs a media field with a neighbour', () => { const flag = apiField('Online', 'online', 'boolean') const number = apiField('Capacity', 'capacity', 'number') const date = apiField('Starts on', 'starts_on', 'date') for (const before of [flag, number, date]) { const rows = groupFields([before, mediaField(), apiField('Ends on', 'ends_on', 'date')]) expect(rows.map((row) => row.length)).toEqual([1, 1, 1]) } }) }) describe('media in the schema builder', () => { const events: ContentType = { id: 1, key: 'events', name: 'Events', icon: null, fields: [...eventsFields, mediaField()], created_at: '', updated_at: '', } it('sends the exact body of a media field', () => { const field = createField(1, { label: 'Thumbnail', key: 'thumbnail', type: 'media' }) expect(payloadField(field)).toEqual({ key: 'thumbnail', label: 'Thumbnail', type: 'media', required: false, unique: false, default: null, min: null, max: null, options: [], relation: null, }) expect(payloadField({ ...field, required: true }).required).toBe(true) }) it('ignores stale settings of another type when sending', () => { const stale = createField(1, { label: 'Thumbnail', key: 'thumbnail', type: 'media', unique: true, defaultValue: 'x', min: '3', max: '9', options: ['a'], }) expect(payloadField(stale)).toMatchObject({ unique: false, default: null, min: null, max: null, options: [], }) }) it('round-trips a loaded media field with no change', () => { let next = 1 const form = formFromType(events, () => next++) const baseline = baselinePayload(events) expect(form.fields[7]!.type).toBe('media') expect(buildPayload(form).fields).toEqual(baseline) expect(diffFields(baseline, form.fields, 37)).toEqual([]) }) it.each(['text', 'number', 'select', 'richtext'] as const)( 'resets unique, default, limits and options changing a new %s row to media and back', (from) => { const row = createField(1, { label: 'Thumbnail', key: 'thumbnail', required: true, type: from, unique: true, defaultValue: 'x', min: '1', max: '80', options: ['a', 'b'], }) const toMedia = changeFieldType(row, 'media') expect(toMedia).toMatchObject({ label: 'Thumbnail', key: 'thumbnail', required: true, type: 'media', unique: false, defaultValue: '', min: '', max: '', options: [], }) expect(changeFieldType(toMedia, 'number')).toMatchObject({ type: 'number', min: '', max: '', defaultValue: '', required: true, }) }, ) it('previews a media value as null', () => { const field = createField(1, { label: 'Thumbnail', key: 'thumbnail', type: 'media' }) expect(sampleEntry('events', [field]).data.thumbnail).toBeNull() }) }) describe('review of a media field', () => { const events: ContentType = { id: 1, key: 'events', name: 'Events', icon: null, fields: eventsFields, created_at: '', updated_at: '', } const baseline = baselinePayload(events) function loaded() { let next = 1 return formFromType(events, () => next++).fields } function withThumbnail(rest: object = {}) { return [ ...loaded(), createField(99, { label: 'Thumbnail', key: 'thumbnail', type: 'media', ...rest }), ] } it('flags a required media field added to a type with entries as impossible', () => { const [change] = diffFields(baseline, withThumbnail({ required: true }), 12) expect(change).toMatchObject({ kind: 'add', key: 'thumbnail', flag: 'cannotApply' }) expect(change!.notes[0]!.text).toBe( "Media, required. A media field can't have a default, so the 12 existing entries would have no image. Make Thumbnail optional to add it.", ) }) it('words the single entry case apart', () => { const [change] = diffFields(baseline, withThumbnail({ required: true }), 1) expect(change!.notes[0]!.text).toBe( "Media, required. A media field can't have a default, so the existing entry would have no image. Make Thumbnail optional to add it.", ) }) it.each([ [12, 'Media, optional. 12 entries are unaffected.'], [1, 'Media, optional. 1 entry is unaffected.'], [0, 'Media, optional.'], ])('reads the clean row for %i entries', (total, text) => { const [change] = diffFields(baseline, withThumbnail(), total) expect(change!.flag).toBeNull() expect(change!.notes[0]!.text).toBe(text) }) it('keeps the add note of the other types unchanged by the count', () => { const added = [...loaded(), createField(99, { label: 'Speaker', key: 'speaker' })] expect(diffFields(baseline, added, 12)[0]!.notes[0]!.text).toBe('Text, optional.') expect(diffFields(baseline, added)[0]!.notes[0]!.text).toBe('Text, optional.') }) it('keeps the summary line', () => { expect(summaryLine(diffFields(baseline, withThumbnail({ required: true }), 12))).toBe( 'Add 1 field', ) }) it('flags making a saved optional media field required as one that may not fit', () => { const saved: ContentType = { ...events, fields: [...eventsFields, mediaField()] } const base = baselinePayload(saved) let next = 1 const fields = formFromType(saved, () => next++).fields.map((field) => field.key === 'thumbnail' ? { ...field, required: true } : field, ) const [change] = diffFields(base, fields, 12) expect(change).toMatchObject({ kind: 'change', flag: 'mayNotFit' }) expect(change!.notes[0]).toMatchObject({ text: 'Now required.', hint: 'An entry may have no value.', }) }) })