/** * check.test.ts — mode=validate (RTV-001..009 + pagespec cross-checks + strict spec). */ import { describe, it, expect } from 'vitest' import { parseEntities } from '../relations.js' import { parseScreenFile, type ParsedScreen } from '../screens.js' import { parseRbacPermissions } from '../rbac.js' import { checkDeclaredTabs, type CheckContext } from '../check.js' import { validateSpec } from '../validate.js' import { DeriveRelatedTabsInputSchema } from '../types.js' // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- const CRM_CLIENTS = `### ENT-001 — Client (agrégat racine) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Name | string(200) | requis | — | ### ENT-002 — Invoice (agrégat racine) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Number | string(20) | requis | — | - **Relations** : Invoice *→1 Client — FK ClientId, scope same-module, onDelete restrict. ` const INVOICES_LIST_MD = `### SCR-CRM-CLIENTS-INVOICES-LIST-001 — Liste des factures (SmartListView) - **Entité** : Invoice (ENT-002) - **Permission** : \`crm.invoices.read\` ` function screensOf( ...parts: Array<{ md: string; module?: string; section?: string; app?: string }> ): ParsedScreen[] { return parts.flatMap((p) => { const app = p.app ?? 'CRM' const module = p.module ?? 'CLIENTS' const section = p.section ?? 'clients' return parseScreenFile(p.md, { file: `${app}/${module}/${section}/screen.md`, app, module, section, }).screens }) } function detailWith(bullets: string): string { return `### SCR-CRM-CLIENTS-CLIENTS-DETAIL-001 — Fiche client (SmartForm) - **Entité** : Client (ENT-001) - **Mode** : detail ${bullets}` } function ctx(overrides: { entites?: Record screens?: ParsedScreen[] rbac?: string spec?: Record pagespecs?: Map | null pagespecsOf?: (app: string, module: string) => Map | null }): CheckContext { return { spec: DeriveRelatedTabsInputSchema.parse({ baRoot: '.smartstack/ba', app: 'CRM', module: 'CLIENTS', mode: 'validate', ...overrides.spec, }), graph: parseEntities(new Map(Object.entries(overrides.entites ?? { 'CRM/CLIENTS': CRM_CLIENTS }))), screens: overrides.screens ?? [], rbac: parseRbacPermissions(overrides.rbac ?? '| A | `crm.invoices.read` | — |'), pagespecs: overrides.pagespecs, pagespecsOf: overrides.pagespecsOf, } } function rules(report: ReturnType, rule: string) { return report.violations.filter((v) => v.rule === rule) } // --------------------------------------------------------------------------- // RTV-001..008 // --------------------------------------------------------------------------- describe('checkDeclaredTabs — RTV rules', () => { it('RTV-001: flags a related entity missing from entité.md', () => { const screens = screensOf({ md: detailWith('- **Onglet lié « Fantômes »** : entité Ghost, FK clientId\n'), }) const report = checkDeclaredTabs(ctx({ screens })) const v = rules(report, 'RTV-001') expect(v).toHaveLength(1) expect(v[0]).toMatchObject({ severity: 'err', tabKey: 'fantomes' }) expect(v[0].message).toContain('Ghost') }) it('RTV-002: flags a missing back-pointing FK on the related entity', () => { const screens = screensOf( { md: detailWith('- **Onglet lié « Factures »** : entité Invoice, FK otherId → SCR-CRM-CLIENTS-INVOICES-LIST-001\n') }, { md: INVOICES_LIST_MD, section: 'invoices' }, ) const report = checkDeclaredTabs(ctx({ screens })) const v = rules(report, 'RTV-002') expect(v).toHaveLength(1) expect(v[0].severity).toBe('err') expect(v[0].message).toContain('otherId') }) it('RTV-002: passes when the FK points back (case-insensitive on the column)', () => { const screens = screensOf( { md: detailWith('- **Onglet lié « Factures »** : entité Invoice, FK clientId → SCR-CRM-CLIENTS-INVOICES-LIST-001\n') }, { md: INVOICES_LIST_MD, section: 'invoices' }, ) const report = checkDeclaredTabs(ctx({ screens })) expect(rules(report, 'RTV-002')).toHaveLength(0) }) it('RTV-003: flags a declared target that does not exist', () => { const screens = screensOf({ md: detailWith('- **Onglet lié « Factures »** : entité Invoice, FK clientId → SCR-DOES-NOT-EXIST-001\n'), }) const report = checkDeclaredTabs(ctx({ screens })) expect(rules(report, 'RTV-003')).toHaveLength(1) expect(rules(report, 'RTV-003')[0].message).toContain('SCR-DOES-NOT-EXIST-001') }) it('RTV-003: flags a target that is not a SmartListView/SmartCard', () => { const form = `### SCR-CRM-CLIENTS-INVOICES-FORM-001 — Fiche facture (SmartForm) - **Entité** : Invoice (ENT-002) - **Mode** : detail - **Sans onglets liés** : fixture ` const screens = screensOf( { md: detailWith('- **Onglet lié « Factures »** : entité Invoice, FK clientId → SCR-CRM-CLIENTS-INVOICES-FORM-001\n') }, { md: form, section: 'invoices' }, ) const report = checkDeclaredTabs(ctx({ screens })) const v = rules(report, 'RTV-003') expect(v).toHaveLength(1) expect(v[0].message).toContain('SmartForm') }) it('RTV-003: flags a tab with no target when none is resolvable', () => { const screens = screensOf({ md: detailWith('- **Onglet lié « Factures »** : entité Invoice, FK clientId\n'), }) const report = checkDeclaredTabs(ctx({ screens })) expect(rules(report, 'RTV-003')).toHaveLength(1) }) it('RTV-003: passes a tab with no target when a list screen resolves', () => { const screens = screensOf( { md: detailWith('- **Onglet lié « Factures »** : entité Invoice, FK clientId\n') }, { md: INVOICES_LIST_MD, section: 'invoices' }, ) const report = checkDeclaredTabs(ctx({ screens })) expect(rules(report, 'RTV-003')).toHaveLength(0) }) it('RTV-004: warns when the declared permission is not in rbac.md', () => { const screens = screensOf( { md: detailWith('- **Onglet lié « Factures »** : entité Invoice, FK clientId → SCR-CRM-CLIENTS-INVOICES-LIST-001 (`crm.unknown.read`)\n') }, { md: INVOICES_LIST_MD, section: 'invoices' }, ) const report = checkDeclaredTabs(ctx({ screens })) const v = rules(report, 'RTV-004') expect(v).toHaveLength(1) expect(v[0].severity).toBe('warn') }) it('RTV-004: silent when the permission exists verbatim', () => { const screens = screensOf( { md: detailWith('- **Onglet lié « Factures »** : entité Invoice, FK clientId → SCR-CRM-CLIENTS-INVOICES-LIST-001 (`crm.invoices.read`)\n') }, { md: INVOICES_LIST_MD, section: 'invoices' }, ) const report = checkDeclaredTabs(ctx({ screens })) expect(rules(report, 'RTV-004')).toHaveLength(0) }) it('RTV-005: flags related tabs on a create-mode form', () => { const create = `### SCR-CRM-CLIENTS-CLIENTS-CREATE-001 — Nouveau client (SmartForm) - **Entité** : Client (ENT-001) - **Mode** : create - **Onglet lié « Factures »** : entité Invoice, FK clientId ` const report = checkDeclaredTabs(ctx({ screens: screensOf({ md: create }) })) const v = rules(report, 'RTV-005') expect(v).toHaveLength(1) expect(v[0].severity).toBe('err') // per-tab prerequisites are not piled on top of the root error expect(rules(report, 'RTV-003')).toHaveLength(0) }) it('RTV-006: flags duplicate tab keys within one screen', () => { const screens = screensOf( { md: detailWith( '- **Onglet lié « Factures »** : entité Invoice, FK clientId → SCR-CRM-CLIENTS-INVOICES-LIST-001\n' + '- **Onglet lié « Factures »** : entité Invoice, FK clientId → SCR-CRM-CLIENTS-INVOICES-LIST-001\n', ), }, { md: INVOICES_LIST_MD, section: 'invoices' }, ) const report = checkDeclaredTabs(ctx({ screens })) const v = rules(report, 'RTV-006') expect(v).toHaveLength(1) expect(v[0]).toMatchObject({ severity: 'err', tabKey: 'factures' }) }) it('RTV-007: flags a detail form with incoming 1:N, zero tabs and no marker', () => { const report = checkDeclaredTabs(ctx({ screens: screensOf({ md: detailWith('') }) })) const v = rules(report, 'RTV-007') expect(v).toHaveLength(1) expect(v[0].severity).toBe('err') expect(v[0].message).toContain('Invoice.ClientId') }) it('RTV-007: the Sans onglets liés marker is the documented escape', () => { const report = checkDeclaredTabs( ctx({ screens: screensOf({ md: detailWith('- **Sans onglets liés** : volumétrie triviale, liste directe suffisante\n') }) }), ) expect(rules(report, 'RTV-007')).toHaveLength(0) expect(report.summary.err).toBe(0) }) it('RTV-007: silent when the entity has no incoming 1:N relation', () => { const noIncoming = `### ENT-001 — Client (agrégat racine) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | ` const report = checkDeclaredTabs( ctx({ entites: { 'CRM/CLIENTS': noIncoming }, screens: screensOf({ md: detailWith('') }) }), ) expect(rules(report, 'RTV-007')).toHaveLength(0) }) it('RTV-008: flags a canonical-shape rejection (PascalCase FK) with the Zod path', () => { const screens = screensOf( { md: detailWith('- **Onglet lié « Factures »** : entité Invoice, FK ClientId → SCR-CRM-CLIENTS-INVOICES-LIST-001\n') }, { md: INVOICES_LIST_MD, section: 'invoices' }, ) const report = checkDeclaredTabs(ctx({ screens })) const v = rules(report, 'RTV-008') expect(v).toHaveLength(1) expect(v[0].severity).toBe('err') expect(v[0].message).toContain('relationFk') // RTV-002 stays green: the FK column matches case-insensitively expect(rules(report, 'RTV-002')).toHaveLength(0) }) it('a fully-valid declaration yields zero violations', () => { const screens = screensOf( { md: detailWith('- **Onglet lié « Factures »** : entité Invoice, FK clientId, affichage table → SCR-CRM-CLIENTS-INVOICES-LIST-001 (`crm.invoices.read`)\n') }, { md: INVOICES_LIST_MD, section: 'invoices' }, ) const report = checkDeclaredTabs(ctx({ screens })) expect(report.violations).toEqual([]) expect(report.summary.err).toBe(0) expect(report.summary.ok).toBeGreaterThan(0) }) }) // --------------------------------------------------------------------------- // Pagespec cross-checks // --------------------------------------------------------------------------- function pagespecOf(relatedTabs: unknown[]): string { return `# Pagespec — Client.detail \`\`\`json ${JSON.stringify({ entity: 'Client', view: 'detail', relatedTabs }, null, 2)} \`\`\` ` } const FACTURES_TAB = { key: 'factures', displayMode: 'table', relatedEntity: 'Invoice', relationFk: 'clientId', relatedModule: 'clients', relatedSection: 'invoices', targetScreen: 'SCR-CRM-CLIENTS-INVOICES-LIST-001', } const DECLARED_SCREENS = () => screensOf( { md: detailWith('- **Onglet lié « Factures »** : entité Invoice, FK clientId → SCR-CRM-CLIENTS-INVOICES-LIST-001\n') }, { md: INVOICES_LIST_MD, section: 'invoices' }, ) describe('checkDeclaredTabs — pagespec cross-checks', () => { it('RTV-101: flags a screen.md tab missing from the pagespec', () => { const report = checkDeclaredTabs( ctx({ screens: DECLARED_SCREENS(), spec: { pagespecs: true }, pagespecs: new Map([['Client.detail.md', pagespecOf([])]]), }), ) const v = rules(report, 'RTV-101') expect(v).toHaveLength(1) expect(v[0]).toMatchObject({ source: 'pagespec', tabKey: 'factures', severity: 'err' }) }) it('RTV-102: flags a pagespec tab absent from screen.md', () => { const extra = { ...FACTURES_TAB, key: 'contacts', relatedEntity: 'Contact' } const report = checkDeclaredTabs( ctx({ screens: DECLARED_SCREENS(), spec: { pagespecs: true }, pagespecs: new Map([['Client.detail.md', pagespecOf([FACTURES_TAB, extra])]]), }), ) const v = rules(report, 'RTV-102') expect(v).toHaveLength(1) expect(v[0]).toMatchObject({ source: 'pagespec', tabKey: 'contacts' }) }) it('RTV-103: flags an fk/target/displayMode drift', () => { const drifted = { ...FACTURES_TAB, displayMode: 'cards' } const report = checkDeclaredTabs( ctx({ screens: DECLARED_SCREENS(), spec: { pagespecs: true }, pagespecs: new Map([['Client.detail.md', pagespecOf([drifted])]]), }), ) const v = rules(report, 'RTV-103') expect(v).toHaveLength(1) expect(v[0].message).toContain('displayMode') }) it('RTV-104: flags a pagespec tab rejected by PageRelatedTabSchema', () => { const invalid = { ...FACTURES_TAB, relationFk: 'ClientId' } const report = checkDeclaredTabs( ctx({ screens: DECLARED_SCREENS(), spec: { pagespecs: true }, pagespecs: new Map([['Client.detail.md', pagespecOf([invalid])]]), }), ) const v = rules(report, 'RTV-104') expect(v).toHaveLength(1) expect(v[0].message).toContain('relationFk') }) // --- RTV-105..107 — routing contract (the sub-view mis-routing guard) --- function listPagespecOf(entity: string, section: string, routeFamily?: string): string { const block: Record = { entity, view: 'list', section } if (routeFamily !== undefined) block.routeFamily = routeFamily return `# Pagespec — ${entity}.list\n\n\`\`\`json\n${JSON.stringify(block, null, 2)}\n\`\`\`\n` } it('RTV-105: flags a sub-view target whose list pagespec carries no routeFamily', () => { // Two list pagespecs share the menu section — the sub-view pattern; the // satellite (Invoice) MUST declare its route family. const report = checkDeclaredTabs( ctx({ screens: DECLARED_SCREENS(), spec: { pagespecs: true }, pagespecs: new Map([ ['Client.detail.md', pagespecOf([FACTURES_TAB])], ['Client.list.md', listPagespecOf('Client', 'clients')], ['Invoice.list.md', listPagespecOf('Invoice', 'clients')], ]), }), ) const v = rules(report, 'RTV-105') expect(v).toHaveLength(1) expect(v[0]).toMatchObject({ source: 'pagespec', tabKey: 'factures', severity: 'err' }) expect(v[0].message).toContain('routeFamily') }) it('RTV-105: flags a relatedRouteFamily that mismatches the target routeFamily', () => { const tab = { ...FACTURES_TAB, relatedRouteFamily: 'invoices' } const report = checkDeclaredTabs( ctx({ screens: DECLARED_SCREENS(), spec: { pagespecs: true }, pagespecs: new Map([ ['Client.detail.md', pagespecOf([tab])], ['Invoice.list.md', listPagespecOf('Invoice', 'clients', 'client-invoices')], ]), }), ) const v = rules(report, 'RTV-105') expect(v).toHaveLength(1) expect(v[0].message).toContain('client-invoices') }) it('RTV-105: flags a missing relatedRouteFamily when the target family differs from relatedSection', () => { const report = checkDeclaredTabs( ctx({ screens: DECLARED_SCREENS(), spec: { pagespecs: true }, pagespecs: new Map([ ['Client.detail.md', pagespecOf([FACTURES_TAB])], // relatedSection: 'invoices' ['Invoice.list.md', listPagespecOf('Invoice', 'clients', 'client-invoices')], ]), }), ) const v = rules(report, 'RTV-105') expect(v).toHaveLength(1) expect(v[0].message).toContain('"relatedRouteFamily": "client-invoices"') }) it('RTV-105: passes when relatedRouteFamily copies the target routeFamily', () => { const tab = { ...FACTURES_TAB, relatedRouteFamily: 'client-invoices' } const report = checkDeclaredTabs( ctx({ screens: DECLARED_SCREENS(), spec: { pagespecs: true }, pagespecs: new Map([ ['Client.detail.md', pagespecOf([tab])], ['Invoice.list.md', listPagespecOf('Invoice', 'clients', 'client-invoices')], ]), }), ) expect(rules(report, 'RTV-105')).toEqual([]) }) it('RTV-106: flags withCreate: true with no .form.md pagespec', () => { const tab = { ...FACTURES_TAB, withCreate: true } const report = checkDeclaredTabs( ctx({ screens: DECLARED_SCREENS(), spec: { pagespecs: true }, pagespecs: new Map([['Client.detail.md', pagespecOf([tab])]]), }), ) const v = rules(report, 'RTV-106') expect(v).toHaveLength(1) expect(v[0]).toMatchObject({ severity: 'err', tabKey: 'factures' }) // With the form pagespec present, the same tab passes. const ok = checkDeclaredTabs( ctx({ screens: DECLARED_SCREENS(), spec: { pagespecs: true }, pagespecs: new Map([ ['Client.detail.md', pagespecOf([tab])], ['Invoice.form.md', '```json\n{"entity":"Invoice","view":"form"}\n```'], ]), }), ) expect(rules(ok, 'RTV-106')).toEqual([]) }) it('RTV-107: flags withRowOpen: true with no .detail.md pagespec', () => { const tab = { ...FACTURES_TAB, withRowOpen: true } const report = checkDeclaredTabs( ctx({ screens: DECLARED_SCREENS(), spec: { pagespecs: true }, pagespecs: new Map([['Client.detail.md', pagespecOf([tab])]]), }), ) const v = rules(report, 'RTV-107') expect(v).toHaveLength(1) expect(v[0]).toMatchObject({ severity: 'err', tabKey: 'factures' }) }) it('an aligned pagespec yields zero pagespec violations', () => { const report = checkDeclaredTabs( ctx({ screens: DECLARED_SCREENS(), spec: { pagespecs: true }, pagespecs: new Map([['Client.detail.md', pagespecOf([FACTURES_TAB])]]), }), ) expect(report.violations.filter((v) => v.source === 'pagespec')).toEqual([]) }) it('a missing pagespecs directory warns instead of crashing', () => { const report = checkDeclaredTabs( ctx({ screens: DECLARED_SCREENS(), spec: { pagespecs: true }, pagespecs: null }), ) expect(report.warnings.some((w) => w.includes('pagespecs'))).toBe(true) expect(report.violations.filter((v) => v.source === 'pagespec')).toEqual([]) }) }) // --------------------------------------------------------------------------- // Strict input spec // --------------------------------------------------------------------------- describe('input spec (strict)', () => { it('rejects an unknown key', () => { const result = validateSpec({ baRoot: '.smartstack/ba', app: 'CRM', module: 'CLIENTS', bogusKey: true, }) expect(result.valid).toBe(false) expect(result.errors.join(' ')).toMatch(/unrecognized/i) }) it('rejects an invalid mode', () => { const result = validateSpec({ baRoot: '.', app: 'CRM', module: 'CLIENTS', mode: 'audit' }) expect(result.valid).toBe(false) expect(result.errors.join(' ')).toContain('mode') }) }) // --------------------------------------------------------------------------- // RTV-009 / RTV-108 — the tab-bar budget (tour 6 §41) // --------------------------------------------------------------------------- describe('checkDeclaredTabs — RTV-009 (screen-side shared-bar budget)', () => { const lie = (n: number, extra = '') => Array.from({ length: n }, (_, i) => `- **Onglet lié « Lié ${i + 1} »** : entité Invoice, FK clientId${extra}\n`).join('') const onglet = (n: number) => Array.from({ length: n }, (_, i) => `- **Onglet « Champ ${i + 1} »** : a, b\n`).join('') it('warns above 7 shared-bar triggers and errs above 9', () => { const warn = checkDeclaredTabs(ctx({ screens: screensOf({ md: detailWith(onglet(2) + lie(6)) }) })) expect(rules(warn, 'RTV-009')).toHaveLength(1) expect(rules(warn, 'RTV-009')[0].severity).toBe('warn') const err = checkDeclaredTabs(ctx({ screens: screensOf({ md: detailWith(onglet(4) + lie(6)) }) })) expect(rules(err, 'RTV-009')[0].severity).toBe('err') }) it('stays silent at 7 — and `affichage summary` bullets never count (band-bound)', () => { const silent = checkDeclaredTabs(ctx({ screens: screensOf({ md: detailWith(onglet(3) + lie(4)) }) })) expect(rules(silent, 'RTV-009')).toHaveLength(0) // 3 + 6 would warn — but 2 of the 6 are summary → band → n = 7, silent. const banded = checkDeclaredTabs(ctx({ screens: screensOf({ md: detailWith(onglet(3) + lie(4) + lie(2, ', affichage summary').replaceAll('Lié 1', 'Sum 1').replaceAll('Lié 2', 'Sum 2')) }), })) expect(rules(banded, 'RTV-009')).toHaveLength(0) }) it('a detail screen with a create-form sibling counts as the unified fiche (field term 0)', () => { const createSibling = `### SCR-CRM-CLIENTS-CLIENTS-CREATE-001 — Nouveau client (SmartForm) - **Entité** : Client (ENT-001) - **Mode** : create ` const bullets = onglet(8) // a wall of inner tabs, zero related const legacy = checkDeclaredTabs(ctx({ screens: screensOf({ md: detailWith(bullets) }) })) expect(rules(legacy, 'RTV-009')).toHaveLength(1) const unified = checkDeclaredTabs(ctx({ screens: screensOf({ md: detailWith(bullets) }, { md: createSibling }), })) expect(rules(unified, 'RTV-009')).toHaveLength(0) }) it('the message names both remedies and forbids dropping tabs', () => { const report = checkDeclaredTabs(ctx({ screens: screensOf({ md: detailWith(onglet(2) + lie(6)) }) })) const msg = rules(report, 'RTV-009')[0].message expect(msg).toContain('affichage summary') expect(msg).toContain('Section') expect(msg).toContain('Do NOT drop authored tabs (PRD-103)') }) }) describe('checkDeclaredTabs — RTV-108 (pagespec-side rendered-bar budget)', () => { const psTab = (key: string, extra: Record = {}) => ({ key, displayMode: 'table', relatedEntity: 'Invoice', relationFk: 'clientId', relatedModule: 'crm', relatedSection: 'invoices', ...extra, }) const detailSpec = (block: Record, entity = 'Client') => `# ${entity} — detail\n\n\`\`\`json\n${JSON.stringify({ entity, view: 'detail', ...block })}\n\`\`\`\n` const keys = (n: number, prefix = 'rel') => Array.from({ length: n }, (_, i) => `${prefix}-${i + 1}`) it('warns above 7 rendered triggers, errs above 9 — even on a screen-less pagespec', () => { const warn = checkDeclaredTabs(ctx({ pagespecs: new Map([[ 'Client.detail.md', detailSpec({ tabs: [{ key: 'a' }, { key: 'b' }], relatedTabs: keys(6).map((k) => psTab(k)) }), ]]), })) const w = rules(warn, 'RTV-108') expect(w).toHaveLength(1) expect(w[0]).toMatchObject({ severity: 'warn', source: 'pagespec', screenCode: 'Client' }) const err = checkDeclaredTabs(ctx({ pagespecs: new Map([[ 'Client.detail.md', detailSpec({ tabs: [{ key: 'a' }, { key: 'b' }, { key: 'c' }, { key: 'd' }], relatedTabs: keys(6).map((k) => psTab(k)) }), ]]), })) expect(rules(err, 'RTV-108')[0].severity).toBe('err') }) it('band cartouches never count — 6 summary tabs melt out of an over-budget bar', () => { const report = checkDeclaredTabs(ctx({ pagespecs: new Map([[ 'Client.detail.md', detailSpec({ tabs: [{ key: 'a' }, { key: 'b' }], relatedTabs: [ ...keys(2).map((k) => psTab(k)), ...keys(6, 'sum').map((k) => psTab(k, { displayMode: 'summary' })), ], }), ]]), })) expect(rules(report, 'RTV-108')).toHaveLength(0) }) it('the unified fiche zeroes the field-tab term; the direct opt-out counts it again', () => { const block = { tabs: keys(4, 'f').map((k) => ({ key: k })), relatedTabs: keys(4).map((k) => psTab(k)) } const unified = checkDeclaredTabs(ctx({ pagespecs: new Map([ ['Client.detail.md', detailSpec(block)], ['Client.form.md', `# Client — form\n\n\`\`\`json\n{"entity":"Client","view":"form"}\n\`\`\`\n`], ]), })) expect(rules(unified, 'RTV-108')).toHaveLength(0) // 0 + 4 = 4 const direct = checkDeclaredTabs(ctx({ pagespecs: new Map([ ['Client.detail.md', detailSpec({ ...block, editExperience: 'direct' })], ['Client.form.md', `# Client — form\n\n\`\`\`json\n{"entity":"Client","view":"form"}\n\`\`\`\n`], ]), })) expect(rules(direct, 'RTV-108')).toHaveLength(1) // 4 + 4 = 8 → warn }) it('the synthetic « info » trigger counts (8 related alone render 9 triggers)', () => { const report = checkDeclaredTabs(ctx({ pagespecs: new Map([[ 'Client.detail.md', detailSpec({ relatedTabs: keys(8).map((k) => psTab(k)) }), ]]), })) const v = rules(report, 'RTV-108') expect(v).toHaveLength(1) expect(v[0].severity).toBe('warn') expect(v[0].message).toContain('synthetic « info » tab') }) it('the message names both remedies and forbids removing tabs', () => { const report = checkDeclaredTabs(ctx({ pagespecs: new Map([[ 'Client.detail.md', detailSpec({ tabs: [{ key: 'a' }, { key: 'b' }], relatedTabs: keys(6).map((k) => psTab(k)) }), ]]), })) const msg = rules(report, 'RTV-108')[0].message expect(msg).toContain('"placement": "band"') expect(msg).toContain('sections[]') expect(msg).toContain('Do NOT remove authored tabs (PRD-103)') }) }) // --------------------------------------------------------------------------- // Cross-application targets (RTV-109 + re-armed RTV-105/106/107) // --------------------------------------------------------------------------- /** The billing application: its own module, its own entity, its own list screen. */ const FACTURATION_FACTURES = `### ENT-010 — Invoice (agrégat racine) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Number | string(20) | requis | — | - **Relations** : Invoice *→1 Client — FK ClientId, scope cross-module (CRM/CLIENTS), onDelete restrict. ` /** CRM without any Invoice of its own — the entity lives in the other application. */ const CRM_CLIENTS_ONLY = `### ENT-001 — Client (agrégat racine) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Name | string(200) | requis | — | ` const FACTURATION_LIST_MD = `### SCR-FACTURATION-FACTURES-LIST-001 — Liste des factures (SmartListView) - **Entité** : Invoice (ENT-010) - **Permission** : \`facturation.factures.read\` ` const CROSS_APP_ENTITES = { 'CRM/CLIENTS': CRM_CLIENTS_ONLY, 'FACTURATION/FACTURES': FACTURATION_FACTURES, } function crossAppScreens(bullets: string): ParsedScreen[] { return screensOf( { md: detailWith(bullets) }, { md: FACTURATION_LIST_MD, app: 'FACTURATION', module: 'FACTURES', section: 'factures' }, ) } function detailPagespec(tabs: unknown[]): Map { return new Map([ ['Client.detail.md', '```json\n' + JSON.stringify({ entity: 'Client', relatedTabs: tabs }) + '\n```'], ]) } const CROSS_APP_TAB = { key: 'factures', displayMode: 'table', relatedEntity: 'Invoice', relationFk: 'clientId', relatedModule: 'factures', relatedSection: 'factures', targetScreen: 'SCR-FACTURATION-FACTURES-LIST-001', permission: 'facturation.factures.read', } const CROSS_APP_BULLET = '- **Onglet lié « Factures »** : entité Invoice, FK clientId → SCR-FACTURATION-FACTURES-LIST-001 (`facturation.factures.read`)\n' const CROSS_APP_RBAC = '| A | `facturation.factures.read` | — |' const CROSS_APP_BASE = { entites: CROSS_APP_ENTITES, rbac: CROSS_APP_RBAC, } describe('checkDeclaredTabs — cross-application targets', () => { const base = { ...CROSS_APP_BASE, screens: crossAppScreens(CROSS_APP_BULLET) } it('resolves a target screen living in ANOTHER application (RTV-003 passes)', () => { const report = checkDeclaredTabs(ctx(base)) expect(rules(report, 'RTV-003')).toHaveLength(0) expect(rules(report, 'RTV-001')).toHaveLength(0) }) it('accepts a permission declared in the OTHER application rbac.md (RTV-004 silent)', () => { const report = checkDeclaredTabs(ctx(base)) expect(rules(report, 'RTV-004')).toHaveLength(0) }) it('RTV-008: the canonical projection of a foreign target still parses', () => { const report = checkDeclaredTabs(ctx(base)) expect(rules(report, 'RTV-008')).toHaveLength(0) }) it('does NOT pull in a homonymous module of another application', () => { const screens = screensOf( { md: detailWith(CROSS_APP_BULLET) }, { md: FACTURATION_LIST_MD, app: 'FACTURATION', module: 'FACTURES', section: 'factures' }, { md: '### SCR-FACT-CLIENTS-DETAIL-001 — Fiche (SmartForm)\n- **Entité** : Ghost (ENT-999)\n- **Mode** : detail\n', app: 'FACTURATION', module: 'CLIENTS', section: 'clients', }, ) const report = checkDeclaredTabs(ctx({ ...base, screens })) expect(report.screens).toEqual(['SCR-CRM-CLIENTS-CLIENTS-DETAIL-001']) expect(rules(report, 'RTV-001')).toHaveLength(0) }) }) describe('checkDeclaredTabs — RTV-109 (a foreign target must declare its application)', () => { const base = { ...CROSS_APP_BASE, screens: crossAppScreens(CROSS_APP_BULLET) } it('errs when a tab targets another application without relatedApp', () => { const report = checkDeclaredTabs(ctx({ ...base, pagespecs: detailPagespec([CROSS_APP_TAB]) })) const v = rules(report, 'RTV-109') expect(v).toHaveLength(1) expect(v[0]).toMatchObject({ severity: 'err', tabKey: 'factures', source: 'pagespec' }) expect(v[0].message).toContain('facturation') expect(v[0].message).toContain('@/features/crm/factures') }) it('is silent once relatedApp is declared', () => { const report = checkDeclaredTabs( ctx({ ...base, pagespecs: detailPagespec([{ ...CROSS_APP_TAB, relatedApp: 'facturation' }]) }), ) expect(rules(report, 'RTV-109')).toHaveLength(0) }) it('errs when relatedApp contradicts where the entity actually lives', () => { const report = checkDeclaredTabs( ctx({ ...base, pagespecs: detailPagespec([{ ...CROSS_APP_TAB, relatedApp: 'logistique' }]) }), ) const v = rules(report, 'RTV-109') expect(v).toHaveLength(1) expect(v[0].message).toContain('contradicts') }) it('never fires for a target living in the page own application', () => { const report = checkDeclaredTabs( ctx({ screens: screensOf( { md: detailWith('- **Onglet lié « Factures »** : entité Invoice, FK clientId → SCR-CRM-CLIENTS-INVOICES-LIST-001\n') }, { md: INVOICES_LIST_MD, section: 'invoices' }, ), pagespecs: detailPagespec([ { ...CROSS_APP_TAB, relatedModule: 'clients', targetScreen: 'SCR-CRM-CLIENTS-INVOICES-LIST-001', permission: 'crm.invoices.read', }, ]), }), ) expect(rules(report, 'RTV-109')).toHaveLength(0) }) }) describe('checkDeclaredTabs — routing checks resolved in the TARGET module', () => { const base = { ...CROSS_APP_BASE, screens: crossAppScreens(CROSS_APP_BULLET) } const tab = { ...CROSS_APP_TAB, relatedApp: 'facturation' } const listOnly: Array<[string, string]> = [['Invoice.list.md', '```json\n{"section":"factures"}\n```']] it('RTV-106 now fires on a cross-application tab whose target has no create form', () => { const report = checkDeclaredTabs( ctx({ ...base, pagespecs: detailPagespec([{ ...tab, withCreate: true }]), pagespecsOf: () => new Map([...listOnly, ['Invoice.detail.md', '{}']]), }), ) const v = rules(report, 'RTV-106') expect(v).toHaveLength(1) expect(v[0].message).toContain('facturation/factures') }) it('RTV-107 now fires on a cross-application tab whose target has no detail page', () => { const report = checkDeclaredTabs( ctx({ ...base, pagespecs: detailPagespec([{ ...tab, withRowOpen: true }]), pagespecsOf: () => new Map(listOnly), }), ) expect(rules(report, 'RTV-107')).toHaveLength(1) }) it('stays silent when the target module really does carry the surfaces', () => { const report = checkDeclaredTabs( ctx({ ...base, pagespecs: detailPagespec([{ ...tab, withCreate: true, withRowOpen: true }]), pagespecsOf: () => new Map([...listOnly, ['Invoice.form.md', '{}'], ['Invoice.detail.md', '{}']]), }), ) expect(rules(report, 'RTV-106')).toHaveLength(0) expect(rules(report, 'RTV-107')).toHaveLength(0) expect(rules(report, 'RTV-105')).toHaveLength(0) }) it('concludes NOTHING when the target module is not authored on disk', () => { const report = checkDeclaredTabs( ctx({ ...base, pagespecs: detailPagespec([{ ...tab, withCreate: true, withRowOpen: true }]), pagespecsOf: () => null, }), ) expect(rules(report, 'RTV-106')).toHaveLength(0) expect(rules(report, 'RTV-107')).toHaveLength(0) expect(report.warnings.join(' ')).toContain('no pagespecs/ directory') }) it('RTV-105 fires when the foreign target carries a different route family', () => { const report = checkDeclaredTabs( ctx({ ...base, pagespecs: detailPagespec([tab]), pagespecsOf: () => new Map([['Invoice.list.md', '```json\n{"section":"factures","routeFamily":"invoices"}\n```']]), }), ) const v = rules(report, 'RTV-105') expect(v).toHaveLength(1) expect(v[0].message).toContain('invoices') }) })