/** * lot3-data-screen.test.ts — the data & screen kinds: upstream trace, catalogue * collisions, surface duplicates, allocation, impact decisions, verify. */ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { allocateEntity, allocateScreen } from '../allocate.js' import { loadScopeCorpus } from '../corpus.js' import { executeChangeImpact, executeVerify } from '../execute.js' import { existingAttributes, existingEntities, existingScreens } from '../existing.js' import { expectedPagespecNameFor } from '../impact.js' import { termForms, traceTerm } from '../trace.js' import { ChangeImpactInputSchema, type ChangeImpactInput } from '../types.js' import { validateSpec } from '../validate.js' import { SCR_DETAIL, SCR_LIST, UC_PREFIX, writeChangeCorpus } from './fixture.js' let tmp: string let root: string beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'ba-change-lot3-')) root = join(tmp, 'ba') }) afterEach(() => rmSync(tmp, { recursive: true, force: true })) const corpusOf = (section?: string) => loadScopeCorpus({ baRoot: root, app: 'CRM', module: 'PIPELINE', ...(section ? { section } : {}) }) const specOf = (over: Partial & { kind: ChangeImpactInput['kind'] }): ChangeImpactInput => ChangeImpactInputSchema.parse({ baRoot: root, app: 'CRM', module: 'PIPELINE', ...over }) function impact(over: Partial & { kind: ChangeImpactInput['kind'] }) { const spec = specOf(over) const v = validateSpec(spec) if (!v.valid || !v.scope) throw new Error(v.errors.join('; ')) return executeChangeImpact(spec, loadScopeCorpus(v.scope, spec.projectPath)) } function verify(over: Partial & { kind: ChangeImpactInput['kind'] }) { const spec = specOf({ mode: 'verify', ...over }) const v = validateSpec(spec) if (!v.valid || !v.scope) throw new Error(v.errors.join('; ')) return executeVerify(spec, loadScopeCorpus(v.scope)) } describe('trace — the upstream search', () => { it('splits PascalCase into every form, folds accents, matches whole words only', () => { expect(termForms('DueDate')).toEqual(expect.arrayContaining(['duedate', 'due date', 'due-date', 'due_date'])) expect(termForms('Amount')).toEqual(['amount']) expect(termForms('ProspectId')).toEqual(expect.arrayContaining(['prospectid', 'prospect'])) // a FK is the relation to the concept expect(termForms('Id')).toEqual([]) writeChangeCorpus(root) const c = corpusOf() expect(traceTerm(c, 'Discount').found).toBe(true) // BR-001 Expression expect(traceTerm(c, 'prospect').hits[0]!.file).toMatch(/use-case\.md$/) expect(traceTerm(c, 'Probability').found).toBe(false) expect(traceTerm(c, 'rospec').found).toBe(false) // never a substring }) }) describe('allocation — ENT module-wide, SCR per section', () => { it('ENT next = 003; SCR opportunites → 003, clients → 002; near-miss screen heading reserved', () => { writeChangeCorpus(root) expect(allocateEntity(corpusOf(), []).next).toBe('ENT-003') const scr = allocateScreen(corpusOf(), 'opportunites', []) expect(scr.canonicalPrefix).toBe('SCR-CRM-PIPELINE-OPPORTUNITES-') expect(scr.next).toBe('SCR-CRM-PIPELINE-OPPORTUNITES-003') expect(allocateScreen(corpusOf(), 'clients', []).next).toBe('SCR-CRM-PIPELINE-CLIENTS-002') const doc = join(root, 'CRM', 'PIPELINE', 'opportunites', 'screen.md') writeFileSync(doc, `${readFileSync(doc, 'utf8')}\n### SCR-CRM-PIPELINE-OPPORTUNITES-007 — Sans type\n- **Entité** : Opportunity (ENT-001)\n`, 'utf8') const again = allocateScreen(corpusOf(), 'opportunites', []) expect(again.reserved.some((r) => r.number === 7 && r.source === 'near-miss-heading')).toBe(true) expect(again.next).toBe('SCR-CRM-PIPELINE-OPPORTUNITES-008') }) }) describe('existing — attributes, entities, screens', () => { it('attribute same folded name → exact; count = the entity\'s attributes', () => { writeChangeCorpus(root) const r = existingAttributes(corpusOf(), 'Opportunity', 'amount', 'add') expect(r.count).toBe(4) expect(r.exact.map((m) => m.reason)).toEqual(['same-name']) expect(existingAttributes(corpusOf(), 'Opportunity', 'Client', 'add').similar.length).toBeGreaterThan(0) }) it('entity same name → exact; modify resolves by code or name', () => { writeChangeCorpus(root) expect(existingEntities(corpusOf(), { title: 'client' }, 'add').exact[0]!.code).toBe('ENT-002') expect(existingEntities(corpusOf(), { code: 'ENT-001' }, 'modify').resolved?.title).toBe('Opportunity') }) it('screen same (entity, type, mode) → same-surface; same title → same-title; a create form is a new surface', () => { writeChangeCorpus(root) const c = corpusOf('opportunites') expect(existingScreens(c, 'opportunites', { entity: 'Opportunity', screenType: 'SmartListView' }, 'add').exact[0]!.reason).toBe('same-surface') expect(existingScreens(c, 'opportunites', { entity: 'Opportunity', screenType: 'SmartForm', mode: 'detail' }, 'add').exact[0]!.code).toBe(SCR_DETAIL) expect(existingScreens(c, 'opportunites', { entity: 'Opportunity', screenType: 'SmartForm', mode: 'create' }, 'add').exact).toEqual([]) expect(existingScreens(c, 'opportunites', { title: 'liste des opportunites' }, 'add').exact[0]!.code).toBe(SCR_LIST) expect(existingScreens(c, 'opportunites', { code: SCR_LIST }, 'modify').resolved?.code).toBe(SCR_LIST) }) }) describe('attribute — impact and blockers', () => { it('a traced FK attribute: precondition skipped, lookup-grants step required, screens + pagespec deltas listed', () => { writeChangeCorpus(root) const r = impact({ kind: 'attribute', target: { entity: 'Opportunity', attribute: 'ProspectId', attributeType: 'Guid' } }) expect(r.blocked).toEqual([]) expect(r.trace?.found).toBe(true) expect(r.owner.writeDiscipline).toBe('entity-block-splice') expect(r.allocation).toBeNull() expect(r.impact[0]!.status).toBe('skipped') const fk = r.impact.find((s) => s.cli?.cliPath.includes('derive-lookup-grants'))! expect(fk.status).toBe('required') expect(fk.cli!.writes).toBe(true) const screens = r.impact.find((s) => s.gateRules.includes('SCR-017'))! expect((screens.data!['screens'] as unknown[]).length).toBe(2) const prd = r.impact.find((s) => s.phase === 'prd')! expect(prd.status).toBe('required') expect(prd.cli!.spec).toMatchObject({ entity: 'Opportunity', mode: 'check' }) }) it('no upstream trace → BLOCKED with the route to kind=use-case; unknown entity → entity-not-found', () => { writeChangeCorpus(root) const r = impact({ kind: 'attribute', target: { entity: 'Opportunity', attribute: 'Probability' } }) expect(r.blocked.map((b) => b.code)).toEqual(['upstream-trace-missing']) expect(r.blocked[0]!.routeTo).toEqual({ skill: 'ba-change', kind: 'use-case' }) expect(r.impact).toEqual([]) const e = impact({ kind: 'attribute', target: { entity: 'Invoice', attribute: 'Amount' } }) expect(e.blocked[0]!).toMatchObject({ code: 'entity-not-found', candidates: ['Opportunity', 'Client'] }) expect(validateSpec({ baRoot: root, app: 'CRM', module: 'PIPELINE', kind: 'attribute', target: { entity: 'Opportunity' } }).errors[0]).toMatch(/target\.attribute/) }) it('op=modify needs no trace and carries the destructive-migration warning', () => { writeChangeCorpus(root) const r = impact({ kind: 'attribute', op: 'modify', target: { entity: 'Opportunity', attribute: 'Amount' } }) expect(r.blocked).toEqual([]) expect(r.existing.resolved?.title).toMatch(/^Opportunity\.Amount/) expect(r.impact.some((s) => /DESTRUCTIVE migration/.test(s.title))).toBe(true) }) }) describe('entity — impact and blockers', () => { it('a traced new entity allocates ENT-003 and walks lookup grants → screens → pagespecs → nav resources', () => { writeChangeCorpus(root) const r = impact({ kind: 'entity', target: { entity: 'Prospect' } }) expect(r.blocked).toEqual([]) expect(r.allocation!.next).toBe('ENT-003') expect(r.impact[0]!.status).toBe('skipped') expect(r.impact.find((s) => s.phase === 'derive')!.cli!.cliPath).toContain('derive-lookup-grants') expect(r.impact.some((s) => s.cli?.cliPath.includes('derive-nav-resources') && s.status === 'required')).toBe(true) expect(r.impact.some((s) => s.cli?.cliPath.includes('derive-related-tabs') && s.cli.spec['mode'] === 'derive')).toBe(true) }) it('a Core entity, a reserved name and a traceless concept are refused; a capability is a warning', () => { writeChangeCorpus(root) // « User » never appears in the fixture text either — both blockers fire, the Core one is what the user must read. const core = impact({ kind: 'entity', target: { entity: 'User' } }) expect(core.blocked.map((b) => b.code)).toContain('core-entity-collision') const reserved = impact({ kind: 'entity', target: { entity: 'Notification' } }) expect(reserved.blocked.some((b) => b.code === 'core-reserved-name' && /use instead/i.test(b.reason))).toBe(true) const none = impact({ kind: 'entity', target: { entity: 'Warehouse' } }) expect(none.blocked.map((b) => b.code)).toEqual(['upstream-trace-missing']) expect(validateSpec({ baRoot: root, app: 'CRM', module: 'PIPELINE', kind: 'entity' }).errors[0]).toMatch(/target\.entity/) }) it('no pagespecs → the PRD, nav-resource and rule-link steps are skipped with a reason', () => { writeChangeCorpus(root, { withPagespecs: false }) const r = impact({ kind: 'entity', target: { entity: 'Prospect' } }) for (const s of r.impact.filter((x) => x.phase === 'prd' || x.cli?.cliPath.includes('derive-nav-resources') || x.cli?.cliPath.includes('derive-rule-links'))) { expect(s.status).toBe('skipped') expect(s.skipReason).toBeTruthy() } }) }) describe('screen — impact and blockers', () => { it('a complete create form: precondition skipped, SCR-003 allocated, the ONE expected pagespec named, related-tabs validate required', () => { writeChangeCorpus(root) const r = impact({ kind: 'screen', section: 'opportunites', target: { entity: 'Opportunity', screenType: 'SmartForm', mode: 'create', permissionPath: 'pipeline.opportunites.create', useCase: `${UC_PREFIX}001`, title: 'Nouvelle opportunité' }, }) expect(r.blocked).toEqual([]) expect(r.allocation!.next).toBe('SCR-CRM-PIPELINE-OPPORTUNITES-003') expect(r.impact[0]!.status).toBe('skipped') const prd = r.impact.find((s) => s.phase === 'prd')! expect(prd.data).toMatchObject({ expectedPagespec: 'Opportunity.form.md', exists: false }) expect(r.impact.find((s) => s.cli?.cliPath.includes('derive-related-tabs'))!.status).toBe('required') }) it('a floor path is an accepted permission; a kanban folds into the list pagespec with derive-kanban-spec', () => { writeChangeCorpus(root) const r = impact({ kind: 'screen', section: 'opportunites', target: { entity: 'Opportunity', screenType: 'SmartKanban', permissionPath: 'pipeline.opportunites.access', useCase: `${UC_PREFIX}002` } }) expect(r.blocked).toEqual([]) const prd = r.impact.find((s) => s.phase === 'prd')! expect(prd.title).toMatch(/folds into the LIST pagespec/) expect(prd.cli!.cliPath).toContain('derive-kanban-spec') expect(prd.cli!.writes).toBe(true) expect(expectedPagespecNameFor('SmartKanban', 'Opportunity', undefined)).toBeNull() }) it('an identical surface is reported; unknown entity / permission / UC / type are blocked with routes', () => { writeChangeCorpus(root) const twin = impact({ kind: 'screen', section: 'opportunites', target: { entity: 'Opportunity', screenType: 'SmartListView' } }) expect(twin.existing.exact[0]!.reason).toBe('same-surface') expect(impact({ kind: 'screen', section: 'opportunites', target: { entity: 'Invoice' } }).blocked[0]!.code).toBe('entity-not-found') expect(impact({ kind: 'screen', section: 'opportunites', target: { permissionPath: 'pipeline.opportunites.approve.all' } }).blocked[0]!.code).toBe('permission-not-found') expect(impact({ kind: 'screen', section: 'opportunites', target: { useCase: `${UC_PREFIX}099` } }).blocked[0]!.code).toBe('use-case-not-found') expect(impact({ kind: 'screen', section: 'opportunites', target: { screenType: 'SmartGrid' } }).blocked[0]!.code).toBe('screen-type-unknown') expect(impact({ kind: 'screen' }).blocked[0]!.code).toBe('section-required') }) }) describe('verify — attribute, entity, screen', () => { const entityPath = () => join(root, 'CRM', 'PIPELINE', 'entité.md') it('an attribute row spliced into the entity table → found, delta +1; a refused row is lost', () => { writeChangeCorpus(root) const base = impact({ kind: 'attribute', target: { entity: 'Opportunity', attribute: 'Discount' } }) const text = readFileSync(entityPath(), 'utf8').replace('| ClientId | Guid | requis | — |\n', '| ClientId | Guid | requis | — |\n| Discount | decimal(5,2) | ≥ 0 | — |\n') writeFileSync(entityPath(), text, 'utf8') const ok = verify({ kind: 'attribute', target: { entity: 'Opportunity', attribute: 'Discount' }, verify: { expectCode: 'Discount', baselineCount: base.existing.count } }) expect(ok.verify).toMatchObject({ found: true, count: 5, delta: 1, lost: [], sourcesCited: 'not-required', ok: true }) writeFileSync(entityPath(), text.replace('| Discount | decimal(5,2) | ≥ 0 | — |', '| Dis count | decimal(5,2) | ≥ 0 | — |'), 'utf8') const ko = verify({ kind: 'attribute', target: { entity: 'Opportunity', attribute: 'Discount' }, verify: { expectCode: 'Discount', baselineCount: base.existing.count } }) expect(ko.verify!.found).toBe(false) expect(ko.verify!.lost.length).toBeGreaterThan(0) expect(ko.verify!.ok).toBe(false) }) it('an entity block appended → found ENT-003, delta +1; a near-miss heading is lost', () => { writeChangeCorpus(root) const base = impact({ kind: 'entity', target: { entity: 'Prospect' } }) const block = '\n### ENT-003 — Prospect (agrégat racine)\n- **Préfixe table** : `pipeline_`\n- **Portée** : strict — données isolées par tenant\n- **Traçabilité** : UC-CRM-PIPELINE-opportunites-001\n\n| Attribut | Type | Contraintes | Calculé |\n|----------|------|-------------|---------|\n| Id | Guid | PK | — |\n| Name | string(200) | requis | — |\n\n- **Affichage** : Name\n' writeFileSync(entityPath(), `${readFileSync(entityPath(), 'utf8')}${block}`, 'utf8') const ok = verify({ kind: 'entity', target: { entity: 'Prospect' }, verify: { expectCode: 'ENT-003', baselineCount: base.existing.count } }) expect(ok.verify).toMatchObject({ found: true, count: 3, delta: 1, lost: [], ok: true }) writeFileSync(entityPath(), `${readFileSync(entityPath(), 'utf8')}\n### ent-004 Lead\n`, 'utf8') const ko = verify({ kind: 'entity', target: { entity: 'Prospect' }, verify: { expectCode: 'ENT-003', baselineCount: base.existing.count } }) expect(ko.verify!.lost.some((l) => /near-miss/.test(l))).toBe(true) expect(ko.verify!.ok).toBe(false) }) it('a screen block appended → found, delta +1; a heading without its (SmartType) suffix is lost', () => { writeChangeCorpus(root) const base = impact({ kind: 'screen', section: 'opportunites', target: { entity: 'Opportunity', screenType: 'SmartForm', mode: 'create' } }) const doc = join(root, 'CRM', 'PIPELINE', 'opportunites', 'screen.md') const block = `\n### SCR-CRM-PIPELINE-OPPORTUNITES-003 — Nouvelle opportunité (SmartForm)\n- **Entité** : Opportunity (ENT-001)\n- **Permission** : \`pipeline.opportunites.create\`\n- **Mode** : create\n- **Cas d'usage liés** : ${UC_PREFIX}001\n- **Champs** : Amount, Stage, Client\n` writeFileSync(doc, `${readFileSync(doc, 'utf8')}${block}`, 'utf8') const ok = verify({ kind: 'screen', section: 'opportunites', verify: { expectCode: 'SCR-CRM-PIPELINE-OPPORTUNITES-003', baselineCount: base.existing.count } }) expect(ok.verify).toMatchObject({ found: true, count: 3, delta: 1, lost: [], ok: true }) writeFileSync(doc, `${readFileSync(doc, 'utf8')}\n### SCR-CRM-PIPELINE-OPPORTUNITES-004 — Sans type\n- **Entité** : Opportunity (ENT-001)\n`, 'utf8') const ko = verify({ kind: 'screen', section: 'opportunites', verify: { expectCode: 'SCR-CRM-PIPELINE-OPPORTUNITES-003', baselineCount: base.existing.count } }) expect(ko.verify!.lost.some((l) => /near-miss heading/.test(l))).toBe(true) expect(ko.verify!.ok).toBe(false) }) })