/** * lib/page-spec-kanban — the first-order `kanban` block of a LIST pagespec * (the board representation folded into the list, mirror of the cards * viewMode) + its two satellite seams: the screen.md SmartKanban bullet * parser (ba-screens.parseKanbanBullets) and the move-action matrix * projection (page-spec-actions.toBusinessCustomAction). * * Covers: parse (valid / invalid / absent), the resolver (statusField gate, * column dedupe, card-field validation, transition dedupe + self-loops, * terminal derivation explicit vs computed, open matrix vs read-only []), * the DnD activation rule, the deterministic ALLOWED_TRANSITIONS literal, * moveMatrixOf, the BA bullet grammar (colors, comma-labels, bare keys, * card line, navigation) and the matrix injection into the business * projection (move action ONLY). */ import { describe, expect, it } from 'vitest' import { DEFAULT_TRANSITION_ERROR_KEY, KANBAN_BA_COLORS, KANBAN_COLOR_FAMILY, KANBAN_MOVE_ACTION_CODE, LIST_VIEW_MODES, allowedTransitionsLiteral, kanbanDndEnabled, moveMatrixOf, parsePageKanban, resolveKanban, } from '../page-spec-kanban.js' import { parseKanbanBullets, parseScreenFile } from '../ba-screens.js' import { toBusinessCustomAction, type PageCustomAction } from '../page-spec-actions.js' const FIELDS = [ { name: 'Code', type: 'string' }, { name: 'Status', type: 'enum' }, { name: 'EmployeeName', type: 'string' }, { name: 'StartDate', type: 'date' }, { name: 'EndDate', type: 'date' }, ] const KANBAN = { statusField: 'status', columns: [ { 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', initiallyHidden: true }, ], titleField: 'code', subtitleField: 'employeeName', cardFields: ['startDate', 'endDate'], transitions: [ { from: 'draft', to: 'submitted', rule: 'BR-012' }, { from: 'submitted', to: 'approved' }, { from: 'submitted', to: 'rejected' }, ], } describe('parsePageKanban', () => { it('parses a valid block', () => { const { kanban, rejected } = parsePageKanban(KANBAN) expect(rejected).toEqual([]) expect(kanban?.statusField).toBe('status') expect(kanban?.columns).toHaveLength(4) }) it('absent → undefined with no rejection (legacy pagespecs untouched)', () => { expect(parsePageKanban(undefined)).toEqual({ kanban: undefined, rejected: [] }) expect(parsePageKanban(null)).toEqual({ kanban: undefined, rejected: [] }) }) it('invalid → undefined + reported issues, never a throw', () => { const { kanban, rejected } = parsePageKanban({ statusField: 'status', columns: [{ key: 'a' }] }) expect(kanban).toBeUndefined() expect(rejected.length).toBeGreaterThan(0) expect(rejected.join(' ')).toContain('kanban.columns') }) it('a single column is not a board', () => { const { kanban, rejected } = parsePageKanban({ statusField: 'status', columns: [{ key: 'a', labelKey: 'kanban.columns.a' }], }) expect(kanban).toBeUndefined() expect(rejected.join(' ')).toContain('kanban.columns') }) it('color outside the closed palette is rejected by the schema', () => { const { kanban } = parsePageKanban({ statusField: 'status', columns: [ { key: 'a', labelKey: 'kanban.columns.a', color: 'purple' }, { key: 'b', labelKey: 'kanban.columns.b' }, ], }) expect(kanban).toBeUndefined() }) }) describe('resolveKanban', () => { it('resolves the full block (camelized fields, tones, terminal derivation)', () => { const { kanban, rejected } = resolveKanban(FIELDS, { kanban: KANBAN }) expect(rejected).toEqual([]) expect(kanban?.statusField).toBe('status') expect(kanban?.titleField).toBe('code') expect(kanban?.subtitleField).toBe('employeeName') expect(kanban?.cardFields).toEqual(['startDate', 'endDate']) expect(kanban?.columns.map(c => c.tone)).toEqual(['neutral', 'info', 'success', 'error']) expect(kanban?.columns[3]?.initiallyHidden).toBe(true) expect(kanban?.allowed?.get('submitted')).toEqual(new Set(['approved', 'rejected'])) // approved & rejected have no outgoing edge → derived terminal. expect(kanban?.terminal).toEqual(new Set(['approved', 'rejected'])) expect(kanban?.transitionErrorKey).toBe(DEFAULT_TRANSITION_ERROR_KEY) }) it('absent block → identity (no kanban, no rejection)', () => { expect(resolveKanban(FIELDS, undefined)).toEqual({ kanban: undefined, rejected: [] }) expect(resolveKanban(FIELDS, {})).toEqual({ kanban: undefined, rejected: [] }) }) it('unknown statusField rejects the WHOLE block — nothing to bucket by', () => { const { kanban, rejected } = resolveKanban(FIELDS, { kanban: { ...KANBAN, statusField: 'phase' }, }) expect(kanban).toBeUndefined() expect(rejected.join(' ')).toContain("'phase' is not a pagespec field") }) it('duplicate column keys are dropped (first wins); < 2 usable columns rejects the block', () => { const dup = { statusField: 'status', columns: [ { key: 'draft', labelKey: 'kanban.columns.draft' }, { key: 'draft', labelKey: 'kanban.columns.draft2' }, { key: 'done', labelKey: 'kanban.columns.done' }, ], } const r1 = resolveKanban(FIELDS, { kanban: dup }) expect(r1.kanban?.columns.map(c => c.labelKey)).toEqual([ 'kanban.columns.draft', 'kanban.columns.done', ]) expect(r1.rejected.join(' ')).toContain("duplicate key 'draft'") const collapsed = { statusField: 'status', columns: [ { key: 'draft', labelKey: 'a' }, { key: 'draft', labelKey: 'b' }, ], } const r2 = resolveKanban(FIELDS, { kanban: collapsed }) expect(r2.kanban).toBeUndefined() expect(r2.rejected.join(' ')).toContain('fewer than 2 usable columns') }) it('unknown title/subtitle/card fields are dropped with a report; statusField in cardFields too', () => { const { kanban, rejected } = resolveKanban(FIELDS, { kanban: { ...KANBAN, titleField: 'nope', subtitleField: 'nada', cardFields: ['startDate', 'ghost', 'status'], }, }) expect(kanban?.titleField).toBeUndefined() expect(kanban?.subtitleField).toBeUndefined() expect(kanban?.cardFields).toEqual(['startDate']) expect(rejected.join(' ')).toContain("kanban.titleField: unknown field 'nope'") expect(rejected.join(' ')).toContain("kanban.cardFields: unknown field 'ghost'") expect(rejected.join(' ')).toContain("'status' is the statusField") }) it('self-loops and duplicate edges are dropped with a report', () => { const { kanban, rejected } = resolveKanban(FIELDS, { kanban: { ...KANBAN, transitions: [ { from: 'draft', to: 'draft' }, { from: 'draft', to: 'submitted' }, { from: 'draft', to: 'submitted' }, ], }, }) expect(kanban?.transitions).toHaveLength(1) expect(rejected.join(' ')).toContain('self-loop') expect(rejected.join(' ')).toContain('duplicate edge') }) it('explicit terminalColumns wins over derivation; unknown keys dropped', () => { const { kanban, rejected } = resolveKanban(FIELDS, { kanban: { ...KANBAN, terminalColumns: ['approved', 'ghost'] }, }) expect(kanban?.terminal).toEqual(new Set(['approved'])) expect(rejected.join(' ')).toContain("terminalColumns: 'ghost' is not a column key") }) it('no transitions → OPEN matrix (allowed undefined) and no derived terminal', () => { const { transitions: _t, ...rest } = KANBAN const { kanban } = resolveKanban(FIELDS, { kanban: rest }) expect(kanban?.allowed).toBeUndefined() expect(kanban?.terminal.size).toBe(0) }) it('transitions: [] → every column terminal (explicitly read-only board)', () => { const { kanban } = resolveKanban(FIELDS, { kanban: { ...KANBAN, transitions: [] } }) expect(kanban?.allowed?.size).toBe(0) expect(kanban?.terminal).toEqual(new Set(['draft', 'submitted', 'approved', 'rejected'])) }) }) describe('kanbanDndEnabled', () => { it('transitions [] = read-only, whatever dndCards says', () => { expect(kanbanDndEnabled({ transitions: [] }, true)).toBe(false) expect(kanbanDndEnabled({ transitions: [], dndCards: true }, true)).toBe(false) }) it('dndCards false always wins; true always enables', () => { expect(kanbanDndEnabled({ dndCards: false }, true)).toBe(false) expect(kanbanDndEnabled({ dndCards: true }, false)).toBe(true) }) it('omitted → auto on the move action', () => { expect(kanbanDndEnabled({}, true)).toBe(true) expect(kanbanDndEnabled({}, false)).toBe(false) expect( kanbanDndEnabled({ transitions: [{ from: 'a', to: 'b' }] }, true), ).toBe(true) }) }) describe('allowedTransitionsLiteral', () => { it('stable output: sorted keys and targets, deduped edges, dropped self-loops', () => { const literal = allowedTransitionsLiteral([ { from: 'submitted', to: 'rejected' }, { from: 'submitted', to: 'approved' }, { from: 'draft', to: 'submitted' }, { from: 'draft', to: 'submitted' }, { from: 'draft', to: 'draft' }, ]) expect(literal).toBe( "{\n draft: ['submitted'],\n submitted: ['approved', 'rejected'],\n}", ) }) it('quotes non-identifier keys (kebab enum values)', () => { const literal = allowedTransitionsLiteral([{ from: 'in-review', to: 'done' }]) expect(literal).toBe("{\n 'in-review': ['done'],\n}") }) it('no edges → {}', () => { expect(allowedTransitionsLiteral([])).toBe('{}') expect(allowedTransitionsLiteral([{ from: 'a', to: 'a' }])).toBe('{}') }) }) describe('moveMatrixOf', () => { it('extracts the deduped matrix + Pascal statusProperty + errorCode', () => { const ctx = moveMatrixOf({ kanban: { ...KANBAN, transitionErrorCode: 'leave.status.invalid-transition' }, }) expect(ctx?.statusProperty).toBe('Status') expect(ctx?.errorCode).toBe('leave.status.invalid-transition') expect(ctx?.transitions).toEqual([ { from: 'draft', to: 'submitted', rule: 'BR-012' }, { from: 'submitted', to: 'approved' }, { from: 'submitted', to: 'rejected' }, ]) }) it('no block / open matrix / read-only [] → undefined (no guard to compile)', () => { expect(moveMatrixOf(undefined)).toBeUndefined() expect(moveMatrixOf({})).toBeUndefined() const { transitions: _t, ...open } = KANBAN expect(moveMatrixOf({ kanban: open })).toBeUndefined() expect(moveMatrixOf({ kanban: { ...KANBAN, transitions: [] } })).toBeUndefined() }) }) // --------------------------------------------------------------------------- // screen.md SmartKanban bullets (ba-screens.parseKanbanBullets) // --------------------------------------------------------------------------- const KANBAN_BLOCK = `### SCR-HR-LEAVE-LIST-002 — Tableau des demandes de congé (SmartKanban) - **Entité** : LeaveRequest (ENT-008) - **Permission** : \`hr.leave.read\` - **Cas d'usage liés** : UC-HR-LEAVE-LIST-001, UC-HR-LEAVE-LIST-002 - **Champ statut** : status - **Colonnes** : draft (Brouillon, gray), submitted (Soumis, blue), approved (Approuvé, green), rejected (Refusé, red) - **Carte** : titre = code, sous-titre = employeeName, champs = startDate, endDate, days - **Navigation** : clic carte → SCR-HR-LEAVE-DETAIL-001 ` describe('parseKanbanBullets', () => { it('parses the canonical kanban-screens.md grammar', () => { const { config, warnings } = parseKanbanBullets(KANBAN_BLOCK) expect(warnings).toEqual([]) expect(config.statusField).toBe('status') expect(config.columns).toEqual([ { key: 'draft', label: 'Brouillon', color: 'gray' }, { key: 'submitted', label: 'Soumis', color: 'blue' }, { key: 'approved', label: 'Approuvé', color: 'green' }, { key: 'rejected', label: 'Refusé', color: 'red' }, ]) expect(config.titleField).toBe('code') expect(config.subtitleField).toBe('employeeName') expect(config.cardFields).toEqual(['startDate', 'endDate', 'days']) expect(config.rowClickTarget).toBe('SCR-HR-LEAVE-DETAIL-001') }) it('a comma inside the label survives when the last token is a palette color', () => { const { config } = parseKanbanBullets( '- **Champ statut** : status\n- **Colonnes** : open (En cours, à traiter, blue), done (Terminé)\n', ) expect(config.columns).toEqual([ { key: 'open', label: 'En cours, à traiter', color: 'blue' }, { key: 'done', label: 'Terminé' }, ]) }) it('invented colors stay verbatim (the deriver drops them, not the parser)', () => { const { config } = parseKanbanBullets( '- **Champ statut** : status\n- **Colonnes** : a (Alpha, purple), b (Beta)\n', ) // `purple` is not in the palette → treated as part of the label. expect(config.columns[0]).toEqual({ key: 'a', label: 'Alpha, purple' }) expect((KANBAN_BA_COLORS as readonly string[]).includes('purple')).toBe(false) }) it('bare keys fall back to key-as-label with a warning', () => { const { config, warnings } = parseKanbanBullets( '- **Champ statut** : status\n- **Colonnes** : draft, submitted\n', ) expect(config.columns).toEqual([ { key: 'draft', label: 'draft' }, { key: 'submitted', label: 'submitted' }, ]) expect(warnings.join(' ')).toContain('keys reused as labels') }) it('a non-bare-key column token is dropped WITH a warning, never in silence', () => { const { config, warnings } = parseKanbanBullets( '- **Champ statut** : status\n- **Colonnes** : draft, submitted, état final\n', ) expect(config.columns.map((c) => c.key)).toEqual(['draft', 'submitted']) expect(warnings.join(' ')).toContain('column entries dropped') expect(warnings.join(' ')).toContain('état final') }) it('a parenthesised card field is dropped WITH a warning, never in silence', () => { const { config, warnings } = parseKanbanBullets( '- **Champ statut** : status\n- **Carte** : titre = code, champs = startDate, statut (badge), endDate\n', ) expect(config.cardFields).toEqual(['startDate', 'endDate']) expect(warnings.join(' ')).toContain('card champs entries dropped') expect(warnings.join(' ')).toContain('statut (badge)') }) it('missing statut/colonnes bullets are warned, never thrown', () => { const { config, warnings } = parseKanbanBullets('- **Entité** : X\n') expect(config.statusField).toBeUndefined() expect(config.columns).toEqual([]) expect(warnings.join(' ')).toContain('Champ statut') expect(warnings.join(' ')).toContain('Colonnes') }) }) describe('parseScreenFile — kanban attachment', () => { it('attaches `kanban` ONLY to SmartKanban blocks', () => { const content = `### SCR-HR-LEAVE-LIST-001 — Demandes de congé (SmartListView) - **Entité** : LeaveRequest (ENT-008) - **Permission** : \`hr.leave.read\` ${KANBAN_BLOCK}` const { screens, warnings } = parseScreenFile(content, { file: 'HR/LEAVE/leave-list/screen.md', module: 'LEAVE', section: 'leave-list', }) expect(warnings).toEqual([]) expect(screens).toHaveLength(2) expect(screens[0].kanban).toBeUndefined() expect(screens[1].kanban?.statusField).toBe('status') expect(screens[1].kanban?.columns).toHaveLength(4) }) it('prefixes kanban grammar warnings with file · code', () => { const { screens, warnings } = parseScreenFile( '### SCR-X-Y-LIST-002 — Board (SmartKanban)\n- **Entité** : Thing\n', { file: 'X/Y/y-list/screen.md', module: 'Y', section: 'y-list' }, ) expect(screens[0].kanban?.columns).toEqual([]) expect(warnings.some(w => w.startsWith('X/Y/y-list/screen.md · SCR-X-Y-LIST-002:'))).toBe(true) }) }) // --------------------------------------------------------------------------- // Business projection — the matrix guard rides the `move` action only // --------------------------------------------------------------------------- const MOVE_ACTION: PageCustomAction = { code: KANBAN_MOVE_ACTION_CODE, kind: 'api', scope: 'row', httpMethod: 'POST', responseDto: null, responseType: null, labelKey: 'list.actions.move', permission: 'hr.leave.update', ucReference: 'UC-HR-LEAVE-LIST-001', guardRules: [], crossModuleDeps: [], payloadParameters: [ { name: 'status', type: 'select', required: true, field: 'status' }, ], } describe('toBusinessCustomAction — kanban matrix injection', () => { const ctx = moveMatrixOf({ kanban: { ...KANBAN, transitionErrorCode: 'hr.leave.invalid-transition' } })! it('injects transitionMatrix + statusProperty + statusErrorCode on the move action', () => { const input = toBusinessCustomAction(MOVE_ACTION, 'LeaveRequest', ctx) expect(input.transitionMatrix).toEqual([ { from: 'draft', to: 'submitted', rule: 'BR-012' }, { from: 'submitted', to: 'approved' }, { from: 'submitted', to: 'rejected' }, ]) expect(input.statusProperty).toBe('Status') expect(input.statusErrorCode).toBe('hr.leave.invalid-transition') }) it('NEVER injects the matrix on another action code', () => { const other = { ...MOVE_ACTION, code: 'archive' } const input = toBusinessCustomAction(other, 'LeaveRequest', ctx) expect(input.transitionMatrix).toBeUndefined() expect(input.statusProperty).toBeUndefined() }) it('no context → the projection is byte-identical to the legacy one', () => { const withCtx = toBusinessCustomAction(MOVE_ACTION, 'LeaveRequest') expect(withCtx.transitionMatrix).toBeUndefined() expect(withCtx.statusErrorCode).toBeUndefined() }) it('round-trip: the injected projection parses against the REAL scaffold-business schema (matrix kept verbatim)', async () => { const { BusinessCustomActionSchema } = await import( '../../development/backend/business-layer/cli/scaffold-business/types.js' ) const input = toBusinessCustomAction(MOVE_ACTION, 'LeaveRequest', ctx) const parsed = BusinessCustomActionSchema.parse(input) expect(parsed.transitionMatrix).toEqual(input.transitionMatrix) expect(parsed.statusProperty).toBe('Status') expect(parsed.statusErrorCode).toBe('hr.leave.invalid-transition') }) }) describe('constants', () => { it('LIST_VIEW_MODES carries the three representations', () => { expect(LIST_VIEW_MODES).toEqual(['table', 'cards', 'kanban']) }) it('every BA color maps to a token family', () => { for (const color of KANBAN_BA_COLORS) { expect(KANBAN_COLOR_FAMILY[color]).toBeTruthy() } }) })