/** * lib/slot-names ↔ scaffold-component renderers ↔ scaffold-extension-config — * one page per view is rendered with the REAL generator and its * `` calls must equal the SSOT table; the catalogue the * extension-config emits must be a subset of what the pages render (it was * DISJOINT before 2026-09-24 — audit B-CRIT-05). * * Lot 14 (2026-09-24): the renderers no longer carry literal `${eLower}.…` * names — every site goes through `slotName.(eLower, suffix)`. The * rendered-page assertions below stay the independent oracle; the * source-level block on top of them FIRES the day a literal comes back or a * site names a suffix outside its view's SSOT row. */ import { describe, it, expect } from 'vitest' import { readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { SLOT_SUFFIXES_BY_VIEW, entityLowerOf, slotName, slotNamesFor, type SlotView } from '../slot-names.js' import { generate } from '../../development/frontend/component/cli/scaffold-component/generate.js' import { validate } from '../../development/frontend/component/cli/scaffold-component/validate.js' import { generate as generateExtensionConfig } from '../../development/frontend/extension-config/cli/scaffold-extension-config/generate.js' const ENTITY = 'JourFerie' /** scaffold-component view name → SSOT view key → file-name suffix of the emitted page. */ const CASES: Array<{ specView: string; view: SlotView; file: RegExp }> = [ { specView: 'list', view: 'list', file: /ListPage\.tsx$/ }, { specView: 'detail', view: 'detail', file: /DetailPage\.tsx$/ }, { specView: 'form', view: 'form', file: /FormPage\.tsx$/ }, { specView: 'module-home', view: 'home', file: /ModuleHomePage\.tsx$/ }, { specView: 'dashboard', view: 'dashboard', file: /DashboardPage\.tsx$/ }, { specView: 'reconduction', view: 'reconduction', file: /ReconductionPage\.tsx$/ }, ] function renderedSlots(specView: string, file: RegExp): string[] { const v = validate({ module: 'rh', appCode: 'myapp', entity: ENTITY, section: 'jours-feries', views: [specView], fields: [{ name: 'label', type: 'string' }, { name: 'date', type: 'date' }], projectPath: '/web', }) expect(v.valid, v.errors.join('\n')).toBe(true) const page = generate(v.data!).find((f) => file.test(f.path)) expect(page, `no page matching ${file} for view ${specView}`).toBeDefined() return [...page!.content.matchAll(/ m[1]).sort() } describe('slot names — SSOT ↔ renderers', () => { for (const c of CASES) { it(`${c.view} page renders exactly slotNamesFor(entity, '${c.view}')`, () => { expect(renderedSlots(c.specView, c.file)).toEqual([...slotNamesFor(ENTITY, c.view)].sort()) }) } it('entityLowerOf mirrors the renderers (first letter only)', () => { expect(entityLowerOf('JourFerie')).toBe('jourFerie') expect(entityLowerOf('Employee')).toBe('employee') }) }) describe('slot names — SSOT ↔ extension-config catalogue', () => { it('every catalogued slot is rendered by a default-view page (list/detail/form)', () => { const [file] = generateExtensionConfig({ module: 'rh', appCode: 'myapp', entities: [ENTITY], projectPath: '/web' }) const catalogued = [...file.content.matchAll(/^\s+'([^']+)': null,$/gm)].map((m) => m[1]).sort() const rendered = new Set([...renderedSlots('list', /ListPage\.tsx$/), ...renderedSlots('detail', /DetailPage\.tsx$/), ...renderedSlots('form', /FormPage\.tsx$/)]) expect(catalogued.length).toBeGreaterThan(0) for (const name of catalogued) expect(rendered.has(name), `${name} is catalogued but never rendered`).toBe(true) expect(catalogued).toEqual([...rendered].sort()) }) it('the SSOT table lists every view kind the renderers know', () => { expect(Object.keys(SLOT_SUFFIXES_BY_VIEW).sort()).toEqual(['dashboard', 'detail', 'form', 'home', 'list', 'reconduction']) }) }) // ── Lot 14: the renderers CONSUME the SSOT (source-level, on top of the rendered oracle) ── const RENDER_DIR = join(dirname(fileURLToPath(import.meta.url)), '../../development/frontend/component/cli/scaffold-component/render') /** SSOT view key → the renderer file that emits it. */ const RENDERER_OF: Record = { list: 'list.ts', detail: 'detail.ts', form: 'form.ts', home: 'home.ts', dashboard: 'dashboard.ts', reconduction: 'reconduction.ts', } const ACCESSOR_SITE = / { for (const view of Object.keys(SLOT_SUFFIXES_BY_VIEW) as SlotView[]) { it(`render/${RENDERER_OF[view]} imports slotName and names every ${view} slot through slotName.${view}()`, () => { const src = readFileSync(join(RENDER_DIR, RENDERER_OF[view]), 'utf-8') expect(src).toMatch(/^import \{ slotName \} from '(?:\.\.\/){6}lib\/slot-names\.js'$/m) // FIRES on the pre-lot-14 shape: ` m[1]))]).toEqual([view]) // …and the suffixes it names are exactly the SSOT row (a site dropped or invented here is a drift). expect([...new Set(sites.map((m) => m[2]))].sort()).toEqual([...SLOT_SUFFIXES_BY_VIEW[view]].sort()) }) } }) describe('slot names — accessor', () => { it('slotName.(eLower, suffix) returns the exact rendered name', () => { expect(slotName.list('jourFerie', 'header.actions')).toBe('jourFerie.header.actions') expect(slotName.home('jourFerie', 'home.kpis.after')).toBe('jourFerie.home.kpis.after') expect(slotName.form('status', 'form.fields.before')).toBe('status.form.fields.before') }) it('agrees with slotNamesFor for every view (one join formula)', () => { for (const view of Object.keys(SLOT_SUFFIXES_BY_VIEW) as SlotView[]) { const namer = slotName[view] as (entityLower: string, suffix: string) => string expect(SLOT_SUFFIXES_BY_VIEW[view].map((s) => namer('jourFerie', s))).toEqual(slotNamesFor('JourFerie', view)) } }) it('FIRES: a suffix the view does not render is refused at run time (tsx runs untyped)', () => { expect(() => slotName.list('jourFerie', 'detail.header' as never)).toThrow(/not a slot of the list view/) expect(() => slotName.home('jourFerie', 'kpis' as never)).toThrow(/not a slot of the home view/) }) })