/** * derive-kanban-spec — the SmartKanban → list-pagespec folding + PRD-135 * check engine. * * Covers: the derive fold (columns re-anchored VERBATIM on the enum, Flow → * transitions with rule codes, transitionErrorCode from the unique rule error * code, viewModes ∪ kanban, i18n seeding + needsTranslation, the auto-derived * move action with its UC/permission anchors), the fail-closed rules (status * field not an enum, columns unmapped, Flow tokens that map to NOTHING), * idempotence (`already`), the legacy standalone outcome, the check-mode legs * a-h + schema, and the fs wrapper over a real BA tree (check never writes). */ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import type { ParsedScreen } from '../../../../../lib/ba-screens.js' import type { BaRule } from '../../../../../lib/ba-rules-rows.js' import { checkKanbanOfPagespec, deriveKanbanForPagespec, execute, mapFlowOntoEnum, workflowRulesOf, type KanbanDeriveContext, } from '../execute.js' const ENUMS = [{ name: 'Status', values: ['draft', 'submitted', 'approved', 'rejected'] }] function rule(partial: Partial): BaRule { return { code: 'BR-012', title: 'Cycle de vie', linkedUcs: [], flow: [], fields: {}, docPath: 'règles-métier.md', ...partial, } } const WORKFLOW_RULE = rule({ type: 'workflow', errorCode: 'hr.leave.invalid-transition', flow: [ { from: 'draft', to: 'submitted' }, { from: 'submitted', to: 'approved', by: 'BA-001-AC-002' }, { from: 'submitted', to: 'rejected' }, ], }) function kanbanScreen(partial?: Partial): ParsedScreen { return { code: 'SCR-HR-LEAVE-LIST-002', title: 'Tableau des demandes', screenType: 'SmartKanban', file: 'HR/LEAVE/leave-list/screen.md', module: 'LEAVE', section: 'leave-list', entity: 'LeaveRequest', declaredTabs: [], fieldTabLabels: [], kanban: { statusField: 'status', columns: [ { key: 'draft', label: 'Brouillon', color: 'gray' }, { key: 'submitted', label: 'Soumis', color: 'blue' }, { key: 'approved', label: 'Approuvé', color: 'green' }, { key: 'rejected', label: 'Refusé', color: 'red' }, ], titleField: 'code', subtitleField: 'employeeName', cardFields: ['startDate', 'endDate'], rowClickTarget: 'SCR-HR-LEAVE-DETAIL-001', }, ...partial, } } function ctx(partial?: Partial): KanbanDeriveContext { return { statusEnums: ENUMS, kanbanScreen: kanbanScreen(), hasListScreen: true, workflowRules: [WORKFLOW_RULE], ...partial, } } function pagespecMd(block: Record): string { return `# LeaveRequest — list\n\n\`\`\`json\n${JSON.stringify(block, null, 2)}\n\`\`\`\n\nProse below.\n` } function blockOf(md: string): Record { return JSON.parse(/```json\s*\n([\s\S]*?)\n```/.exec(md)![1]!) as Record } const BASE_BLOCK = { screenCode: 'SCR-HR-LEAVE-LIST-001', entity: 'LeaveRequest', section: 'leave-list', view: 'list', permission: 'hr.leave.read', linkedUseCases: ['UC-HR-LEAVE-LIST-001'], columns: [ { key: 'code' }, { key: 'employeeName' }, { key: 'status' }, { key: 'startDate' }, { key: 'endDate' }, ], actions: [], i18nKeys: { fr: {}, en: {}, it: {}, de: {} }, } describe('mapFlowOntoEnum', () => { it('maps fold-insensitively, emits VERBATIM enum tokens, annotates the rule', () => { const r = rule({ type: 'workflow', flow: [{ from: 'DRAFT', to: 'Submitted' }] }) const { transitions, dropped } = mapFlowOntoEnum([r], ENUMS[0]!.values) expect(dropped).toEqual([]) expect(transitions).toEqual([{ from: 'draft', to: 'submitted', rule: 'BR-012' }]) }) it('drops (and reports) an edge with a token outside the enum', () => { const r = rule({ type: 'workflow', flow: [{ from: 'brouillon', to: 'submitted' }] }) const { transitions, dropped } = mapFlowOntoEnum([r], ENUMS[0]!.values) expect(transitions).toEqual([]) expect(dropped).toEqual(['BR-012: brouillon → submitted']) }) it('dedupes edges across rules (first rule wins the annotation)', () => { const r2 = rule({ code: 'BR-020', type: 'state-transition', flow: [{ from: 'draft', to: 'submitted' }] }) const { transitions } = mapFlowOntoEnum([WORKFLOW_RULE, r2], ENUMS[0]!.values) expect(transitions.filter((t) => t.from === 'draft')).toHaveLength(1) expect(transitions[0]!.rule).toBe('BR-012') }) it('collects the distinct error codes of contributing rules only', () => { const noContrib = rule({ code: 'BR-030', type: 'workflow', errorCode: 'other.code', flow: [{ from: 'x', to: 'y' }] }) const { errorCodes } = mapFlowOntoEnum([WORKFLOW_RULE, noContrib], ENUMS[0]!.values) expect(errorCodes).toEqual(['hr.leave.invalid-transition']) }) }) describe('workflowRulesOf', () => { it('keeps workflow/state-transition rules WITH flow edges, case-insensitive', () => { const rules = [ WORKFLOW_RULE, rule({ code: 'BR-001', type: 'validation', flow: [{ from: 'a', to: 'b' }] }), rule({ code: 'BR-002', type: 'Workflow', flow: [] }), rule({ code: 'BR-003', type: 'STATE-TRANSITION', flow: [{ from: 'a', to: 'b' }] }), ] expect(workflowRulesOf(rules).map((r) => r.code)).toEqual(['BR-012', 'BR-003']) }) }) describe('deriveKanbanForPagespec — the fold', () => { it('folds the SmartKanban into the list pagespec: columns, transitions, viewModes, i18n, move action', () => { const { md, outcome } = deriveKanbanForPagespec(pagespecMd(BASE_BLOCK), 'p.md', ctx()) expect(outcome.status).toBe('derived') const block = blockOf(md) const kanban = block.kanban as Record expect(kanban.statusField).toBe('status') expect(kanban.columns).toEqual([ { key: 'draft', labelKey: 'kanban.columns.draft', color: 'gray' }, { key: 'submitted', labelKey: 'kanban.columns.submitted', color: 'blue' }, { key: 'approved', labelKey: 'kanban.columns.approved', color: 'green' }, { key: 'rejected', labelKey: 'kanban.columns.rejected', color: 'red' }, ]) expect(kanban.titleField).toBe('code') expect(kanban.subtitleField).toBe('employeeName') expect(kanban.cardFields).toEqual(['startDate', 'endDate']) expect(kanban.transitions).toEqual([ { from: 'draft', to: 'submitted', rule: 'BR-012' }, { from: 'submitted', to: 'approved', rule: 'BR-012', by: 'BA-001-AC-002' }, { from: 'submitted', to: 'rejected', rule: 'BR-012' }, ]) expect(kanban.transitionErrorCode).toBe('hr.leave.invalid-transition') expect(block.viewModes).toEqual(['table', 'kanban']) expect(block.defaultViewMode).toBeUndefined() // The auto-derived move action. const actions = block.actions as Array> expect(actions).toHaveLength(1) expect(actions[0]).toMatchObject({ code: 'move', kind: 'api', scope: 'row', endpoint: 'move', permission: 'hr.leave.update', ucReference: 'UC-HR-LEAVE-LIST-001', guardRules: ['BR-012'], payloadParameters: [{ name: 'status', type: 'select', required: true, field: 'status' }], }) // The select param carries the column options — without them the table's // CustomActionDialog renders an EMPTY dropdown and the move is unusable. const params = actions[0]!.payloadParameters as Array<{ options?: unknown }> expect(params[0]!.options).toEqual([ { value: 'draft', labelKey: 'kanban.columns.draft' }, { value: 'submitted', labelKey: 'kanban.columns.submitted' }, { value: 'approved', labelKey: 'kanban.columns.approved' }, { value: 'rejected', labelKey: 'kanban.columns.rejected' }, ]) expect(outcome.derivedAction).toBe('move') // i18n: fr copy seeded in the 4 locales, en/it/de reported for translation. const i18n = block.i18nKeys as Record> expect(i18n.fr!['kanban.columns.draft']).toBe('Brouillon') expect(i18n.de!['kanban.columns.approved']).toBe('Approuvé') expect(i18n.en!['list.actions.move']).toBe('Move') expect(outcome.needsTranslation).toContain('kanban.columns.draft') }) it('is idempotent — an existing kanban key yields `already`, md untouched', () => { const first = deriveKanbanForPagespec(pagespecMd(BASE_BLOCK), 'p.md', ctx()) const second = deriveKanbanForPagespec(first.md, 'p.md', ctx()) expect(second.outcome.status).toBe('already') expect(second.md).toBe(first.md) }) it('columns are re-anchored VERBATIM on the enum (fold match, enum casing wins)', () => { const screen = kanbanScreen() screen.kanban!.columns = [ { key: 'DRAFT', label: 'Brouillon' }, { key: 'Submitted', label: 'Soumis' }, ] const { md } = deriveKanbanForPagespec(pagespecMd(BASE_BLOCK), 'p.md', ctx({ kanbanScreen: screen })) const kanban = blockOf(md).kanban as { columns: Array<{ key: string }> } expect(kanban.columns.map((c) => c.key)).toEqual(['draft', 'submitted']) }) it('a standalone view:kanban pagespec → legacy-standalone, untouched', () => { const { md, outcome } = deriveKanbanForPagespec( pagespecMd({ ...BASE_BLOCK, view: 'kanban' }), 'p.md', ctx(), ) expect(outcome.status).toBe('legacy-standalone') expect(blockOf(md).kanban).toBeUndefined() }) it('non-list views are skipped; sections without a SmartKanban are no-kanban-screen', () => { expect(deriveKanbanForPagespec(pagespecMd({ ...BASE_BLOCK, view: 'form' }), 'p.md', ctx()).outcome.status).toBe('skipped') expect(deriveKanbanForPagespec(pagespecMd(BASE_BLOCK), 'p.md', ctx({ kanbanScreen: undefined })).outcome.status).toBe('no-kanban-screen') }) it('« Champ statut » that names no enum attribute → needs-judgment (never invented)', () => { const screen = kanbanScreen() screen.kanban!.statusField = 'phase' const { outcome } = deriveKanbanForPagespec(pagespecMd(BASE_BLOCK), 'p.md', ctx({ kanbanScreen: screen })) expect(outcome.status).toBe('needs-judgment') expect(outcome.reason).toContain("'phase'") }) it('columns that do not map onto the enum → needs-judgment when < 2 survive', () => { const screen = kanbanScreen() screen.kanban!.columns = [ { key: 'brouillon', label: 'Brouillon' }, { key: 'draft', label: 'Brouillon' }, ] const { outcome } = deriveKanbanForPagespec(pagespecMd(BASE_BLOCK), 'p.md', ctx({ kanbanScreen: screen })) expect(outcome.status).toBe('needs-judgment') expect(outcome.reason).toContain('brouillon') }) it('workflow rules whose Flow maps to ZERO edges → needs-judgment (fail closed, never an open matrix)', () => { const bad = rule({ type: 'workflow', flow: [{ from: 'Brouillon', to: 'Clôturé' }] }) const { outcome } = deriveKanbanForPagespec(pagespecMd(BASE_BLOCK), 'p.md', ctx({ workflowRules: [bad] })) expect(outcome.status).toBe('needs-judgment') expect(outcome.droppedTransitions).toEqual(['BR-012: Brouillon → Clôturé']) }) it('no workflow rules → open matrix (no transitions key), NO move action derived', () => { const { md, outcome } = deriveKanbanForPagespec(pagespecMd(BASE_BLOCK), 'p.md', ctx({ workflowRules: [] })) expect(outcome.status).toBe('derived') const block = blockOf(md) expect((block.kanban as Record).transitions).toBeUndefined() expect((block.actions as unknown[])).toHaveLength(0) expect(outcome.moveActionSkipped).toContain('no workflow rules') }) it('an authored move action is never duplicated', () => { const authored = { ...BASE_BLOCK, actions: [{ code: 'move', kind: 'api', scope: 'row' }] } const { md, outcome } = deriveKanbanForPagespec(pagespecMd(authored), 'p.md', ctx()) expect((blockOf(md).actions as unknown[])).toHaveLength(1) expect(outcome.derivedAction).toBeUndefined() }) it('no UC anchor → move action skipped with an actionable reason, block still written', () => { const { md, outcome } = deriveKanbanForPagespec( pagespecMd({ ...BASE_BLOCK, linkedUseCases: [] }), 'p.md', ctx(), ) expect(outcome.status).toBe('derived') expect(outcome.moveActionSkipped).toContain('no UC anchor') expect((blockOf(md).actions as unknown[])).toHaveLength(0) expect((blockOf(md).kanban as Record).transitions).toBeDefined() }) it('preserves an existing cards viewMode; kanban-only sections default to the board', () => { const withCards = { ...BASE_BLOCK, viewModes: ['table', 'cards'] } const r1 = deriveKanbanForPagespec(pagespecMd(withCards), 'p.md', ctx()) expect(blockOf(r1.md).viewModes).toEqual(['table', 'cards', 'kanban']) const r2 = deriveKanbanForPagespec(pagespecMd(BASE_BLOCK), 'p.md', ctx({ hasListScreen: false })) expect(blockOf(r2.md).defaultViewMode).toBe('kanban') }) }) describe('checkKanbanOfPagespec — the PRD-135 legs', () => { it('a freshly derived pagespec is clean', () => { const { md } = deriveKanbanForPagespec(pagespecMd(BASE_BLOCK), 'p.md', ctx()) expect(checkKanbanOfPagespec(md, 'p.md', ctx())).toEqual([]) }) it('(f) standalone view:kanban pagespec = err', () => { const findings = checkKanbanOfPagespec(pagespecMd({ ...BASE_BLOCK, view: 'kanban' }), 'p.md', ctx()) expect(findings).toMatchObject([{ leg: 'f', severity: 'err' }]) }) it('(a) kanban block on a non-list view = err', () => { const findings = checkKanbanOfPagespec( pagespecMd({ ...BASE_BLOCK, view: 'form', kanban: { statusField: 'status', columns: [] } }), 'p.md', ctx(), ) expect(findings).toMatchObject([{ leg: 'a', severity: 'err' }]) }) it('(e) viewModes ⇔ block, both directions + defaultViewMode membership', () => { const { md } = deriveKanbanForPagespec(pagespecMd(BASE_BLOCK), 'p.md', ctx()) const noMode = blockOf(md) noMode.viewModes = ['table'] expect(checkKanbanOfPagespec(pagespecMd(noMode), 'p.md', ctx())).toContainEqual( expect.objectContaining({ leg: 'e', severity: 'err' }), ) const orphan = { ...BASE_BLOCK, viewModes: ['table', 'kanban'] } expect(checkKanbanOfPagespec(pagespecMd(orphan), 'p.md', ctx())).toContainEqual( expect.objectContaining({ leg: 'e', severity: 'err' }), ) const badDefault = { ...BASE_BLOCK, viewModes: ['table'], defaultViewMode: 'cards' } expect(checkKanbanOfPagespec(pagespecMd(badDefault), 'p.md', ctx())).toContainEqual( expect.objectContaining({ leg: 'e', message: expect.stringContaining("'cards'") }), ) }) it('(schema) an invalid block reports issues, never throws', () => { const bad = { ...BASE_BLOCK, viewModes: ['table', 'kanban'], kanban: { statusField: 'status', columns: [{ key: 'a' }] } } const findings = checkKanbanOfPagespec(pagespecMd(bad), 'p.md', ctx()) expect(findings.some((f) => f.leg === 'schema')).toBe(true) }) it('(b) column outside the enum = err; enum value without a column = warn', () => { const { md } = deriveKanbanForPagespec(pagespecMd(BASE_BLOCK), 'p.md', ctx()) const block = blockOf(md) const kanban = block.kanban as { columns: Array<{ key: string; labelKey: string }> } kanban.columns = kanban.columns.filter((c) => c.key !== 'rejected') kanban.columns.push({ key: 'ghost', labelKey: 'kanban.columns.ghost' }) ;(block.i18nKeys as Record>).fr!['kanban.columns.ghost'] = 'Fantôme' const findings = checkKanbanOfPagespec(pagespecMd(block), 'p.md', ctx()) expect(findings).toContainEqual(expect.objectContaining({ leg: 'b', severity: 'err', message: expect.stringContaining("'ghost'") })) expect(findings).toContainEqual(expect.objectContaining({ leg: 'b', severity: 'warn', message: expect.stringContaining("'rejected'") })) }) it('(c) BR edge missing from the block = err; extra block edge = warn; token outside enum = err', () => { const { md } = deriveKanbanForPagespec(pagespecMd(BASE_BLOCK), 'p.md', ctx()) const block = blockOf(md) const kanban = block.kanban as { transitions: Array<{ from: string; to: string }> } kanban.transitions = kanban.transitions.filter((t) => t.to !== 'rejected') kanban.transitions.push({ from: 'approved', to: 'draft' }) const findings = checkKanbanOfPagespec(pagespecMd(block), 'p.md', ctx()) expect(findings).toContainEqual(expect.objectContaining({ leg: 'c', severity: 'err', message: expect.stringContaining('submitted → rejected') })) expect(findings).toContainEqual(expect.objectContaining({ leg: 'c', severity: 'warn', message: expect.stringContaining('approved → draft') })) kanban.transitions.push({ from: 'draft', to: 'CLOSED' }) expect(checkKanbanOfPagespec(pagespecMd(block), 'p.md', ctx())).toContainEqual( expect.objectContaining({ leg: 'c', severity: 'err', message: expect.stringContaining("'CLOSED'") }), ) }) it('(d) statusField not among the list columns = err; unknown card field = warn', () => { const { md } = deriveKanbanForPagespec(pagespecMd(BASE_BLOCK), 'p.md', ctx()) const block = blockOf(md) block.columns = (block.columns as Array<{ key: string }>).filter((c) => c.key !== 'status') ;(block.kanban as Record).cardFields = ['startDate', 'ghostField'] const findings = checkKanbanOfPagespec(pagespecMd(block), 'p.md', ctx()) expect(findings).toContainEqual(expect.objectContaining({ leg: 'd', severity: 'err', message: expect.stringContaining('status') })) expect(findings).toContainEqual(expect.objectContaining({ leg: 'd', severity: 'warn', message: expect.stringContaining('ghostField') })) }) it('(g) transitions without a move action = warn (read-only board)', () => { const { md } = deriveKanbanForPagespec(pagespecMd({ ...BASE_BLOCK, linkedUseCases: [] }), 'p.md', ctx()) const findings = checkKanbanOfPagespec(md, 'p.md', ctx()) expect(findings).toContainEqual(expect.objectContaining({ leg: 'g', severity: 'warn' })) }) it('(h) column label key missing from i18nKeys.fr = err', () => { const { md } = deriveKanbanForPagespec(pagespecMd(BASE_BLOCK), 'p.md', ctx()) const block = blockOf(md) delete (block.i18nKeys as Record>).fr!['kanban.columns.draft'] const findings = checkKanbanOfPagespec(pagespecMd(block), 'p.md', ctx()) expect(findings).toContainEqual(expect.objectContaining({ leg: 'h', severity: 'err', message: expect.stringContaining('kanban.columns.draft') })) }) }) describe('execute — fs wrapper over a real BA tree', () => { function makeTree(): { workdir: string; pagespecDir: string; moduleRoot: string; pagespecPath: string } { const workdir = mkdtempSync(join(tmpdir(), 'derive-kanban-')) const moduleRoot = join(workdir, '.smartstack', 'ba', 'HR', 'LEAVE') const sectionDir = join(moduleRoot, 'leave-list') const pagespecDir = join(moduleRoot, 'pagespecs') mkdirSync(sectionDir, { recursive: true }) mkdirSync(pagespecDir, { recursive: true }) writeFileSync(join(moduleRoot, 'entité.md'), `# Entités ### ENT-008 — LeaveRequest (agrégat racine) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Code | string(20) | requis | — | | Status | enum | draft/submitted/approved/rejected | — | | StartDate | date | requis | — | `, 'utf-8') writeFileSync(join(moduleRoot, 'règles-métier.md'), `# Règles métier ### BR-012 — Cycle de vie d'une demande - **Type** : workflow - **Sévérité** : err - **Code d'erreur** : \`hr.leave.invalid-transition\` - **Flow** : - draft → submitted - submitted → approved (by: BA-001-AC-002) - submitted → rejected `, 'utf-8') writeFileSync(join(sectionDir, 'screen.md'), `# Écrans ### SCR-HR-LEAVE-LIST-001 — Demandes de congé (SmartListView) - **Entité** : LeaveRequest (ENT-008) - **Permission** : \`hr.leave.read\` ### SCR-HR-LEAVE-LIST-002 — Tableau des demandes (SmartKanban) - **Entité** : LeaveRequest (ENT-008) - **Permission** : \`hr.leave.read\` - **Champ statut** : status - **Colonnes** : draft (Brouillon, gray), submitted (Soumis, blue), approved (Approuvé, green), rejected (Refusé, red) - **Carte** : titre = code, champs = startDate - **Navigation** : clic carte → SCR-HR-LEAVE-DETAIL-001 `, 'utf-8') const pagespecPath = join(pagespecDir, 'LeaveRequest.list.md') writeFileSync(pagespecPath, pagespecMd(BASE_BLOCK), 'utf-8') return { workdir, pagespecDir, moduleRoot, pagespecPath } } it('derive reads the tree, folds the block and writes the pagespec', () => { const { workdir, pagespecDir, moduleRoot, pagespecPath } = makeTree() const report = execute( { mode: 'derive', pagespecDir, moduleRoot }, workdir, ) expect(report.written).toBe(1) expect(report.outcomes[0]!.status).toBe('derived') const block = blockOf(readFileSync(pagespecPath, 'utf-8')) expect((block.kanban as Record).transitions).toHaveLength(3) expect(block.viewModes).toEqual(['table', 'kanban']) // Second run: idempotent. const again = execute({ mode: 'derive', pagespecDir, moduleRoot }, workdir) expect(again.written).toBe(0) expect(again.outcomes[0]!.status).toBe('already') }) it('check mode never writes and reports a clean derived tree as finding-free', () => { const { workdir, pagespecDir, moduleRoot, pagespecPath } = makeTree() execute({ mode: 'derive', pagespecDir, moduleRoot }, workdir) const before = readFileSync(pagespecPath, 'utf-8') const report = execute({ mode: 'check', pagespecDir, moduleRoot }, workdir) expect(report.findings).toEqual([]) expect(report.written).toBe(0) expect(readFileSync(pagespecPath, 'utf-8')).toBe(before) }) })