/** * fiche-unified.test.ts — the « fiche unique, édition en place » contract. * * An entity whose view-set carries detail AND form (without the 'direct' * opt-out) gets ONE fiche: the DetailPage renders the own fields as * read-first section cards toggling to edit IN PLACE (permission-driven), * with the dirty-gated save bar, while the TabStrip carries only the 360 * related tabs. `/edit` arrives with every section opened; the header * « Modifier » opens them all. The FormPage's historical edit mode is * superseded by this surface (lib/edit-surface.ts is the SSOT trigger). */ import { describe, it, expect } from 'vitest' import { generate } from '../generate.js' import type { ScaffoldComponentInput, PageSpecMin } from '../types.js' const I18N: Record = { 'detail.title': 'Fiche' } function ficheSpec(overrides: Partial = {}, pageSpecExtra: Record = {}): ScaffoldComponentInput { return { module: 'conducteurs', appCode: 'flotte', entity: 'Driver', section: 'annuaire', views: ['detail'], entityViews: ['list', 'detail', 'form'], fields: [ { name: 'FirstName', type: 'string', required: true }, { name: 'LastName', type: 'string', required: true }, { name: 'DepartmentId', type: 'guid', required: false, fkTo: { entity: 'Department', module: 'core', apiEndpoint: '/api/core/departments/lookup' } }, ], projectPath: '/web', pageSpec: { screenCode: 'SCR-DRIVER-DETAIL', module: 'conducteurs', appCode: 'flotte', section: 'annuaire', entity: 'Driver', view: 'detail', filePath: 'src/pages/flotte/conducteurs/annuaire/DriverDetailPage.tsx', permission: 'conducteurs.annuaire.read', actions: [], tabs: [{ key: 'identite', fields: ['firstName', 'lastName', 'departmentId'] }], i18nKeys: { fr: I18N, en: I18N, it: I18N, de: I18N }, specHash: 'x'.repeat(16), ...pageSpecExtra, } as unknown as PageSpecMin, ...overrides, } } const pageOf = (input: ScaffoldComponentInput) => generate(input).find((f) => /DetailPage\.tsx$/.test(f.path))!.content describe('scaffold-component / unified fiche — édition en place', () => { it('renders the own fields as read-first SectionCards, no field tabpanels', () => { const c = pageOf(ficheSpec()) expect(c).toContain(' { const c = pageOf(ficheSpec()) expect(c).toMatch(/\{canUpdate && \(\s*\n\s*
{ const c = pageOf(ficheSpec()) expect(c).toContain('useUpdateDriver') expect(c).not.toContain('useCreateDriver') expect(c).not.toContain('toCreatePayload') expect(c).toMatch(/updateMutation\.mutateAsync\(\{ id, data: formData \}\)/) // Save stays on the page: baseline resyncs, sections close. expect(c).toMatch(/setBaseline\(formData\)\s*\n\s*setEditingSections\(new Set\(\)\)/) }) it('/edit arrival opens every section; « Modifier » opens them all too', () => { const c = pageOf(ficheSpec()) expect(c).toMatch(/location\.pathname\.endsWith\('\/edit'\) \? new Set\(driverFicheSectionKeys\)/) expect(c).toMatch(/const handleEditAll = \(\) => setEditingSections\(new Set\(driverFicheSectionKeys\)\)/) expect(c).toMatch(/data-testid="detail-action-edit"\s*\n\s*onClick=\{handleEditAll\}/) expect(c).not.toContain('routes.annuaire.edit(') }) it('FK lookups search by formData and are declared AFTER the form state (TDZ)', () => { const c = pageOf(ficheSpec()) expect(c).toMatch(/useDepartmentLookup\(\{ search: \(formData\.departmentId as string \| undefined\) \|\| undefined, pageSize: 1 \}\)/) expect(c.indexOf('const [formData, setFormData]')).toBeLessThan(c.indexOf('useDepartmentLookup({ search:')) // The read dd resolves the displayName, never the raw Guid. expect(c).toMatch(/departmentIdLookupData\?\.items \?\? \[\]\)\.find\(\(it\) => it\.id === formData\.departmentId\)\?\.displayName/) }) it('the TabStrip carries ONLY the 360 related tabs, first related is the default', () => { const c = pageOf(ficheSpec({}, { relatedTabs: [{ key: 'permis', displayMode: 'table', relatedEntity: 'DrivingLicence', relationFk: 'driverId', relatedModule: 'conducteurs', relatedSection: 'permis', permission: 'conducteurs.permis.read', labelKey: 'detail.related.permis.label', }], })) expect(c).toContain(' { const c = pageOf(ficheSpec()) expect(c).not.toContain(' { const c = pageOf(ficheSpec({}, { relatedTabs: [{ key: 'assurances', displayMode: 'summary', relatedEntity: 'Insurance', relationFk: 'driverId', relatedModule: 'conducteurs', relatedSection: 'assurances', permission: 'conducteurs.assurances.read', }], })) // No strip, no read-body duplicate under the form — the form is the body. expect(c).not.toContain(' { const c = pageOf(ficheSpec({}, { editExperience: 'direct' })) expect(c).not.toContain(' { const c = pageOf(ficheSpec({ entityViews: ['list', 'detail'] })) expect(c).not.toContain('form-submit') expect(c).toContain('id="tabpanel-identite"') expect(c).not.toContain('useUpdateDriver') }) it('versioned entity echoes rowVersion in the update payload', () => { const c = pageOf(ficheSpec({ versioned: true } as Partial)) expect(c).toMatch(/updateMutation\.mutateAsync\(\{ id, data: \{ \.\.\.formData, rowVersion \} \}\)/) expect(c).toMatch(/setRowVersion\(\(data as \{ rowVersion\?: string \}\)\.rowVersion\)/) }) it('lifecycle: an all-phased section renders under its status guard with the phase marker', () => { const c = pageOf(ficheSpec({ fields: [ { name: 'Status', type: 'string', required: true, options: [ { value: 'Draft', label: 'Brouillon' }, { value: 'Paid', label: 'Payée' }, ] }, { name: 'Label', type: 'string', required: true }, { name: 'PaymentDate', type: 'date', required: false }, ], }, { tabs: undefined, lifecycle: { statusField: 'status', phases: [{ key: 'paiement', statuses: ['Paid'], fields: ['paymentDate'] }], }, sections: [ { key: 'general', fields: ['status', 'label'] }, { key: 'paiement', fields: ['paymentDate'] }, ], })) expect(c).toContain('{/* phase:paiement */}') // The all-phased card gates on the STATUS predicate (there is no create // branch on the fiche, so isEdit cannot be the gate). expect(c).toMatch(/\{\(formData\.status === 'Paid'\) && \(/) }) it('offline write: no savedAt stamp (the outbox chip is the truth)', () => { const c = pageOf(ficheSpec({ pwa: { support: 'full', offline: 'write' } } as Partial)) expect(c).not.toContain('savedAt') expect(c).toMatch(/isDirty && \(\s*\n\s* { const c = pageOf(ficheSpec({ pwa: { support: 'full', offline: 'read' } } as Partial)) expect(c).toMatch(/disabled=\{isPending \|\| !isDirty \|\| !isOnline\}/) }) it('the summary band and delete/custom actions keep their detail contract', () => { const c = pageOf(ficheSpec({}, { summary: { titleField: 'lastName' }, actions: [{ code: 'duplicate', scope: 'header', labelKey: 'detail.actions.duplicate', permission: 'conducteurs.annuaire.create', variant: 'secondary' }], })) expect(c).toContain('data-testid="detail-summary"') expect(c).toContain('data-testid="detail-action-delete"') expect(c).toContain('detail-action-duplicate') }) })