/** * lib/page-spec-lifecycle — the first-order `lifecycle` block of a form * pagespec (phases anchored on the status enum, owning later-phase fields * and/or phase-scoped requiredness). * * Covers: parse (valid / invalid / absent), the guard-predicate synthesis * (`lifecycleVisibleWhen` single vs IN membership), the resolver seeding * (`field.phase` / `field.requiredInPhase`, explicit-value precedence, first * claim wins), the reported rejections (reserved `creation`, unknown field, * statusField claimed, requiredFields without statuses, double claim) and the * identity guarantee on absence — plus a drift lock: the scaffold-component * reader must import THIS module, never re-declare the shape. */ import { readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { RESERVED_PHASE_KEY, lifecycleVisibleWhen, parsePageLifecycle, resolveLifecycle, } from '../page-spec-lifecycle.js' const FIELDS = [ { name: 'Amount', type: 'decimal' }, { name: 'Status', type: 'enum' }, { name: 'PaymentDate', type: 'date' }, { name: 'PaymentMethod', type: 'string' }, { name: 'DueDate', type: 'date' }, ] const LIFECYCLE = { statusField: 'status', phases: [ { key: 'paiement', statuses: ['PAYEE'], capturedBy: 'marquerPayee', fields: ['paymentDate', 'paymentMethod'], requiredFields: ['paymentDate'], }, { key: 'soumission', statuses: ['SOUMISE', 'PAYEE'], requiredFields: ['dueDate'] }, ], } describe('parsePageLifecycle', () => { it('parses a valid block', () => { const { lifecycle, rejected } = parsePageLifecycle(LIFECYCLE) expect(rejected).toEqual([]) expect(lifecycle?.statusField).toBe('status') expect(lifecycle?.phases).toHaveLength(2) }) it('absent → undefined with no rejection (legacy pagespecs untouched)', () => { expect(parsePageLifecycle(undefined)).toEqual({ lifecycle: undefined, rejected: [] }) expect(parsePageLifecycle(null)).toEqual({ lifecycle: undefined, rejected: [] }) }) it('invalid → undefined + reported issues, never a throw', () => { const { lifecycle, rejected } = parsePageLifecycle({ phases: [] }) expect(lifecycle).toBeUndefined() expect(rejected.some(r => r.includes('statusField'))).toBe(true) }) it('a phase needs at least one of fields/requiredFields', () => { const { lifecycle } = parsePageLifecycle({ statusField: 'status', phases: [{ key: 'vide' }] }) expect(lifecycle).toBeUndefined() }) }) describe('lifecycleVisibleWhen', () => { it('one status → the legacy single comparison', () => { expect(lifecycleVisibleWhen('status', ['PAYEE'])).toBe("status === 'PAYEE'") }) it('several statuses → the IN membership form', () => { expect(lifecycleVisibleWhen('status', ['SOUMISE', 'PAYEE'])).toBe("status IN ('SOUMISE', 'PAYEE')") }) it('escapes single quotes in status values', () => { expect(lifecycleVisibleWhen('etat', ["L'ETAPE"])).toBe("etat === 'L\\'ETAPE'") }) }) describe('resolveLifecycle', () => { it('absent block → identity (fields untouched, empty effects)', () => { const r = resolveLifecycle(FIELDS, {}) expect(r.fields).toBe(FIELDS) expect(r.effects.size).toBe(0) expect(r.phases).toEqual([]) expect(r.rejected).toEqual([]) expect(r.statusFieldOnForm).toBe(false) }) it('seeds phase on owned fields, requiredInPhase on required ones', () => { const r = resolveLifecycle(FIELDS, { lifecycle: LIFECYCLE }) expect(r.rejected).toEqual([]) expect(r.statusField).toBe('status') expect(r.statusFieldOnForm).toBe(true) const byName = new Map(r.fields.map(f => [f.name, f as Record])) expect(byName.get('PaymentDate')).toMatchObject({ phase: 'paiement', requiredInPhase: true }) expect(byName.get('PaymentMethod')).toMatchObject({ phase: 'paiement' }) expect((byName.get('PaymentMethod') as { requiredInPhase?: boolean }).requiredInPhase).toBeUndefined() // requiredFields-only entry: required in phase, NOT owned (visible at create) expect(byName.get('DueDate')).toMatchObject({ requiredInPhase: true }) expect((byName.get('DueDate') as { phase?: string }).phase).toBeUndefined() expect(byName.get('Amount')).not.toHaveProperty('phase') expect(r.effects.get('dueDate')).toMatchObject({ phase: 'soumission', owned: false, requiredInPhase: true }) }) it('an explicit field.phase is never overwritten', () => { const fields = [...FIELDS.slice(0, 3).map(f => ({ ...f })), { name: 'PaymentMethod', type: 'string', phase: 'autre' }] const r = resolveLifecycle(fields, { lifecycle: LIFECYCLE }) expect(r.fields.find(f => f.name === 'PaymentMethod')).toMatchObject({ phase: 'autre' }) }) it(`rejects the reserved '${RESERVED_PHASE_KEY}' phase and skips it`, () => { const r = resolveLifecycle(FIELDS, { lifecycle: { statusField: 'status', phases: [{ key: RESERVED_PHASE_KEY, fields: ['amount'] }] }, }) expect(r.rejected.some(m => m.includes('reserved'))).toBe(true) expect(r.effects.size).toBe(0) expect(r.phases).toEqual([]) }) it('rejects an unknown field, the statusField itself, and a double claim (first wins)', () => { const r = resolveLifecycle(FIELDS, { lifecycle: { statusField: 'status', phases: [ { key: 'paiement', statuses: ['PAYEE'], fields: ['paymentDate', 'ghost', 'status'] }, { key: 'sortie', statuses: ['SORTIE'], fields: ['paymentDate'] }, ], }, }) expect(r.rejected.some(m => m.includes("unknown field 'ghost'"))).toBe(true) expect(r.rejected.some(m => m.includes('statusField'))).toBe(true) expect(r.rejected.some(m => m.includes("already claimed by phase 'paiement'"))).toBe(true) expect(r.effects.get('paymentDate')?.phase).toBe('paiement') }) it('rejects a requiredFields-only entry in a statuses-less phase (no expressible guard)', () => { const r = resolveLifecycle(FIELDS, { lifecycle: { statusField: 'status', phases: [{ key: 'flou', requiredFields: ['dueDate'] }] }, }) expect(r.rejected.some(m => m.includes('needs statuses'))).toBe(true) expect(r.effects.size).toBe(0) }) it('statusFieldOnForm false when the status field is not among the fields', () => { const r = resolveLifecycle(FIELDS.filter(f => f.name !== 'Status'), { lifecycle: LIFECYCLE }) expect(r.statusFieldOnForm).toBe(false) // Effects still seeded — the caller degrades the guards, not the exclusion. expect(r.effects.get('paymentDate')?.owned).toBe(true) }) }) describe('lifecycle drift lock — scaffold-component imports the lib', () => { const skillsRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..') const read = (rel: string) => readFileSync(join(skillsRoot, rel), 'utf8') it('the reader resolves through lib/page-spec-lifecycle, never a local re-declaration', () => { const context = read('development/frontend/component/cli/scaffold-component/render/context.ts') expect(context).toMatch(/from '.*lib\/page-spec-lifecycle\.js'/) for (const rel of [ 'development/frontend/component/cli/scaffold-component/render/context.ts', 'development/frontend/component/cli/scaffold-component/render/shared.ts', 'development/frontend/component/cli/scaffold-component/render/detail.ts', 'development/frontend/component/cli/scaffold-component/types.ts', ]) { expect(read(rel), `${rel} re-declares the lifecycle schema — import lib/page-spec-lifecycle instead`) .not.toMatch(/\binterface\s+PageLifecycle\b|\btype\s+PageLifecycle\s*=|PageLifecycleSchema\s*=\s*z\.object/) } }) })