diff --git a/configs/camunda-oca/regression-invariants.test.ts b/configs/camunda-oca/regression-invariants.test.ts index 36adc11..8e76f75 100644 --- a/configs/camunda-oca/regression-invariants.test.ts +++ b/configs/camunda-oca/regression-invariants.test.ts @@ -54,6 +54,29 @@ const VARIANT_SCENARIOS_DIR = getVariantOutputDir(REPO_ROOT); const GENERATED_TESTS_DIR = getPlaywrightSuiteDir(REPO_ROOT); const BUNDLED_SPEC_PATH = join(getSpecBundleDir(REPO_ROOT), 'rest-api.bundle.json'); +// #331: opIds whose per-endpoint feature spec is intentionally omitted +// because a scenario-template instantiation already encodes the +// canonical functional test for that operation. Materializer writes +// the coverage artefact alongside the suite. Loaded lazily — feature- +// presence invariants subtract this set; the lifecycle specs under +// `edges/`, `entities/`, `runtime-entities/`, and `state-transitions/` +// are the regression guard for the suppressed operations. +let _suppressedOpIdsCache: Set | undefined; +function loadSuppressedOpIds(): Set { + if (_suppressedOpIdsCache) return _suppressedOpIdsCache; + const coveragePath = join(GENERATED_TESTS_DIR, 'coverage.json'); + if (!existsSync(coveragePath)) { + _suppressedOpIdsCache = new Set(); + return _suppressedOpIdsCache; + } + // biome-ignore lint/plugin: runtime contract boundary — materializer-emitted coverage artefact; only `suppressedOpIds: string[]` is read. + const parsed = JSON.parse(readFileSync(coveragePath, 'utf8')) as { + suppressedOpIds?: string[]; + }; + _suppressedOpIdsCache = new Set(parsed.suppressedOpIds ?? []); + return _suppressedOpIdsCache; +} + // --------------------------------------------------------------------------- // Bundled-spec helpers (shared between #326 and #247 invariants). // @@ -2037,6 +2060,10 @@ describeForThisConfig('bundled-spec invariants: emitted Playwright suite', () => let totalExpected = 0; let totalActual = 0; let suitesWithEc = 0; + // #331: suppressed opIds have no feature spec to wrap — the + // equivalent eventually-consistent reads inside their lifecycle + // spec are guarded by the template emitter's own wrap logic. + const suppressed = loadSuppressedOpIds(); for (const f of readdirSync(FEATURE_SCENARIOS_DIR)) { if (!f.endsWith('-scenarios.json')) continue; @@ -2044,6 +2071,7 @@ describeForThisConfig('bundled-spec invariants: emitted Playwright suite', () => // biome-ignore lint/plugin: parsed JSON is a runtime contract boundary; shape locally typed as CollectionLite const coll = JSON.parse(raw) as CollectionLite; if (!coll || typeof coll !== 'object') continue; + if (suppressed.has(coll.endpoint?.operationId)) continue; const expected = expectedWraps(coll); if (expected === 0) continue; suitesWithEc++; @@ -2277,7 +2305,7 @@ describeForThisConfig('bundled-spec invariants: fixture selection by required st ); }); - it('getIncident.feature.spec.ts deploys bpmn/incident-script-task.bpmn and chains searchIncidents → getIncident (#305 Phase 5a)', () => { + it('Incident.resolveIncident lifecycle spec deploys bpmn/incident-script-task.bpmn and chains searchIncidents → getIncident (#305 Phase 5a)', () => { // Phase 5a promoted IncidentKey serverEmergent → runtimeEmission, gated // by the new ModelEmitsIncident capability. The selector must pick the // new incident-script-task fixture (the only one declaring @@ -2291,30 +2319,42 @@ describeForThisConfig('bundled-spec invariants: fixture selection by required st // IncidentKey (synthetic seed only), the emitted spec would have a // single getIncident step seeded with `seedBinding('incidentKeyVar')`, // and the test would be a no-op against any real broker. - const spec = join(GENERATED_TESTS_DIR, 'getIncident.feature.spec.ts'); + // + // #331: getIncident's per-endpoint feature spec is now suppressed + // because `Incident.resolveIncident` (StateTransitionVisibleAfterAction + // template) is the canonical runtime-discovery test. The same chain + // assertions hold against the lifecycle spec — the production behaviour + // is unchanged, only the spec file moved. + const spec = join( + GENERATED_TESTS_DIR, + 'state-transitions', + 'Incident.resolveIncident.lifecycle.spec.ts', + ); if (!existsSync(spec)) { throw new Error(`expected emitted spec ${spec} not found — run 'npm run testsuite:generate'`); } const src = readFileSync(spec, 'utf8'); - expect(src, 'getIncident must deploy the incident-emitting fixture').toContain( + expect(src, 'Incident.resolveIncident must deploy the incident-emitting fixture').toContain( '@@FILE:bpmn/incident-script-task.bpmn', ); - expect(src, 'getIncident must include searchIncidents discovery step').toContain( - "test.step('searchIncidents'", - ); - expect(src, 'getIncident must include getIncident endpoint step').toContain( - "test.step('getIncident'", + expect(src, 'Incident.resolveIncident must include searchIncidents discovery step').toContain( + "test.step('prereq: searchIncidents'", ); - expect(src, 'getIncident must extract incidentKey from searchIncidents response').toContain( - "extractInto(ctx, 'incidentKeyVar', json?.items?.[0]?.incidentKey)", - ); - expect(src, 'getIncident must consume the runtime-discovered incidentKey').toContain( - '${ctx.incidentKeyVar', + expect(src, 'Incident.resolveIncident must include getIncident endpoint step').toContain( + "test.step('observe (read-back): getIncident'", ); + expect( + src, + 'Incident.resolveIncident must extract incidentKey from searchIncidents response', + ).toMatch(/extractInto\(ctx, 'incidentKeyVar', json\d*\?\.items\?\.\[0\]\?\.incidentKey\)/); + expect( + src, + 'Incident.resolveIncident must consume the runtime-discovered incidentKey', + ).toContain('${ctx.incidentKeyVar'); // Negative: must NOT fall back to seeded-only resolution. expect( src, - 'getIncident must NOT seed incidentKeyVar — the runtimeEmission chain supplies it', + 'Incident.resolveIncident must NOT seed incidentKeyVar — the runtimeEmission chain supplies it', ).not.toContain("seedBinding('incidentKeyVar')"); }); @@ -4167,11 +4207,23 @@ describeForThisConfig('bundled-spec invariants: suite-partition cut (#162 PR 4)' // without a corresponding `.feature.spec.ts` means the emitter // dropped the suite silently — exactly the failure mode the // partition cut is supposed to prevent. + // + // #331: operations covered by a scenario-template instantiation + // (EdgeLifecycle / EntityLifecycle / UpdatedFieldVisibleOnReadBack / + // StateTransitionVisibleAfterAction) are intentionally suppressed + // — the lifecycle spec under edges/, entities/, runtime-entities/, + // or state-transitions/ is the canonical functional test. Read + // the suppression set from the coverage artefact and exclude + // those opIds from this guard. A separate invariant below pins + // the inverse (every suppressed opId is backed by an emitted + // lifecycle spec). + const suppressed = loadSuppressedOpIds(); const offenders: { jsonFile: string; expectedSpec: string }[] = []; for (const { file, parsed } of loadAllFeatureFiles()) { if (!parsed.scenarios?.length) continue; const opId = parsed.endpoint?.operationId; if (!opId) continue; + if (suppressed.has(opId)) continue; const expectedSpec = `${opId}.feature.spec.ts`; const specPath = join(GENERATED_TESTS_DIR, expectedSpec); if (!existsSync(specPath)) { @@ -4184,6 +4236,54 @@ describeForThisConfig('bundled-spec invariants: suite-partition cut (#162 PR 4)' ).toEqual([]); }); + it('every scenario-template-suppressed opId is backed by an emitted lifecycle spec (#331)', () => { + // The inverse of the partition-cut guard above. The coverage + // artefact declares which opIds the materializer suppressed + // because a scenario-template instantiation already covers them; + // for every such opId there must be a `coverage.entries[]` row + // pointing at an emitted lifecycle spec that actually exists on + // disk. Without this guard a generator regression that emitted + // an empty coverage map (or one pointing at non-existent specs) + // would silently delete the feature spec for an operation that + // now has no test at all. + const coveragePath = join(GENERATED_TESTS_DIR, 'coverage.json'); + if (!existsSync(coveragePath)) { + throw new Error( + `coverage artefact not found at ${coveragePath} — run 'npm run testsuite:generate'`, + ); + } + // biome-ignore lint/plugin: runtime contract boundary — materializer-emitted coverage artefact. + const cov = JSON.parse(readFileSync(coveragePath, 'utf8')) as { + suppressedOpIds?: string[]; + entries?: Array<{ operationId: string; emittedSpec: string }>; + }; + const entries = cov.entries ?? []; + const opToSpecs = new Map(); + for (const e of entries) { + const arr = opToSpecs.get(e.operationId) ?? []; + arr.push(e.emittedSpec); + opToSpecs.set(e.operationId, arr); + } + const offenders: { opId: string; reason: string }[] = []; + for (const opId of cov.suppressedOpIds ?? []) { + const specs = opToSpecs.get(opId); + if (!specs || specs.length === 0) { + offenders.push({ opId, reason: 'no coverage entry' }); + continue; + } + const missing = specs.filter((rel) => !existsSync(join(GENERATED_TESTS_DIR, rel))); + if (missing.length > 0) { + offenders.push({ opId, reason: `missing emitted spec(s): ${missing.join(', ')}` }); + } + } + expect( + offenders, + 'Operations whose feature spec was suppressed must be covered by an emitted lifecycle spec.', + ).toEqual([]); + // Sanity: the bundled spec exercises this pattern non-trivially. + expect((cov.suppressedOpIds ?? []).length).toBeGreaterThan(0); + }); + it('variant suite covers every flat top-level optional present in the feature suite pre-cut (#162 PR 4)', () => { // After dropping the `subShapeRootOf` `segments.length < 2` // filter, the variant planner must enumerate flat top-level diff --git a/materializer/src/coverage.ts b/materializer/src/coverage.ts new file mode 100644 index 0000000..83cb9f2 --- /dev/null +++ b/materializer/src/coverage.ts @@ -0,0 +1,182 @@ +// Scenario-template coverage extractor (#331). +// +// Walks the per-template scenario JSON files emitted by the planner +// (`generated//scenarios/templates//*.json`) and +// collects every operationId that appears as the `op` of an `Invoke` or +// `Observe` step. Those operations are, by definition, the +// units-under-test of a well-formed scenario-driven spec — emitting a +// per-endpoint feature spec for the same operation would be redundant +// at best and structurally malformed at worst (see #331 for the +// EdgeLifecycle motivating example). +// +// The collector is deliberately template-agnostic: a new +// ScenarioTemplate added to `configs//ontology/scenario-templates.json` +// (and emitted by the planner under a new subdirectory of +// `scenarios/templates/`) extends suppression automatically as soon as +// it is wired into `templateOutputDirs`. +// +// `PrereqChain` steps are intentionally *excluded* from coverage — +// they are scaffolding, not units-under-test. The Invoke/Observe vs +// PrereqChain distinction is the same closed taxonomy declared in +// `scenarioTemplateSchema.ts`. + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +export interface CoverageEntry { + operationId: string; + template: string; + appliesToKind: string; + aboxRow: string; + stepKind: 'invoke' | 'observe'; + emittedSpec: string; +} + +export interface CoverageResult { + suppressedOpIds: Set; + entries: CoverageEntry[]; +} + +export interface BuildCoverageOptions { + /** `generated//scenarios/templates/`. */ + templateScenariosRootDir: string; + /** `configs//ontology/scenario-templates.json`, or `undefined` + * to treat every template as suppressing (the default). */ + templatesAboxPath: string | undefined; + /** + * Maps `templateName → emitted output directory relative to the + * playwright suite root`. For each scenario file under + * `//.json` the + * recorded `emittedSpec` becomes + * `/.lifecycle.spec.ts`. Templates not + * present in this map are skipped (no spec is emitted, so coverage + * cannot legitimately claim the op). + */ + templateOutputDirs: Record; +} + +interface ScenarioStep { + kind?: string; + operationId?: string; +} + +interface ScenarioFile { + templateName?: string; + subjectName?: string; + subjectKind?: string; + scenario?: { steps?: ScenarioStep[] }; +} + +interface ScenarioTemplateAbox { + templates?: Array<{ name?: string; suppressesFeatureTest?: boolean }>; +} + +/** + * Collect coverage from on-disk scenario JSON files. Pure I/O — + * caller decides where the artefact lives and how the suppression set + * is consumed. + */ +export async function buildCoverage(opts: BuildCoverageOptions): Promise { + const suppressByTemplate = await loadSuppressionMap(opts.templatesAboxPath); + const entries: CoverageEntry[] = []; + + let templateDirs: string[]; + try { + templateDirs = await fs.readdir(opts.templateScenariosRootDir); + } catch (e) { + if (typeof e === 'object' && e !== null && 'code' in e && Reflect.get(e, 'code') === 'ENOENT') { + return { suppressedOpIds: new Set(), entries: [] }; + } + throw e; + } + templateDirs.sort(); + + for (const templateName of templateDirs) { + const templateDir = path.join(opts.templateScenariosRootDir, templateName); + const stat = await fs.stat(templateDir); + if (!stat.isDirectory()) continue; + + // Templates not wired to an emitted output dir don't actually + // produce a spec on disk; suppressing the feature spec on the + // strength of a scenario JSON nobody renders would create an + // untested gap. Skip. + const emittedDir = opts.templateOutputDirs[templateName]; + if (!emittedDir) continue; + + // Default: every well-formed scenario template suppresses. The + // opt-out is per-template so a future non-functional template + // (smoke / chaos / load) can emit a spec without claiming + // coverage. + const suppresses = suppressByTemplate.get(templateName) ?? true; + if (!suppresses) continue; + + const files = (await fs.readdir(templateDir)).filter((f) => f.endsWith('.json')).sort(); + for (const f of files) { + const raw = await fs.readFile(path.join(templateDir, f), 'utf8'); + let parsed: ScenarioFile; + try { + // biome-ignore lint/plugin: runtime contract boundary — planner-emitted scenario JSON; downstream emitter validates the full shape. + parsed = JSON.parse(raw) as ScenarioFile; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + throw new Error( + `coverage: failed to parse scenario JSON ${path.join(templateDir, f)}: ${msg}`, + ); + } + const subjectName = parsed.subjectName; + const subjectKind = parsed.subjectKind; + const tmplName = parsed.templateName ?? templateName; + if (!subjectName) continue; + const emittedSpec = path.join(emittedDir, `${subjectName}.lifecycle.spec.ts`); + const steps = parsed.scenario?.steps ?? []; + for (const step of steps) { + const kind = step.kind; + if ((kind === 'invoke' || kind === 'observe') && step.operationId) { + entries.push({ + operationId: step.operationId, + template: tmplName, + appliesToKind: subjectKind ?? '', + aboxRow: subjectName, + stepKind: kind, + emittedSpec, + }); + } + } + } + } + + return { + suppressedOpIds: new Set(entries.map((e) => e.operationId)), + entries, + }; +} + +async function loadSuppressionMap( + templatesAboxPath: string | undefined, +): Promise> { + const out = new Map(); + if (!templatesAboxPath) return out; + let raw: string; + try { + raw = await fs.readFile(templatesAboxPath, 'utf8'); + } catch (e) { + if (typeof e === 'object' && e !== null && 'code' in e && Reflect.get(e, 'code') === 'ENOENT') { + return out; + } + throw e; + } + let parsed: ScenarioTemplateAbox; + try { + // biome-ignore lint/plugin: runtime contract boundary — scenario-templates ABox JSON; only `templates[].name` and `templates[].suppressesFeatureTest` are read. + parsed = JSON.parse(raw) as ScenarioTemplateAbox; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + throw new Error(`coverage: failed to parse ${templatesAboxPath}: ${msg}`); + } + for (const t of parsed.templates ?? []) { + if (typeof t.name === 'string') { + out.set(t.name, t.suppressesFeatureTest !== false); + } + } + return out; +} diff --git a/materializer/src/index.ts b/materializer/src/index.ts index c64a7d4..c10b1a8 100644 --- a/materializer/src/index.ts +++ b/materializer/src/index.ts @@ -18,6 +18,7 @@ import { getFeatureOutputDir, getPlaywrightSuiteDir, getTemplateScenariosDir, + getTemplateScenariosRootDir, getVariantOutputDir, } from 'path-analyser/configResolver'; import { @@ -28,6 +29,7 @@ import { import { getEmitterRoleForOperation } from 'path-analyser/ontology/operationRoles'; import type { EndpointScenarioCollection, GlobalContextSeed } from 'path-analyser/types'; import { parseCliArgs } from './cli-args.js'; +import { buildCoverage, type CoverageResult } from './coverage.js'; import { writeEmitted, writeScaffolded } from './orchestrator.js'; import { PlaywrightEmitter } from './playwright/emitter.js'; import { @@ -466,12 +468,39 @@ async function run() { await writeScaffolded(emitter, buildCtx('', 'feature')); if (positional === '--all') { + // #331: scenario-template coverage. Build the suppression set + // from on-disk template scenario JSONs before the feature loop so + // operations covered by a well-formed EdgeLifecycle / + // EntityLifecycle / UpdatedFieldVisibleOnReadBack / + // StateTransitionVisibleAfterAction spec (or any future scenario + // template wired into `templateOutputDirs`) do not also emit a + // structurally weaker feature spec. Suppression only applies to + // emitters that ship the corresponding template suites; for now + // that is Playwright. See materializer/src/coverage.ts and #331. + let coverage: CoverageResult = { suppressedOpIds: new Set(), entries: [] }; + if (emitter.id === PlaywrightEmitter.id) { + coverage = await buildCoverage({ + templateScenariosRootDir: getTemplateScenariosRootDir(repoRoot), + templatesAboxPath: path.join(configDir, 'ontology', 'scenario-templates.json'), + templateOutputDirs: { + EdgeLifecycle: 'edges', + EntityLifecycle: 'entities', + UpdatedFieldVisibleOnReadBack: 'runtime-entities', + StateTransitionVisibleAfterAction: 'state-transitions', + }, + }); + } let count = 0; + let suppressedCount = 0; for (const f of files) { try { const content = await fs.readFile(path.join(featureDir, f), 'utf8'); const parsed = parseScenarioCollection(content); if (!parsed.endpoint?.operationId) continue; + if (coverage.suppressedOpIds.has(parsed.endpoint.operationId)) { + suppressedCount++; + continue; + } await writeEmitted(emitter, parsed, buildCtx(parsed.endpoint.operationId, 'feature')); count++; } catch (e) { @@ -573,8 +602,32 @@ async function run() { }); lifecycleCount += stateTransitionWritten.length; } + // #331: persist the coverage artefact alongside the suites so it + // is diffable in PRs and consumable by the L3 invariant in + // configs//regression-invariants.test.ts. Written for + // every emitter so the artefact's presence is independent of + // whether the current target shipped template suites this run. + await fs.writeFile( + path.join(outDir, 'coverage.json'), + `${JSON.stringify( + { + version: 1, + suppressedOpIds: [...coverage.suppressedOpIds].sort(), + entries: [...coverage.entries].sort((a, b) => + a.operationId === b.operationId + ? a.template === b.template + ? a.aboxRow.localeCompare(b.aboxRow) || a.stepKind.localeCompare(b.stepKind) + : a.template.localeCompare(b.template) + : a.operationId.localeCompare(b.operationId), + ), + }, + null, + 2, + )}\n`, + 'utf8', + ); console.log( - `Generated test suites for ${count} endpoints (+${variantCount} variant suites, +${lifecycleCount} lifecycle suites) in ${outDir} (target: ${emitter.id})`, + `Generated test suites for ${count} endpoints (+${variantCount} variant suites, +${lifecycleCount} lifecycle suites, -${suppressedCount} suppressed by scenario-template coverage) in ${outDir} (target: ${emitter.id})`, ); return; } diff --git a/ontology/vocabulary/scenario-template.schema.json b/ontology/vocabulary/scenario-template.schema.json index a6d366c..3157232 100644 --- a/ontology/vocabulary/scenario-template.schema.json +++ b/ontology/vocabulary/scenario-template.schema.json @@ -68,6 +68,10 @@ "type": "string", "minLength": 1, "description": "Maintainer-facing prose explaining what behaviour this template asserts and why it is encoded as a template (vs. baked into the planner)." + }, + "suppressesFeatureTest": { + "type": "boolean", + "description": "#331: whether this template suppresses per-endpoint feature-spec emission for the operationIds bound to its `Invoke` / `Observe` steps. Defaults to `true` — every well-formed scenario template is the canonical functional test for the operations it exercises, and a parallel feature spec would be either strictly weaker (EntityLifecycle case) or structurally malformed (EdgeLifecycle case: no key-only prereq chain can encode the establish-before-revoke precondition). Set to `false` only for future non-functional templates (smoke / chaos / load) that emit a spec without claiming functional coverage of the operations they touch. The materializer reads this flag from `configs//ontology/scenario-templates.json` to build the suppression set in `materializer/src/coverage.ts`." } } }, diff --git a/path-analyser/src/ontology/scenarioTemplateSchema.ts b/path-analyser/src/ontology/scenarioTemplateSchema.ts index b8497e3..21d9a3a 100644 --- a/path-analyser/src/ontology/scenarioTemplateSchema.ts +++ b/path-analyser/src/ontology/scenarioTemplateSchema.ts @@ -105,6 +105,11 @@ export const scenarioTemplateSchema = { description: 'Maintainer-facing prose explaining what behaviour this template asserts and why it is encoded as a template (vs. baked into the planner).', }, + suppressesFeatureTest: { + type: 'boolean', + description: + '#331: whether this template suppresses per-endpoint feature-spec emission for the operationIds bound to its `Invoke` / `Observe` steps. Defaults to `true` — every well-formed scenario template is the canonical functional test for the operations it exercises, and a parallel feature spec would be either strictly weaker (EntityLifecycle case) or structurally malformed (EdgeLifecycle case: no key-only prereq chain can encode the establish-before-revoke precondition). Set to `false` only for future non-functional templates (smoke / chaos / load) that emit a spec without claiming functional coverage of the operations they touch. The materializer reads this flag from `configs//ontology/scenario-templates.json` to build the suppression set in `materializer/src/coverage.ts`.', + }, }, }, AppliesTo: { diff --git a/tests/codegen/coverage.test.ts b/tests/codegen/coverage.test.ts new file mode 100644 index 0000000..ccc19b1 --- /dev/null +++ b/tests/codegen/coverage.test.ts @@ -0,0 +1,132 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { buildCoverage } from '../../materializer/src/coverage.ts'; + +/** + * Coverage extractor unit tests (#331 / PR #332). + * + * These lock in three behaviours that are easy to break by a rename or + * a default-flip and have no upstream test guarding them: + * + * 1. `suppressesFeatureTest: false` on the ABox is honoured (the + * reader's property name and the schema field name must agree). + * 2. Omitting `suppressesFeatureTest` defaults to suppressing. + * 3. `prereqChain` steps are excluded — only `invoke` / `observe` + * step kinds contribute to the suppression set. This is the + * closed taxonomy declared in `scenarioTemplateSchema.ts`. + */ +describe('buildCoverage (#331)', () => { + let tmp: string; + + beforeEach(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'coverage-')); + }); + + afterEach(async () => { + await fs.rm(tmp, { recursive: true, force: true }); + }); + + async function writeScenario( + templateScenariosRootDir: string, + templateName: string, + subjectName: string, + steps: Array<{ kind: string; operationId?: string }>, + ) { + const dir = path.join(templateScenariosRootDir, templateName); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile( + path.join(dir, `${subjectName}.json`), + JSON.stringify({ + templateName, + subjectName, + subjectKind: 'Entity', + scenario: { steps }, + }), + ); + } + + test('default (no ABox) treats every template as suppressing', async () => { + const root = path.join(tmp, 'scenarios', 'templates'); + await writeScenario(root, 'EntityLifecycle', 'User', [ + { kind: 'invoke', operationId: 'createUser' }, + { kind: 'observe', operationId: 'getUser' }, + ]); + + const result = await buildCoverage({ + templateScenariosRootDir: root, + templatesAboxPath: undefined, + templateOutputDirs: { EntityLifecycle: 'entities' }, + }); + + expect([...result.suppressedOpIds].sort()).toEqual(['createUser', 'getUser']); + }); + + test('suppressesFeatureTest: false on the ABox excludes that template from suppression', async () => { + const root = path.join(tmp, 'scenarios', 'templates'); + await writeScenario(root, 'EntityLifecycle', 'User', [ + { kind: 'invoke', operationId: 'createUser' }, + ]); + await writeScenario(root, 'SmokeTemplate', 'Tenant', [ + { kind: 'invoke', operationId: 'createTenant' }, + ]); + + const aboxPath = path.join(tmp, 'scenario-templates.json'); + await fs.writeFile( + aboxPath, + JSON.stringify({ + templates: [ + { name: 'EntityLifecycle' }, // default → true + { name: 'SmokeTemplate', suppressesFeatureTest: false }, // explicit opt-out + ], + }), + ); + + const result = await buildCoverage({ + templateScenariosRootDir: root, + templatesAboxPath: aboxPath, + templateOutputDirs: { + EntityLifecycle: 'entities', + SmokeTemplate: 'smoke', + }, + }); + + expect(result.suppressedOpIds.has('createUser')).toBe(true); + expect(result.suppressedOpIds.has('createTenant')).toBe(false); + }); + + test('only invoke and observe steps contribute — prereqChain is scaffolding', async () => { + const root = path.join(tmp, 'scenarios', 'templates'); + await writeScenario(root, 'EntityLifecycle', 'User', [ + { kind: 'prereqChain', operationId: 'createTenant' }, // excluded + { kind: 'invoke', operationId: 'createUser' }, + { kind: 'observe', operationId: 'getUser' }, + ]); + + const result = await buildCoverage({ + templateScenariosRootDir: root, + templatesAboxPath: undefined, + templateOutputDirs: { EntityLifecycle: 'entities' }, + }); + + expect([...result.suppressedOpIds].sort()).toEqual(['createUser', 'getUser']); + expect(result.suppressedOpIds.has('createTenant')).toBe(false); + }); + + test('templates not wired into templateOutputDirs are skipped (no spec emitted, no coverage claimed)', async () => { + const root = path.join(tmp, 'scenarios', 'templates'); + await writeScenario(root, 'UnwiredTemplate', 'Thing', [ + { kind: 'invoke', operationId: 'doThing' }, + ]); + + const result = await buildCoverage({ + templateScenariosRootDir: root, + templatesAboxPath: undefined, + templateOutputDirs: {}, // not wired + }); + + expect(result.suppressedOpIds.size).toBe(0); + expect(result.entries).toEqual([]); + }); +});