import { describe, it, expect } from 'vitest' import { validate } from '../validate.js' function spec(fields: object[]): object { return { module: 'projets', appCode: 'projet', entity: 'Projet', section: 'list', views: ['list', 'detail', 'form'], fields, projectPath: '/web', } } // The FK gate — a FK-shaped field must carry fkTo (or an explicit opt-out). // Without it the generated pages ship a raw Guid (test-RH Projet.StatutId: // free-text Guid on the form, Guid column on the list, Guid
on // the detail). Silent degradation is now a validation ERROR. describe('scaffold-component / validate — FK gate', () => { it('rejects a FK-shaped field without fkTo (the StatutId post-mortem shape)', () => { const r = validate(spec([ { name: 'label', type: 'string', required: true }, { name: 'statutId', type: 'guid', required: true }, ])) expect(r.valid).toBe(false) expect(r.errors).toHaveLength(1) expect(r.errors[0]).toMatch(/fields\.statutId/) expect(r.errors[0]).toMatch(/derive-fk-specs/) expect(r.errors[0]).toMatch(/noLookup/) }) it('rejects a string-typed FK-shaped field too (orchestrators pass Guid FKs as string)', () => { const r = validate(spec([{ name: 'ClientId', type: 'string', required: true }])) expect(r.valid).toBe(false) }) it('accepts the same field once fkTo is derived', () => { const r = validate(spec([ { name: 'statutId', type: 'guid', required: true, fkTo: { entity: 'Statut', module: 'configuration', apiEndpoint: '/api/configuration/statuts/lookup' } }, ])) expect(r.valid).toBe(true) }) it('accepts fkTo.app (cross-app target) through the schema', () => { const r = validate(spec([ { name: 'clientId', type: 'guid', required: true, fkTo: { entity: 'Client', app: 'client', module: 'annuaire', navRoute: 'annuaire.list', apiEndpoint: '/api/annuaire/list/lookup' } }, ])) expect(r.valid).toBe(true) }) it('accepts an explicit noLookup opt-out for a genuine non-FK identifier', () => { const r = validate(spec([{ name: 'trackingId', type: 'string', required: false, noLookup: true }])) expect(r.valid).toBe(true) }) it('whitelists externalId / parentId / guidId without any flag', () => { const r = validate(spec([ { name: 'externalId', type: 'string', required: false }, { name: 'parentId', type: 'guid', required: false }, { name: 'guidId', type: 'string', required: false }, ])) expect(r.valid).toBe(true) }) it('ignores enum fields carrying options and non-reference types', () => { const r = validate(spec([ { name: 'priorityId', type: 'string', required: true, options: [{ value: 'p1', label: 'P1' }] }, { name: 'legacyId', type: 'int', required: false }, ])) expect(r.valid).toBe(true) }) it('reports EVERY offending field, not just the first', () => { const r = validate(spec([ { name: 'clientId', type: 'guid', required: true }, { name: 'statutId', type: 'guid', required: true }, ])) expect(r.valid).toBe(false) expect(r.errors).toHaveLength(2) }) }) // The coded-entity gate — when the entity carries a system-allocated Code // (ICodedEntity), a form field named `code` must be readonly or absent. The // gate is SIGNAL-driven (`codedEntity` flag): a referential whose code is // legitimately user-typed (no codePattern) must keep validating untouched. // The legacy kanban gate — the standalone kanban view and the invocation-level // kanbanConfig are retired: the board is a viewMode of the LIST, configured by // pagespec.kanban + viewModes. Refuse loudly with the migration path. describe('scaffold-component / validate — legacy kanban gate', () => { const base = [{ name: 'label', type: 'string', required: true }] it("refuses views: ['kanban'] with the migration message", () => { const r = validate({ ...spec(base), views: ['kanban'] }) expect(r.valid).toBe(false) expect(r.errors.join(' ')).toMatch(/viewMode of the LIST page/) expect(r.errors.join(' ')).toMatch(/derive-kanban-spec/) }) it('refuses a lingering kanbanConfig even on a list invocation', () => { const r = validate({ ...spec(base), views: ['list'], kanbanConfig: { groupBy: 'status', columns: [{ key: 'open', labelKey: 'kanban.columns.open' }] }, }) expect(r.valid).toBe(false) expect(r.errors.join(' ')).toMatch(/drop kanbanConfig/) }) it("warns (never blocks) on a legacy entityViews 'kanban' token", () => { const r = validate({ ...spec(base), views: ['list'], entityViews: ['list', 'kanban'] }) expect(r.valid).toBe(true) expect(r.warnings.join(' ')).toMatch(/entityViews.*'kanban' token ignored/) }) it('accepts the folded shape: pagespec.kanban + viewModes on a list view', () => { const r = validate({ ...spec([ { name: 'label', type: 'string', required: true }, { name: 'status', type: 'string', required: true }, ]), views: ['list'], pageSpec: { screenCode: 'SCR-X', module: 'projets', appCode: 'projet', section: 'list', entity: 'Projet', view: 'list', filePath: 'src/pages/x.tsx', permission: 'projets.list.read', viewModes: ['table', 'kanban'], kanban: { statusField: 'status', columns: [ { key: 'open', labelKey: 'kanban.columns.open' }, { key: 'closed', labelKey: 'kanban.columns.closed' }, ], }, i18nKeys: { fr: {}, en: {}, it: {}, de: {} }, specHash: 'a'.repeat(64), }, }) expect(r.valid).toBe(true) }) }) describe('scaffold-component / validate — coded-entity gate (Code is engine-allocated)', () => { const minPageSpec = (over: object = {}): object => ({ screenCode: 'SCR-PROJET-PROJETS-LIST-003', module: 'projets', appCode: 'projet', section: 'list', entity: 'Projet', view: 'form', filePath: 'pagespecs/Projet.form.md', permission: 'projet.projets.list.update', i18nKeys: { fr: {}, en: {}, it: {}, de: {} }, specHash: 'abc123', ...over, }) it('rejects an editable code field when codedEntity is set top-level', () => { const r = validate({ ...spec([{ name: 'code', type: 'string', required: true }]), codedEntity: true }) expect(r.valid).toBe(false) expect(r.errors).toHaveLength(1) expect(r.errors[0]).toMatch(/fields\.code/) expect(r.errors[0]).toMatch(/engine-allocated/) expect(r.errors[0]).toMatch(/readonly: true/) }) it('rejects it too when the signal arrives via pageSpec.codedEntity', () => { const r = validate({ ...spec([{ name: 'Code', type: 'string', required: true }]), pageSpec: minPageSpec({ codedEntity: true }), }) expect(r.valid).toBe(false) expect(r.errors[0]).toMatch(/fields\.Code/) }) it('accepts the code field once marked readonly (display the allocated code on edit)', () => { const r = validate({ ...spec([{ name: 'code', type: 'string', required: false, readonly: true }]), codedEntity: true }) expect(r.valid).toBe(true) }) it('accepts isComputed as the readonly alias', () => { const r = validate({ ...spec([{ name: 'code', type: 'string', required: false, isComputed: true }]), codedEntity: true }) expect(r.valid).toBe(true) }) it('without the codedEntity signal an editable code stays valid (user-typed referential codes)', () => { const r = validate(spec([{ name: 'code', type: 'string', required: true }])) expect(r.valid).toBe(true) }) it('ignores non-form views — a list/detail column on code is legitimate', () => { const base = spec([{ name: 'code', type: 'string', required: true }]) as { views: string[] } const r = validate({ ...base, views: ['list', 'detail'], codedEntity: true }) expect(r.valid).toBe(true) }) }) // The detail-tabs gate — tabs[].fields are camelCase pagespec keys matched // camel-insensitively against fields[] (entité.md PascalCase in the real // pipeline). An entry resolving to NO field used to ship as an empty tabpanel // (18/18 empty
grids, 77 declared fields, zero rendered) — now an error. describe('scaffold-component / validate — detail tabs fields gate', () => { const tabbedSpec = (tabs: object[]) => ({ module: 'conducteurs', appCode: 'flotte', entity: 'Driver', section: 'annuaire', views: ['detail'], projectPath: '/web', fields: [ { name: 'FirstName', type: 'string', required: true }, { name: 'MobilePhone', type: 'string', required: false }, ], 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', i18nKeys: { fr: {}, en: {}, it: {}, de: {} }, specHash: 'test-hash', tabs, }, }) it('accepts camelCase tab fields over PascalCase fields[] (the real pipeline shape)', () => { const r = validate(tabbedSpec([{ key: 'identite', fields: ['firstName', 'mobilePhone'] }])) expect(r.valid).toBe(true) expect(r.errors).toHaveLength(0) }) it('rejects a tab field that resolves to none of the entity fields', () => { const r = validate(tabbedSpec([{ key: 'identite', fields: ['firstName', 'ghostField'] }])) expect(r.valid).toBe(false) expect(r.errors.some((e) => /pageSpec\.tabs\.identite/.test(e) && /ghostField/.test(e))).toBe(true) }) it('a tab without fields[] (render-all) passes untouched', () => { const r = validate(tabbedSpec([{ key: 'info' }])) expect(r.valid).toBe(true) }) }) // The composite-creation SIGNAL (§ the amputated Create DTOs): a form pagespec // field belonging to a CHILD entity was silently dropped by model binding — // the vehicle's plate, the licence's categories. v1 refuses loudly and names // the child; composite creation is the named follow-up `creation-composite`. describe('scaffold-component / validate — composite-creation signal', () => { const formSpec = (pageSpecFields: object[], moduleEntities?: object[]) => ({ module: 'parc', appCode: 'flotte', entity: 'Vehicle', section: 'vehicules', views: ['form'], projectPath: '/web', fields: [ { name: 'Vin', type: 'string', required: true }, { name: 'VehicleTypeId', type: 'guid', required: true, fkTo: { entity: 'VehicleType', module: 'parametrage', apiEndpoint: '/api/parametrage/vehicle-types/lookup' } }, ], ...(moduleEntities ? { moduleEntities } : {}), pageSpec: { screenCode: 'SCR-VEHICLE-FORM', module: 'parc', appCode: 'flotte', section: 'vehicules', entity: 'Vehicle', view: 'form', filePath: 'src/pages/flotte/parc/vehicules/VehicleFormPage.tsx', permission: 'parc.vehicules.create', i18nKeys: { fr: {}, en: {}, it: {}, de: {} }, specHash: 'test-hash', fields: pageSpecFields, }, }) it('a form field resolving on the entity passes', () => { const r = validate(formSpec([{ key: 'vin' }, { key: 'vehicleTypeId' }])) expect(r.valid).toBe(true) }) it('a child-entity field is refused WITH the child named (the plate on the vehicle form)', () => { const r = validate(formSpec( [{ key: 'vin' }, { key: 'plateNumber' }], [{ name: 'VehicleRegistration', fields: ['PlateNumber', 'StartDate'], fks: [{ field: 'VehicleId', target: 'Vehicle' }] }], )) expect(r.valid).toBe(false) expect(r.errors.some(e => /plateNumber/.test(e) && /VehicleRegistration/.test(e) && /VehicleId/.test(e))).toBe(true) expect(r.errors.some(e => /creation-composite/.test(e))).toBe(true) }) it('an unresolvable field without a child match still errs (generic message, PRD-122)', () => { const r = validate(formSpec([{ key: 'ghost' }])) expect(r.valid).toBe(false) expect(r.errors.some(e => /ghost/.test(e) && /PRD-122/.test(e))).toBe(true) }) }) describe('scaffold-component / validate — coded-entity gate arms on the ENRICHED object too', () => { const objFlag = { label: { fr: 'Référence' } } it('rejects an editable code with the object flag top-level and via pageSpec', () => { const top = validate({ ...spec([{ name: 'code', type: 'string', required: true }]), codedEntity: objFlag }) expect(top.valid).toBe(false) expect(top.errors[0]).toMatch(/fields\.code/) const viaPageSpec = validate({ ...spec([{ name: 'code', type: 'string', required: true }]), pageSpec: { screenCode: 'SCR-PROJET-PROJETS-LIST-003', module: 'projets', appCode: 'projet', section: 'list', entity: 'Projet', view: 'form', filePath: 'pagespecs/Projet.form.md', permission: 'projet.projets.list.update', i18nKeys: { fr: {}, en: {}, it: {}, de: {} }, specHash: 'abc123', codedEntity: objFlag, }, }) expect(viaPageSpec.valid).toBe(false) }) it('codedEntity: false and absent flag stay non-coded (boolean semantics preserved)', () => { const r = validate({ ...spec([{ name: 'code', type: 'string', required: true }]), codedEntity: false }) expect(r.errors.filter((e) => /fields\.code/.test(e))).toEqual([]) }) })