diff --git a/materializer/src/coverageSummary.ts b/materializer/src/coverageSummary.ts new file mode 100644 index 0000000..c93e224 --- /dev/null +++ b/materializer/src/coverageSummary.ts @@ -0,0 +1,138 @@ +// Deterministic coverage summary (#335 follow-up). +// +// Reads the bundled OpenAPI spec for the active config and folds the +// emitted-feature opIds, the template-derived suppression set, and the +// raw coverage entries into a single summary block that ships inside +// `generated//playwright/coverage.json` (v2). The summary is +// the source of truth for: +// +// • the reconciliation math (total spec ops = emitted features + +// suppressed-by-template + unmapped), +// • per-template aggregates (specs, unique ops, total entries, +// invoke / observe step tallies), +// • the unmapped-operations list (must be empty on a healthy run). +// +// Built once by the materializer so a separate report renderer +// (`scripts/render-coverage-report.ts`) can transform it without +// re-walking the planner outputs — guaranteeing the Markdown report +// and the JSON artefact agree by construction. + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { CoverageEntry } from './coverage.js'; + +export interface PerTemplateSummary { + name: string; + specs: number; + uniqueOperations: number; + entries: number; + invokeSteps: number; + observeSteps: number; +} + +export interface CoverageSummary { + totalSpecOperations: number; + emittedFeatureSpecs: number; + suppressedByTemplate: number; + variantSpecs: number; + lifecycleSpecs: number; + unmappedOperations: string[]; + perTemplate: PerTemplateSummary[]; +} + +export interface BuildCoverageSummaryInput { + allSpecOpIds: readonly string[]; + emittedFeatureOpIds: ReadonlySet; + suppressedOpIds: ReadonlySet; + entries: readonly CoverageEntry[]; + variantSpecs: number; + lifecycleSpecs: number; +} + +interface BundledSpec { + paths?: Record>; +} + +/** + * Collect every operationId declared in the bundled OpenAPI spec. + * Returns `[]` when the spec is missing (e.g. a config that ships no + * bundled spec) so the summary still renders deterministically. + */ +export async function loadSpecOperationIds(specBundleDir: string): Promise { + const bundledSpecPath = path.join(specBundleDir, 'rest-api.bundle.json'); + let raw: string; + try { + raw = await fs.readFile(bundledSpecPath, 'utf8'); + } catch (e) { + if (typeof e === 'object' && e !== null && 'code' in e && Reflect.get(e, 'code') === 'ENOENT') { + return []; + } + throw e; + } + let spec: BundledSpec; + try { + // biome-ignore lint/plugin: runtime contract boundary — bundled OpenAPI spec; only paths[].{method}.operationId is read. + spec = JSON.parse(raw) as BundledSpec; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + throw new Error(`coverage summary: failed to parse ${bundledSpecPath}: ${msg}`); + } + // Dedupe via Set: OpenAPI does not technically forbid duplicate + // operationIds, and treating them as a multiset would inflate + // totalSpecOperations and the unmapped count (cf. PR #337 review). + // Other consumers of spec opIds in this repo (e.g. + // configs/camunda-oca/regression-invariants.test.ts) also treat them + // as a Set, so the reconciliation math stays consistent. + const ids = new Set(); + for (const pathItem of Object.values(spec.paths ?? {})) { + if (!pathItem || typeof pathItem !== 'object') continue; + for (const op of Object.values(pathItem)) { + if (!op || typeof op !== 'object') continue; + const opId = Reflect.get(op, 'operationId'); + if (typeof opId === 'string' && opId.length > 0) ids.add(opId); + } + } + return [...ids].sort(); +} + +export function buildCoverageSummary(input: BuildCoverageSummaryInput): CoverageSummary { + const accountedFor = new Set([...input.emittedFeatureOpIds, ...input.suppressedOpIds]); + const unmappedOperations = input.allSpecOpIds.filter((id) => !accountedFor.has(id)).sort(); + + const perTemplateAgg = new Map< + string, + { specs: Set; opIds: Set; entries: number; invoke: number; observe: number } + >(); + for (const e of input.entries) { + let agg = perTemplateAgg.get(e.template); + if (!agg) { + agg = { specs: new Set(), opIds: new Set(), entries: 0, invoke: 0, observe: 0 }; + perTemplateAgg.set(e.template, agg); + } + agg.specs.add(e.emittedSpec); + agg.opIds.add(e.operationId); + agg.entries++; + if (e.stepKind === 'invoke') agg.invoke++; + else if (e.stepKind === 'observe') agg.observe++; + } + const perTemplate: PerTemplateSummary[] = [...perTemplateAgg.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, agg]) => ({ + name, + specs: agg.specs.size, + uniqueOperations: agg.opIds.size, + entries: agg.entries, + invokeSteps: agg.invoke, + observeSteps: agg.observe, + })); + + return { + totalSpecOperations: input.allSpecOpIds.length, + emittedFeatureSpecs: input.emittedFeatureOpIds.size, + suppressedByTemplate: input.suppressedOpIds.size, + variantSpecs: input.variantSpecs, + lifecycleSpecs: input.lifecycleSpecs, + unmappedOperations, + perTemplate, + }; +} diff --git a/materializer/src/index.ts b/materializer/src/index.ts index 930d6a4..3ca7f40 100644 --- a/materializer/src/index.ts +++ b/materializer/src/index.ts @@ -17,6 +17,7 @@ import { getActiveConfigName, getFeatureOutputDir, getPlaywrightSuiteDir, + getSpecBundleDir, getTemplateScenariosDir, getTemplateScenariosRootDir, getVariantOutputDir, @@ -31,6 +32,7 @@ import { getEmitterRoleForOperation } from 'path-analyser/ontology/operationRole import type { EndpointScenarioCollection, GlobalContextSeed } from 'path-analyser/types'; import { parseCliArgs } from './cli-args.js'; import { buildCoverage, type CoverageResult, templateOutputDir } from './coverage.js'; +import { buildCoverageSummary, loadSpecOperationIds } from './coverageSummary.js'; import { writeEmitted, writeScaffolded } from './orchestrator.js'; import { PlaywrightEmitter } from './playwright/emitter.js'; import { @@ -496,6 +498,12 @@ async function run() { } let count = 0; let suppressedCount = 0; + // #335: track which opIds were emitted as feature specs so the + // coverage summary can compute the unmapped set (ops in the spec + // that are neither emitted as a feature spec nor suppressed by a + // scenario-template lifecycle suite). Should be empty on a healthy + // spec; a non-empty set surfaces planner / coverage drift. + const emittedFeatureOpIds = new Set(); for (const f of files) { try { const content = await fs.readFile(path.join(featureDir, f), 'utf8'); @@ -506,6 +514,7 @@ async function run() { continue; } await writeEmitted(emitter, parsed, buildCtx(parsed.endpoint.operationId, 'feature')); + emittedFeatureOpIds.add(parsed.endpoint.operationId); count++; } catch (e) { const msg = e instanceof Error ? e.message : String(e); @@ -572,6 +581,23 @@ async function run() { lifecycleCount += written.length; } } + // #335: build a deterministic coverage summary alongside the raw + // suppression set / entries. The summary block answers "how many + // operations in the spec are covered, by what kind of suite, and + // by which template" without requiring readers to re-walk the + // feature-output / template-scenarios directories. The summary is + // emitted for every emitter so PR diffs and the + // `npm run coverage:report` script see the same shape regardless + // of which target the materializer was invoked for. + const allSpecOpIds = await loadSpecOperationIds(getSpecBundleDir(repoRoot)); + const summary = buildCoverageSummary({ + allSpecOpIds, + emittedFeatureOpIds, + suppressedOpIds: coverage.suppressedOpIds, + entries: coverage.entries, + variantSpecs: variantCount, + lifecycleSpecs: lifecycleCount, + }); // #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 @@ -581,7 +607,10 @@ async function run() { path.join(outDir, 'coverage.json'), `${JSON.stringify( { - version: 1, + version: 2, + config: configName, + emitter: emitter.id, + summary, suppressedOpIds: [...coverage.suppressedOpIds].sort(), entries: [...coverage.entries].sort((a, b) => a.operationId === b.operationId diff --git a/package.json b/package.json index d0ea3ca..4badc00 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "testsuite:observe:run": "npm run codegen:playwright:all && npm run test:pw:path-analyser && npm run observe:aggregate", "observe:aggregate": "npm run build:analyser && node path-analyser/dist/src/scripts/aggregate-observations.js", "optional-responses": "node optional-responses/report.js", + "coverage:report": "tsx scripts/render-coverage-report.ts", "pipeline": "npm run fetch-spec && npm run testsuite:generate && npm run generate:request-validation", "lint": "biome check", "lint:fix": "biome check --write", diff --git a/scripts/render-coverage-report.ts b/scripts/render-coverage-report.ts new file mode 100644 index 0000000..8543fc8 --- /dev/null +++ b/scripts/render-coverage-report.ts @@ -0,0 +1,220 @@ +// Render the materializer's coverage artefact as a human-readable +// report (#335 follow-up). +// +// Reads `generated//playwright/coverage.json` (v2) and +// emits a deterministic Markdown report by default. Pass `--format=json` +// to re-emit the raw JSON (useful when piping to other tools). +// +// Usage: +// npm run coverage:report # Markdown to stdout +// npm run coverage:report -- --format=json # JSON to stdout +// npm run coverage:report -- --input # explicit path override +// npm run coverage:report -- --out # write to a file +// +// The renderer is a pure transform: it never re-walks the planner +// outputs or the bundled spec. The summary block embedded in +// coverage.json by the materializer is the single source of truth, so +// the JSON artefact and the Markdown report agree by construction. + +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { getActiveConfigName, getPlaywrightSuiteDir } from '../path-analyser/src/configResolver.ts'; + +// Derive REPO_ROOT from the script's own location rather than walking +// up from process.cwd() looking for a package.json — this is a +// monorepo with nested package.json files (materializer/, path-analyser/, …) +// so a cwd-based search would happily stop at a workspace package.json +// and then break configs.json lookups (cf. PR #337 review). The script +// lives at /scripts/render-coverage-report.ts, so the repo root +// is one directory up. This matches the convention used by sibling +// scripts (export-ontology.ts, build-ontology.ts, run-pw-request-validation.ts). +const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + +interface PerTemplateSummary { + name: string; + specs: number; + uniqueOperations: number; + entries: number; + invokeSteps: number; + observeSteps: number; +} + +interface CoverageSummary { + totalSpecOperations: number; + emittedFeatureSpecs: number; + suppressedByTemplate: number; + variantSpecs: number; + lifecycleSpecs: number; + unmappedOperations: string[]; + perTemplate: PerTemplateSummary[]; +} + +interface CoverageArtefact { + version: number; + config?: string; + emitter?: string; + summary?: CoverageSummary; + suppressedOpIds?: string[]; + entries?: unknown[]; +} + +interface CliArgs { + format: 'markdown' | 'json'; + inputPath: string | undefined; + outPath: string | undefined; +} + +function parseArgs(argv: readonly string[]): CliArgs { + let format: 'markdown' | 'json' = 'markdown'; + let inputPath: string | undefined; + let outPath: string | undefined; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--format=json' || arg === '--json') format = 'json'; + else if (arg === '--format=markdown' || arg === '--markdown') format = 'markdown'; + else if (arg === '--format' && i + 1 < argv.length) { + const v = argv[++i]; + if (v !== 'json' && v !== 'markdown') { + throw new Error(`--format must be 'json' or 'markdown' (got '${v}')`); + } + format = v; + } else if (arg === '--input' && i + 1 < argv.length) { + inputPath = argv[++i]; + } else if (arg === '--out' && i + 1 < argv.length) { + outPath = argv[++i]; + } else if (arg === '-h' || arg === '--help') { + printUsage(); + process.exit(0); + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + return { format, inputPath, outPath }; +} + +function printUsage(): void { + console.error( + [ + 'Usage: tsx scripts/render-coverage-report.ts [--format markdown|json] [--input ] [--out ]', + '', + 'Reads the materializer coverage artefact and emits a human-readable', + "report. Defaults to Markdown on stdout for the active config's", + 'Playwright suite (`generated//playwright/coverage.json`).', + ].join('\n'), + ); +} + +export function renderMarkdown(artefact: CoverageArtefact): string { + const summary = artefact.summary; + if (!summary) { + throw new Error( + `coverage artefact has no 'summary' block (version ${artefact.version}); ` + + 're-run the materializer (`npm run codegen:playwright:all`) to produce a v2 artefact.', + ); + } + const config = artefact.config ?? '(unknown config)'; + const emitter = artefact.emitter ?? '(unknown emitter)'; + const total = summary.totalSpecOperations; + const emitted = summary.emittedFeatureSpecs; + const suppressed = summary.suppressedByTemplate; + const covered = emitted + suppressed; + const pct = total > 0 ? ((covered / total) * 100).toFixed(1) : '0.0'; + + const lines: string[] = []; + lines.push(`# Coverage report — ${config}`); + lines.push(''); + lines.push(`- Emitter: \`${emitter}\``); + lines.push(`- Spec operations: **${total}**`); + lines.push(`- Emitted feature specs: **${emitted}**`); + lines.push(`- Suppressed by scenario-template coverage: **${suppressed}**`); + lines.push(`- Variant specs: **${summary.variantSpecs}**`); + lines.push(`- Lifecycle (template) specs: **${summary.lifecycleSpecs}**`); + lines.push(`- Operation coverage: **${covered} / ${total} (${pct}%)**`); + lines.push(`- Unmapped operations: **${summary.unmappedOperations.length}**`); + lines.push(''); + + lines.push('## Reconciliation'); + lines.push(''); + lines.push('```'); + lines.push(`spec operations: ${String(total).padStart(4)}`); + lines.push(` emitted feature specs: ${String(emitted).padStart(4)}`); + lines.push( + `+ suppressed by template: ${String(suppressed).padStart(4)} (covered by ${summary.lifecycleSpecs} lifecycle specs)`, + ); + lines.push(`+ unmapped: ${String(summary.unmappedOperations.length).padStart(4)}`); + lines.push( + `= total covered + gaps: ${String(emitted + suppressed + summary.unmappedOperations.length).padStart(4)}`, + ); + lines.push('```'); + lines.push(''); + lines.push( + `Variant specs (${summary.variantSpecs}) are emitted in addition to the per-endpoint feature specs and enumerate optional sub-shapes; they do not contribute to the spec-operation coverage tally.`, + ); + lines.push(''); + + lines.push('## Per-template coverage'); + lines.push(''); + if (summary.perTemplate.length === 0) { + lines.push('_No template-derived coverage in this run._'); + } else { + lines.push('| Template | Specs | Unique ops | Entries | Invoke steps | Observe steps |'); + lines.push('|---|---:|---:|---:|---:|---:|'); + for (const t of summary.perTemplate) { + lines.push( + `| \`${t.name}\` | ${t.specs} | ${t.uniqueOperations} | ${t.entries} | ${t.invokeSteps} | ${t.observeSteps} |`, + ); + } + } + lines.push(''); + + lines.push('## Unmapped operations'); + lines.push(''); + if (summary.unmappedOperations.length === 0) { + lines.push('_None — every spec operation is covered by a feature or lifecycle suite._'); + } else { + lines.push( + 'Operations declared in the bundled OpenAPI spec that the planner produced no feature scenario for and that no scenario-template lifecycle suite covers:', + ); + lines.push(''); + for (const opId of summary.unmappedOperations) { + lines.push(`- \`${opId}\``); + } + } + lines.push(''); + + return lines.join('\n'); +} + +function main(argv: readonly string[]): void { + const args = parseArgs(argv); + const inputPath = args.inputPath ?? path.join(getPlaywrightSuiteDir(REPO_ROOT), 'coverage.json'); + if (!existsSync(inputPath)) { + const activeConfig = getActiveConfigName(REPO_ROOT); + throw new Error( + `coverage artefact not found at ${inputPath} ` + + `(active config: ${activeConfig}). Run \`npm run codegen:playwright:all\` to produce it.`, + ); + } + // biome-ignore lint/plugin: runtime contract boundary — materializer-emitted coverage artefact. + const artefact = JSON.parse(readFileSync(inputPath, 'utf8')) as CoverageArtefact; + const output = + args.format === 'json' + ? `${JSON.stringify(artefact, null, 2)}\n` + : `${renderMarkdown(artefact)}\n`; + if (args.outPath) { + writeFileSync(args.outPath, output, 'utf8'); + } else { + process.stdout.write(output); + } +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { + try { + main(process.argv.slice(2)); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error(`render-coverage-report: ${msg}`); + process.exit(1); + } +} diff --git a/tests/codegen/coverage-summary.test.ts b/tests/codegen/coverage-summary.test.ts new file mode 100644 index 0000000..cb4bc66 --- /dev/null +++ b/tests/codegen/coverage-summary.test.ts @@ -0,0 +1,236 @@ +// Tests for the coverage-report renderer (#335 follow-up). +// +// Two surfaces are guarded: +// 1. `buildCoverageSummary` — pure aggregation: reconciliation math +// (total = emitted + suppressed + unmapped), per-template tallies, +// and unmapped-operation derivation. +// 2. `renderMarkdown` — deterministic Markdown layout for a known +// summary block. Locked against a hand-crafted artefact so future +// reshapes surface as a single diff rather than silent drift. +// +// The materializer is the only writer of `coverage.json`, and the +// renderer is a pure transform over the embedded `summary` block, so +// these unit tests + the existing L3 invariant (every suppressed opId +// has an emitted lifecycle spec) are sufficient — no fixture under +// `generated//` is required for renderer correctness. + +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { + buildCoverageSummary, + loadSpecOperationIds, +} from '../../materializer/src/coverageSummary.ts'; +import { renderMarkdown } from '../../scripts/render-coverage-report.ts'; + +describe('buildCoverageSummary (#335)', () => { + test('reconciles emitted + suppressed + unmapped against total', () => { + const summary = buildCoverageSummary({ + allSpecOpIds: ['createGroup', 'deleteGroup', 'getGroup', 'orphanOp'], + emittedFeatureOpIds: new Set(['getGroup']), + suppressedOpIds: new Set(['createGroup', 'deleteGroup']), + entries: [ + { + operationId: 'createGroup', + template: 'EntityLifecycle', + appliesToKind: 'Entity', + aboxRow: 'Group', + stepKind: 'invoke', + emittedSpec: 'templates/EntityLifecycle/Group.lifecycle.spec.ts', + }, + { + operationId: 'deleteGroup', + template: 'EntityLifecycle', + appliesToKind: 'Entity', + aboxRow: 'Group', + stepKind: 'invoke', + emittedSpec: 'templates/EntityLifecycle/Group.lifecycle.spec.ts', + }, + { + operationId: 'createGroup', + template: 'EntityLifecycle', + appliesToKind: 'Entity', + aboxRow: 'Group', + stepKind: 'observe', + emittedSpec: 'templates/EntityLifecycle/Group.lifecycle.spec.ts', + }, + ], + variantSpecs: 5, + lifecycleSpecs: 1, + }); + + expect(summary.totalSpecOperations).toBe(4); + expect(summary.emittedFeatureSpecs).toBe(1); + expect(summary.suppressedByTemplate).toBe(2); + expect(summary.variantSpecs).toBe(5); + expect(summary.lifecycleSpecs).toBe(1); + expect(summary.unmappedOperations).toEqual(['orphanOp']); + expect(summary.perTemplate).toEqual([ + { + name: 'EntityLifecycle', + specs: 1, + uniqueOperations: 2, + entries: 3, + invokeSteps: 2, + observeSteps: 1, + }, + ]); + }); + + test('per-template aggregates are sorted by name', () => { + const summary = buildCoverageSummary({ + allSpecOpIds: [], + emittedFeatureOpIds: new Set(), + suppressedOpIds: new Set(['a', 'b']), + entries: [ + { + operationId: 'a', + template: 'ZTemplate', + appliesToKind: 'X', + aboxRow: 'r1', + stepKind: 'invoke', + emittedSpec: 'templates/ZTemplate/r1.lifecycle.spec.ts', + }, + { + operationId: 'b', + template: 'ATemplate', + appliesToKind: 'X', + aboxRow: 'r2', + stepKind: 'invoke', + emittedSpec: 'templates/ATemplate/r2.lifecycle.spec.ts', + }, + ], + variantSpecs: 0, + lifecycleSpecs: 2, + }); + expect(summary.perTemplate.map((t) => t.name)).toEqual(['ATemplate', 'ZTemplate']); + }); + + test('unmapped operations are sorted', () => { + const summary = buildCoverageSummary({ + allSpecOpIds: ['zebra', 'alpha', 'mango'], + emittedFeatureOpIds: new Set(), + suppressedOpIds: new Set(), + entries: [], + variantSpecs: 0, + lifecycleSpecs: 0, + }); + expect(summary.unmappedOperations).toEqual(['alpha', 'mango', 'zebra']); + }); +}); + +describe('loadSpecOperationIds (#337)', () => { + let tmpDir: string; + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'coverage-summary-test-')); + }); + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('returns sorted unique operationIds (dedupes duplicates across paths/methods)', async () => { + // OpenAPI does not technically forbid duplicate operationIds. Treat them as a set + // so totalSpecOperations + unmapped reconciliation don't inflate (PR #337 review). + const spec = { + paths: { + '/groups': { + get: { operationId: 'listGroups' }, + post: { operationId: 'createGroup' }, + }, + '/groups/{id}': { + // Same operationId appearing again under a different path — must + // contribute one entry, not two. + get: { operationId: 'listGroups' }, + delete: { operationId: 'deleteGroup' }, + }, + }, + }; + writeFileSync(join(tmpDir, 'rest-api.bundle.json'), JSON.stringify(spec), 'utf8'); + + const ids = await loadSpecOperationIds(tmpDir); + + expect(ids).toEqual(['createGroup', 'deleteGroup', 'listGroups']); + // Defect-class guard: total length === unique-set size, for any spec. + expect(ids.length).toBe(new Set(ids).size); + }); + + test('returns [] when the bundled spec file is missing', async () => { + const ids = await loadSpecOperationIds(tmpDir); + expect(ids).toEqual([]); + }); +}); + +describe('renderMarkdown (#335)', () => { + test('renders a deterministic Markdown report for a known summary', () => { + const md = renderMarkdown({ + version: 2, + config: 'camunda-oca', + emitter: 'playwright', + summary: { + totalSpecOperations: 190, + emittedFeatureSpecs: 117, + suppressedByTemplate: 73, + variantSpecs: 70, + lifecycleSpecs: 26, + unmappedOperations: [], + perTemplate: [ + { + name: 'EdgeLifecycle', + specs: 14, + uniqueOperations: 28, + entries: 56, + invokeSteps: 28, + observeSteps: 28, + }, + { + name: 'EntityLifecycle', + specs: 8, + uniqueOperations: 32, + entries: 40, + invokeSteps: 24, + observeSteps: 16, + }, + ], + }, + }); + expect(md).toContain('# Coverage report — camunda-oca'); + expect(md).toContain('- Emitter: `playwright`'); + expect(md).toContain('- Spec operations: **190**'); + expect(md).toContain('- Emitted feature specs: **117**'); + expect(md).toContain('- Suppressed by scenario-template coverage: **73**'); + expect(md).toContain('- Operation coverage: **190 / 190 (100.0%)**'); + expect(md).toContain('| `EdgeLifecycle` | 14 | 28 | 56 | 28 | 28 |'); + expect(md).toContain('| `EntityLifecycle` | 8 | 32 | 40 | 24 | 16 |'); + expect(md).toContain( + '_None — every spec operation is covered by a feature or lifecycle suite._', + ); + }); + + test('renders the unmapped-operations list when non-empty', () => { + const md = renderMarkdown({ + version: 2, + config: 'demo', + emitter: 'playwright', + summary: { + totalSpecOperations: 3, + emittedFeatureSpecs: 1, + suppressedByTemplate: 0, + variantSpecs: 0, + lifecycleSpecs: 0, + unmappedOperations: ['missingOpA', 'missingOpB'], + perTemplate: [], + }, + }); + expect(md).toContain('- Unmapped operations: **2**'); + expect(md).toContain('- `missingOpA`'); + expect(md).toContain('- `missingOpB`'); + expect(md).toContain('_No template-derived coverage in this run._'); + }); + + test('throws when the artefact is missing the summary block (v1 artefact)', () => { + expect(() => renderMarkdown({ version: 1, suppressedOpIds: [], entries: [] })).toThrow( + /no 'summary' block/, + ); + }); +});