/** * derive.test.ts — mode=derive candidate computation. */ import { describe, it, expect } from 'vitest' import { parseEntities } from '../relations.js' import { parseScreenFile, type ParsedScreen } from '../screens.js' import { parseRbacPermissions } from '../rbac.js' import { deriveCandidates, type DeriveContext } from '../derive.js' import { DeriveRelatedTabsInputSchema } from '../types.js' // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- const CRM_CLIENTS = ` ### ENT-001 — Client (agrégat racine) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Code | string(20) | requis | — | | Name | string(200) | requis | — | | Email | string(200) | — | — | | Phone | string(50) | — | — | - **Relations** : Client *→1 Sector — FK SectorId, scope same-module, onDelete restrict. Client *→1 User — FK OwnerUserId, scope core (auth_Users), onDelete restrict. ### ENT-002 — Invoice (agrégat racine) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Number | string(20) | requis | — | | Amount | decimal(18,2) | ≥ 0 | — | | IssuedAt | date | requis | — | | Status | enum | DRAFT/SENT/PAID | — | - **Relations** : Invoice *→1 Client — FK ClientId, scope same-module, onDelete restrict. ### ENT-003 — Address (composant) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Street | string(200) | requis | — | | Zip | string(10) | requis | — | | City | string(100) | requis | — | - **Relations** : Address *→1 Client — FK ClientId, scope same-module, onDelete cascade. ### ENT-004 — ClientProfile (composant) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Notes | string(2000) | — | — | - **Relations** : ClientProfile 1→1 Client — FK ClientId, scope same-module, onDelete cascade. ### ENT-005 — ClientTag (jonction) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | - **Relations** : ClientTag *→1 Client — FK ClientId, scope same-module, onDelete cascade. ClientTag *→1 Tag — FK TagId, scope same-module, onDelete cascade. ### ENT-006 — Tag (lookup) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Label | string(100) | requis | — | ### ENT-007 — Sector (lookup) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Label | string(100) | requis | — | ` const SALES_ORDERS = `### ENT-001 — Order (agrégat racine) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | - **Relations** : Order *→1 Client — FK ClientId, scope cross-module (CRM/CLIENTS), onDelete restrict. ` const DETAIL_SCREEN_MD = `### SCR-CRM-CLIENTS-CLIENTS-DETAIL-001 — Fiche client (SmartForm) - **Entité** : Client (ENT-001) - **Permission** : \`crm.clients.read\` - **Mode** : detail ` const DETAIL_WITH_TAB_MD = `### SCR-CRM-CLIENTS-CLIENTS-DETAIL-001 — Fiche client (SmartForm) - **Entité** : Client (ENT-001) - **Mode** : detail - **Onglet lié « Factures »** : entité Invoice, FK clientId → SCR-CRM-CLIENTS-INVOICES-LIST-001 (\`crm.invoices.read\`) ` const INVOICES_LIST_MD = `### SCR-CRM-CLIENTS-INVOICES-LIST-001 — Liste des factures (SmartListView) - **Entité** : Invoice (ENT-002) - **Permission** : \`crm.invoices.read\` ` const INVOICES_LIST_NO_PERM_MD = `### SCR-CRM-CLIENTS-INVOICES-LIST-001 — Liste des factures (SmartListView) - **Entité** : Invoice (ENT-002) ` /** Keeps Invoice NON-inert in the default ctx: a creatable satellite stays on * the attribute heuristic (table), never the inert-satellite summary. */ const INVOICES_CREATE_MD = `### SCR-CRM-CLIENTS-INVOICES-CREATE-001 — Nouvelle facture (SmartForm) - **Entité** : Invoice (ENT-002) - **Mode** : create ` function screensOf(...parts: Array<{ md: string; module?: string; section?: string }>): ParsedScreen[] { return parts.flatMap((p) => { const module = p.module ?? 'CLIENTS' const section = p.section ?? 'clients' return parseScreenFile(p.md, { file: `CRM/${module}/${section}/screen.md`, module, section, }).screens }) } function ctx(overrides: { entites?: Record screens?: ParsedScreen[] rbac?: string spec?: Record }): DeriveContext { return { spec: DeriveRelatedTabsInputSchema.parse({ baRoot: '.smartstack/ba', app: 'CRM', module: 'CLIENTS', ...overrides.spec, }), graph: parseEntities(new Map(Object.entries(overrides.entites ?? { 'CRM/CLIENTS': CRM_CLIENTS }))), screens: overrides.screens ?? screensOf( { md: DETAIL_SCREEN_MD }, { md: INVOICES_LIST_MD, section: 'invoices' }, { md: INVOICES_CREATE_MD, section: 'invoices' }, ), rbac: parseRbacPermissions(overrides.rbac ?? '| A | `crm.clients.read` | — |\n| A | `crm.invoices.read` | — |'), } } function candidateFor(report: ReturnType, relatedEntity: string, kind?: string) { return report.candidates.find( (c) => c.relatedEntity === relatedEntity && (kind === undefined || c.relationKind === kind), ) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('deriveCandidates — incoming', () => { it('emits a fully-resolved incoming candidate (target + permission from the list screen)', () => { const report = deriveCandidates(ctx({})) const invoice = candidateFor(report, 'Invoice', 'incoming-1n')! expect(invoice).toMatchObject({ forEntity: 'Client', forScreens: ['SCR-CRM-CLIENTS-CLIENTS-DETAIL-001'], relatedModule: 'CLIENTS', relationFk: 'clientId', suggestedKey: 'invoices', defaultInclude: true, alreadyDeclared: false, }) expect(invoice.screenTarget).toMatchObject({ resolved: true, code: 'SCR-CRM-CLIENTS-INVOICES-LIST-001', screenType: 'SmartListView', }) expect(invoice.permission).toEqual({ resolved: true, value: 'crm.invoices.read', source: 'target-screen', }) }) it('suggests table for a CREATABLE related entity with ≥5 attributes / status enum', () => { // Invoice has a create SmartForm in the default ctx — the inert-satellite // rule stays out of the way and the attribute heuristic keeps the call. const report = deriveCandidates(ctx({})) const invoice = candidateFor(report, 'Invoice')! expect(invoice.suggestedDisplayMode).toBe('table') expect(invoice.displayModeReason).not.toBe('') }) it('suggests summary for an INERT satellite (list resolved, no SmartForm anywhere)', () => { const report = deriveCandidates( ctx({ screens: screensOf({ md: DETAIL_SCREEN_MD }, { md: INVOICES_LIST_MD, section: 'invoices' }) }), ) const invoice = candidateFor(report, 'Invoice', 'incoming-1n')! expect(invoice.suggestedDisplayMode).toBe('summary') expect(invoice.displayModeReason).toContain('sans création ni fiche') // Suggestion only — inclusion is untouched. expect(invoice.defaultInclude).toBe(true) }) it('a detail/edit SmartForm bound to the satellite also keeps the attribute heuristic', () => { const invoiceDetail = `### SCR-CRM-CLIENTS-INVOICES-DETAIL-001 — Fiche facture (SmartForm) - **Entité** : Invoice (ENT-002) - **Mode** : detail ` const report = deriveCandidates( ctx({ screens: screensOf( { md: DETAIL_SCREEN_MD }, { md: INVOICES_LIST_MD, section: 'invoices' }, { md: invoiceDetail, section: 'invoices' }, ) }), ) expect(candidateFor(report, 'Invoice', 'incoming-1n')!.suggestedDisplayMode).toBe('table') }) it('suggests cards for an address-like composant', () => { // Address's list target does NOT resolve — an unscanned/unauthored surface // never reads as inert; the attribute heuristic keeps the call (cards). const report = deriveCandidates(ctx({})) const address = candidateFor(report, 'Address', 'incoming-1n')! expect(address.suggestedDisplayMode).toBe('cards') expect(address.displayModeReason).toContain('≤4') }) it('suggests summary for an incoming 1→1 relation', () => { const report = deriveCandidates(ctx({})) const profile = candidateFor(report, 'ClientProfile', 'incoming-1n')! expect(profile.suggestedDisplayMode).toBe('summary') expect(profile.displayModeReason).toContain('1→1') }) it('warns when no list screen resolves for a candidate', () => { const report = deriveCandidates(ctx({})) const address = candidateFor(report, 'Address')! expect(address.screenTarget.resolved).toBe(false) expect(report.warnings.some((w) => w.includes('Address'))).toBe(true) }) it('marks a candidate alreadyDeclared when the screen declares a matching bullet', () => { const report = deriveCandidates( ctx({ screens: screensOf({ md: DETAIL_WITH_TAB_MD }, { md: INVOICES_LIST_MD, section: 'invoices' }) }), ) expect(candidateFor(report, 'Invoice')!.alreadyDeclared).toBe(true) expect(candidateFor(report, 'Address')!.alreadyDeclared).toBe(false) }) it('resolves the permission from rbac.md when the target screen has no Permission bullet', () => { const report = deriveCandidates( ctx({ screens: screensOf({ md: DETAIL_SCREEN_MD }, { md: INVOICES_LIST_NO_PERM_MD, section: 'invoices' }), rbac: '| A | `crm.clients.invoices.read` | — |', }), ) expect(candidateFor(report, 'Invoice')!.permission).toEqual({ resolved: true, value: 'crm.clients.invoices.read', source: 'rbac', }) }) it('leaves the permission unresolved when neither source matches', () => { const report = deriveCandidates(ctx({ rbac: '| A | `x.y.z` | — |' })) expect(candidateFor(report, 'Address')!.permission).toEqual({ resolved: false }) }) }) describe('deriveCandidates — cross-module & core', () => { it('includes cross-module incoming candidates with the APP/MOD module label', () => { const report = deriveCandidates( ctx({ entites: { 'CRM/CLIENTS': CRM_CLIENTS, 'SALES/ORDERS': SALES_ORDERS } }), ) const order = candidateFor(report, 'Order', 'incoming-1n')! expect(order.relatedModule).toBe('SALES/ORDERS') expect(order.relationFk).toBe('clientId') }) it('filters cross-module candidates when includeCrossModule is false', () => { const report = deriveCandidates( ctx({ entites: { 'CRM/CLIENTS': CRM_CLIENTS, 'SALES/ORDERS': SALES_ORDERS }, spec: { includeCrossModule: false }, }), ) expect(candidateFor(report, 'Order')).toBeUndefined() }) it('never emits a candidate from a scope core relation', () => { const report = deriveCandidates(ctx({})) expect(candidateFor(report, 'User')).toBeUndefined() }) }) describe('deriveCandidates — outgoing', () => { it('emits the entity\'s own FK as an excluded-by-default summary candidate', () => { const report = deriveCandidates(ctx({})) const sector = candidateFor(report, 'Sector', 'outgoing-n1')! expect(sector).toMatchObject({ relationFk: 'sectorId', defaultInclude: false, suggestedDisplayMode: 'summary', }) expect(sector.displayModeReason).toContain('lookup field already covers') }) }) describe('deriveCandidates — junction (nm-via)', () => { it('surfaces the far side of a junction, never the junction itself', () => { const report = deriveCandidates(ctx({})) const tag = candidateFor(report, 'Tag', 'nm-via')! expect(tag).toMatchObject({ viaEntity: 'ClientTag', relationFk: 'clientId', suggestedKey: 'tags', }) expect(tag.displayModeReason).toContain('N:M via junction ClientTag') expect(candidateFor(report, 'ClientTag')).toBeUndefined() }) it('the inert-satellite rule never applies to nm-via (the association list IS the point)', () => { // Tag has no SmartForm anywhere, yet the far side of a junction keeps the // attribute heuristic (lookup ≤4 attrs → cards): the tab exists to SHOW // which records are associated, a count would hide exactly that. const report = deriveCandidates(ctx({})) expect(candidateFor(report, 'Tag', 'nm-via')!.suggestedDisplayMode).toBe('cards') }) }) describe('deriveCandidates — scoping', () => { it('emits no candidate for a create-mode form', () => { const createOnly = `### SCR-CRM-CLIENTS-CLIENTS-CREATE-001 — Nouveau client (SmartForm) - **Entité** : Client (ENT-001) - **Mode** : create ` const report = deriveCandidates(ctx({ screens: screensOf({ md: createOnly }) })) expect(report.totals.screens).toBe(0) expect(report.candidates).toHaveLength(0) }) it('honors the entity filter', () => { const report = deriveCandidates(ctx({ spec: { entity: 'Invoice' } })) expect(report.totals.screens).toBe(0) // the only detail screen is bound to Client expect(report.candidates).toHaveLength(0) }) it('honors the section filter', () => { const report = deriveCandidates(ctx({ spec: { section: 'other-section' } })) expect(report.totals.screens).toBe(0) }) it('warns when the screen entity is missing from entité.md', () => { const ghost = `### SCR-X-001 — Fiche fantôme (SmartForm) - **Entité** : Ghost - **Mode** : detail ` const report = deriveCandidates(ctx({ screens: screensOf({ md: ghost }) })) expect(report.candidates).toHaveLength(0) expect(report.warnings.some((w) => w.includes('Ghost'))).toBe(true) }) }) // --------------------------------------------------------------------------- // Cross-application candidates — relatedApp seeding // --------------------------------------------------------------------------- describe('deriveCandidates — cross-application relations', () => { /** Client lives in CRM; the invoice that points at it lives in FACTURATION. */ const CRM_ONLY = `### ENT-001 — Client (agrégat racine) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Name | string(200) | requis | — | ` const FACTURATION = `### 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. ` const FOREIGN_LIST = `### SCR-FACTURATION-FACTURES-LIST-001 — Factures (SmartListView) - **Entité** : Invoice (ENT-010) - **Permission** : \`facturation.factures.read\` ` function crossAppCtx(): ReturnType { const own = screensOf({ md: DETAIL_SCREEN_MD }) const foreign = parseScreenFile(FOREIGN_LIST, { file: 'FACTURATION/FACTURES/factures/screen.md', app: 'FACTURATION', module: 'FACTURES', section: 'factures', }).screens return ctx({ entites: { 'CRM/CLIENTS': CRM_ONLY, 'FACTURATION/FACTURES': FACTURATION }, screens: [...own, ...foreign], rbac: '| A | `facturation.factures.read` | — |', }) } it('seeds relatedApp from the APP/MOD path the relation itself carries', () => { const report = deriveCandidates(crossAppCtx()) const invoice = report.candidates.find((c) => c.relatedEntity === 'Invoice') expect(invoice).toBeDefined() expect(invoice?.relatedApp).toBe('facturation') expect(invoice?.relatedModule).toBe('FACTURATION/FACTURES') }) it('resolves the target list screen in the OTHER application', () => { const report = deriveCandidates(crossAppCtx()) const invoice = report.candidates.find((c) => c.relatedEntity === 'Invoice') expect(invoice?.screenTarget.resolved).toBe(true) expect(invoice?.screenTarget.code).toBe('SCR-FACTURATION-FACTURES-LIST-001') }) it('never seeds relatedApp for a same-application candidate', () => { const report = deriveCandidates(ctx({})) const invoice = report.candidates.find((c) => c.relatedEntity === 'Invoice') expect(invoice).toBeDefined() expect(invoice?.relatedApp).toBeUndefined() }) })