Skip to content

Commit 1a3b653

Browse files
authored
Merge pull request #158 from laurentftech/fix/test-coverage-scenario-counting
fix(test-coverage): count only real scenarios, dedupe, fix the percentage
2 parents 52e3ec1 + 143e790 commit 1a3b653

2 files changed

Lines changed: 72 additions & 9 deletions

File tree

src/core/test-generator/coverage-analyzer.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,48 @@ describe("test 3") {}
162162
expect(report.byDomain['auth']?.hasDrift).toBe(true);
163163
});
164164

165+
it('ignores tags pointing at scenarios that do not exist in the parsed specs', async () => {
166+
// Regression: example/fixture tags (e.g. a foreign "billing" domain) living
167+
// in the test suite must NOT count as coverage, must not appear in `covered`,
168+
// and must not inflate coveragePercent.
169+
const testDir = join(tmpDir, 'spec-tests', 'auth');
170+
await mkdir(testDir, { recursive: true });
171+
await writeFile(
172+
join(testDir, 'mixed.spec.ts'),
173+
[
174+
// real
175+
'// openlore: {"domain":"auth","requirement":"UserLogin","scenario":"SuccessfulLogin"}',
176+
// bogus — domain/requirement/scenario not in AUTH_SPEC
177+
'// openlore: {"domain":"billing","requirement":"Invoice","scenario":"Paid"}',
178+
'// openlore: {"domain":"auth","requirement":"UserLogin","scenario":"DoesNotExist"}',
179+
].join('\n')
180+
);
181+
182+
const report = await analyzeTestCoverage({ rootPath: tmpDir, testDirs: ['spec-tests'] });
183+
184+
expect(report.coveredScenarios).toBe(1); // only the real one
185+
expect(report.coveragePercent).toBe(33.3); // 1 / 3, not 3/3
186+
expect(report.covered.map((c) => c.scenarioName)).toEqual(['SuccessfulLogin']);
187+
expect(report.covered.some((c) => c.domain === 'billing')).toBe(false);
188+
// invariant: covered + uncovered = total
189+
expect(report.coveredScenarios + report.uncovered.length).toBe(report.totalScenarios);
190+
});
191+
192+
it('dedupes a scenario tagged by multiple files', async () => {
193+
const testDir = join(tmpDir, 'spec-tests', 'auth');
194+
await mkdir(testDir, { recursive: true });
195+
const tag = '// openlore: {"domain":"auth","requirement":"UserLogin","scenario":"SuccessfulLogin"}';
196+
await writeFile(join(testDir, 'a.spec.ts'), tag + '\ndescribe("a") {}');
197+
await writeFile(join(testDir, 'b.spec.ts'), tag + '\ndescribe("b") {}');
198+
199+
const report = await analyzeTestCoverage({ rootPath: tmpDir, testDirs: ['spec-tests'] });
200+
201+
expect(report.coveredScenarios).toBe(1);
202+
expect(report.taggedScenarios).toBe(1);
203+
expect(report.covered).toHaveLength(1);
204+
expect(report.byDomain['auth'].covered).toBe(1);
205+
});
206+
165207
it('supports Python # openlore: tags', async () => {
166208
const testDir = join(tmpDir, 'spec-tests', 'auth');
167209
await mkdir(testDir, { recursive: true });

src/core/test-generator/coverage-analyzer.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -279,14 +279,32 @@ export async function analyzeTestCoverage(opts: {
279279
}
280280

281281
// ── 5. Build final covered / uncovered sets ──────────────────────────────
282-
const allCovered: CoveredScenario[] = [...tagCovered];
283-
for (const [, entry] of semanticCovered) {
284-
allCovered.push(entry);
285-
}
286-
287-
const allCoveredKeys = new Set(
288-
allCovered.map((c) => `${c.domain}::${c.requirement}::${c.scenarioName}`)
282+
// Coverage is counted ONLY against scenarios that actually exist in the parsed
283+
// specs. Two guards:
284+
// - drop tags whose scenario isn't a real parsed scenario (e.g. example/
285+
// fixture tags living inside the test suite itself, like the auth specs in
286+
// this analyzer's own tests) — otherwise they inflate the count and make
287+
// `covered + uncovered ≠ total`.
288+
// - dedupe by scenario key so several files tagging the same scenario, or a
289+
// repeated tag, count once.
290+
const scenarioKeys = new Set(
291+
allScenarios.map((s) => `${s.domain}::${s.requirement}::${s.scenarioName}`)
289292
);
293+
const keyOf = (c: CoveredScenario): string =>
294+
`${c.domain}::${c.requirement}::${c.scenarioName}`;
295+
296+
// tag entries first, then semantic — first write wins on dedupe, so a tagged
297+
// scenario keeps its tag attribution.
298+
const rawCovered: CoveredScenario[] = [...tagCovered, ...semanticCovered.values()];
299+
const allCovered: CoveredScenario[] = [];
300+
const allCoveredKeys = new Set<string>();
301+
for (const c of rawCovered) {
302+
const k = keyOf(c);
303+
if (!scenarioKeys.has(k)) continue; // not a real scenario — ignore
304+
if (allCoveredKeys.has(k)) continue; // already counted
305+
allCoveredKeys.add(k);
306+
allCovered.push(c);
307+
}
290308

291309
const uncovered: UncoveredScenario[] = allScenarios
292310
.filter((s) => !allCoveredKeys.has(`${s.domain}::${s.requirement}::${s.scenarioName}`))
@@ -328,10 +346,13 @@ export async function analyzeTestCoverage(opts: {
328346
}
329347

330348
// ── 8. Totals ────────────────────────────────────────────────────────────
349+
// All derived from the deduped, real-scenario-only `allCovered`, so the
350+
// invariants hold: coveredScenarios = taggedScenarios + discoveredScenarios,
351+
// and coveredScenarios + uncovered.length = totalScenarios.
331352
const totalScenarios = allScenarios.length;
332-
const taggedScenarios = tagCovered.length;
333-
const discoveredScenarios = semanticCovered.size;
334353
const coveredScenarios = allCovered.length;
354+
const taggedScenarios = allCovered.filter((c) => c.discoveredBy === 'tag').length;
355+
const discoveredScenarios = allCovered.filter((c) => c.discoveredBy === 'semantic').length;
335356
const coveragePercent =
336357
totalScenarios === 0
337358
? 0

0 commit comments

Comments
 (0)