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([])
})
})