diff --git a/docs/coverage.md b/docs/coverage.md
index efeadd3..5da62a9 100644
--- a/docs/coverage.md
+++ b/docs/coverage.md
@@ -28,23 +28,26 @@ Point it at a repository:
```console
$ perch coverage
Source files Mutation score Survived No coverage
-src/cart.ts 71% (10 of 14) 4 0
src/checkout.ts 60% (3 of 5) 2 0
src/inventory.ts 63% (5 of 8) 2 0
+src/cart.ts 71% (10 of 14) 4 0
All source 67% (18 of 27) 8 0
Test files Quality Duplicates Checks nothing Live services
test/cart.test.ts 43% (3 of 7) 4 0 1
-test/checkout.test.ts 100% (1 of 1) 0 0 0
test/inventory.test.ts 75% (3 of 4) 0 1 0
All tests 58% (7 of 12) 4 1 1
-
-src/cart.ts
- ID Line Problem Confidence Test or method Note
- 0399f832 12 survived 87% applyDiscount With `101` instead of `100`, none of the …
- 1be27338 12 survived 87% applyDiscount With `>` instead of `>=`, none of the 5 t…
- 20fb814a 12 survived 87% applyDiscount With `1` instead of `0`, none of the 5 te…
- 883b4b21 12 survived 84% applyDiscount With `false` as the condition, none of th…
+1 test file has nothing to fix.
+
+Where to add tests
+ Method Where Killed Survived Tests
+ applyDiscount src/cart.ts:11 6 of 10 4 5
+ Add a test that asserts on the value `100` at line 12. 3 more edits survive.
+ placeOrder src/checkout.ts:5 3 of 5 2 1
+ Add a test in which the condition at line 6 is true, and assert on what follows. 1 more edit
+ survives.
+ canFulfil src/inventory.ts:4 5 of 8 2 4
+ Add a test that asserts on the value `0` at line 6. 1 more edit survives.
test/cart.test.ts
ID Line Problem Confidence Test or method Note
@@ -57,7 +60,7 @@ test/cart.test.ts
test/inventory.test.ts
ID Line Problem Confidence Test or method Note
98a1a024 23 mocked - canFulfil > returns … Mocks every method it calls: canFul…
-shop at commit 0ade512: 4 methods, 12 tests, 14 problems, 10 shown, --all for the rest
+shop at commit 0ade512: 4 methods, 12 tests, 14 problems, --all for every mutant
Report: .perch/coverage/index.html
28 requests 0 tokens in $0.0000
```
@@ -65,21 +68,31 @@ Report: .perch/coverage/index.html
The mutation score is the share of mutants killed by at least one test. A test
kills a mutant when Perch puts its chance of failing against it at 70% or more.
No coverage counts the mutants in methods no test reaches through the call
-graph. They count against the score. Perch lists each survived mutant with the
-edit it made and the number of tests that miss it. The confidence is how sure
-Perch is that no test kills the mutant and that the mutant changes what a caller
-sees. A test that checks nothing kills no mutant in the code it reaches. A
-duplicate kills exactly the mutants an earlier test kills. A test that mocks
-what it tests calls only methods it has replaced with its own mocks, so it
-checks the mocks. That is a fact of the call graph, so it is listed with `-`
-for confidence. So is a problem on a test or method the model couldn't be asked
-about. Use `--all` to see every row.
-
-Perch also writes an HTML report. It shows each line of source with its problems
-under it. For a survived mutant, it shows the original and the mutated version
-of the line, and the tests that still pass. The report is one file, unless your
-repository has more than 8 MB of source. Then each file gets its own page in
-the `files/` directory next to `index.html`.
+graph. They count against the score. Source files are listed worst first, and
+test files only when something in them needs fixing.
+
+Under the tables, Perch lists the methods to add a test to, most survived
+mutants first. Each has a line of numbers and, under it, the test to add, from
+its surest survived mutant: the edit a new test has to tell apart, and the line
+it is on. `--all` lists every method, and every survived mutant with the id
+that `perch close` takes. Test problems follow, by file. The confidence is how
+sure Perch is. A test that checks nothing kills no mutant in the code it
+reaches. A duplicate kills exactly the mutants an earlier test kills. A test
+that mocks what it tests calls only methods it has replaced with its own mocks,
+so it checks the mocks. That is a fact of the call graph, so it is listed with
+`-` for confidence. So is a problem on a test or method the model couldn't be
+asked about.
+
+Perch also writes an HTML report. It opens on the methods to add a test to,
+each with its score, how many tests reach it, and the test to add; the mutants
+fold out under the row. Source files are a tree of directories, worst first,
+with each score coloured against 60% and 80% as Stryker colours its own. A
+file's page shows its code with a mark in the gutter on each line where a
+mutant survived; press the mark to see each edit, the line both ways, and a
+Copy prompt button, or step through the survivors with the Next survived
+button. The report is one file, unless your repository has more than 8 MB of
+source. Then each file gets its own page in the `files/` directory next to
+`index.html`.
## How it differs from a coverage report
@@ -200,17 +213,15 @@ changed on the branch:
```console
$ perch coverage --since main
-src/cart.ts
- ID Line Problem Confidence Test or method Note
- 20fb814a 12 survived 86% applyDiscount With `1` instead of `0`, none of the 5 te…
- 1be27338 12 survived 85% applyDiscount With `>` instead of `>=`, none of the 5 t…
- 0399f832 12 survived 84% applyDiscount With `101` instead of `100`, none of the …
- 883b4b21 12 survived 81% applyDiscount With `false` as the condition, none of th…
+Where to add tests
+ Method Where Killed Survived Tests
+ applyDiscount src/cart.ts:11 6 of 10 4 5
+ Add a test that asserts on what line 12 returns. 3 more edits survive.
test/cart.test.ts
ID Line Problem Confidence Test or method Note
427fce7a 22 redundant 76% applyDiscount > tak… Kills the same mutants as applyDis…
-shop at commit 6fe98ce: 4 methods, 12 tests, 5 problems in changed code, 9 elsewhere
+shop at commit 6fe98ce: 4 methods, 12 tests, 5 problems in changed code, 9 elsewhere, --all for every mutant
Report: .perch/coverage/index.html
28 requests 0 tokens in $0.0000
```
diff --git a/src/coverage-advice.js b/src/coverage-advice.js
new file mode 100644
index 0000000..edac7bc
--- /dev/null
+++ b/src/coverage-advice.js
@@ -0,0 +1,40 @@
+/**
+ * What test to add for a survived mutant, in one sentence, from the kind of edit it is. The sentence is fixed wording around the
+ * report's own facts: the line, and the text the edit changed. It says what a test has to tell apart, not how to write it.
+ */
+
+/** A piece of code in a sentence, cut to one line of it. */
+const edit = text => {
+ const one = String(text ?? '').trim().replace(/\s+/g, ' ');
+ return `\`${one.length > 60 ? `${one.slice(0, 57)}…` : one}\``;
+};
+
+/** The other value of a forced condition: a test is missing for the case the mutant removed. */
+const otherBranch = to => (/^(true|True)$/.test(String(to).trim()) ? 'false' : 'true');
+
+export function adviceFor(mutant) {
+ const line = mutant.line;
+ switch (mutant.kind) {
+ case 'body': return 'Add a test that fails when the method does nothing.';
+ case 'removal': return `Add a test that fails when ${edit(mutant.from)} is not called.`;
+ case 'block': return `Add a test that fails when the block at line ${line} is skipped.`;
+ case 'condition': return `Add a test in which the condition at line ${line} is ${otherBranch(mutant.to)}, and assert on what follows.`;
+ case 'boundary': return `Add a test at the boundary of ${edit(mutant.from)} on line ${line}, where ${edit(mutant.from)} and ${edit(mutant.to)} give different results.`;
+ case 'logic': return `Add a test in which only one side of ${edit(mutant.from)} on line ${line} holds.`;
+ case 'string': return `Add a test that asserts on the text ${edit(mutant.from)} at line ${line}.`;
+ case 'number': return `Add a test that asserts on the value ${edit(mutant.from)} at line ${line}.`;
+ case 'return': return `Add a test that asserts on what line ${line} returns.`;
+ case 'arithmetic': case 'update': return `Add a test that asserts on the arithmetic at line ${line}, where ${edit(mutant.from)} can become ${edit(mutant.to)}.`;
+ default: return `Add a test that asserts on the result of line ${line}, where ${edit(mutant.from)} can become ${edit(mutant.to)} unnoticed.`;
+ }
+}
+
+/**
+ * The test to add for a method, from its surest survived mutant, and how many more edits survive beside it. `mutants` are the
+ * method's listed survived mutants, surest first.
+ */
+export function methodAdvice(mutants) {
+ if (!mutants.length) return '';
+ const more = mutants.length - 1;
+ return `${adviceFor(mutants[0])}${more ? ` ${more} more ${more === 1 ? 'edit survives' : 'edits survive'}.` : ''}`;
+}
diff --git a/src/coverage-html.js b/src/coverage-html.js
index e9856b9..859c7ef 100644
--- a/src/coverage-html.js
+++ b/src/coverage-html.js
@@ -5,6 +5,7 @@
*/
import { createHash } from 'node:crypto';
import { WORDMARK, WORDMARK_LIGHT } from './brand.js';
+import { methodAdvice } from './coverage-advice.js';
/** Source text and names come from the repository being read, so every one of them is escaped before it becomes markup. */
const ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
@@ -27,10 +28,10 @@ const when = stamp => String(stamp ?? '').replace('T', ' ').replace(/:\d\d(\.\d+
const counted = (count, one, many = `${one}s`) => `${escape(count)} ${count === 1 ? one : many}`;
/**
- * The colour a percentage is drawn in. The bands are the ones Istanbul draws, so a page that looks like a coverage report reads
- * like one too. They colour a number the report gave; they do not grade anything the number does not already say.
+ * The colour a percentage is drawn in: red under 60, amber to 80, green from there, the thresholds Stryker ships with, so a score
+ * here reads the same as one in its report. They colour a number the report gave; they do not grade anything it does not say.
*/
-const band = value => (value === null || value === undefined ? 'none' : value < 0.5 ? 'low' : value < 0.8 ? 'mid' : 'high');
+const band = value => (value === null || value === undefined ? 'none' : value < 0.6 ? 'low' : value < 0.8 ? 'mid' : 'high');
/**
* A link to a line of a file: a section of this page, or with `page`, the page of its own that file was written to. The path
@@ -224,44 +225,92 @@ const copyAll = '
');
- expect(under(html, 'src/cart.py', 6)).toContain('Still passes: test_discount_10 and test_discount_20.');
- expect(under(html, 'src/cart.py', 6)).not.toContain('% sure');
- // The tests that reach the method are listed once, in its bar above the code, not again in the gap's note.
- expect(under(html, 'src/cart.py', 6)).not.toContain('Reached by');
+ // The line's note holds its survived mutants, folded until the mark in the gutter is pressed: each one's edit in words and the
+ // line both ways; no percentage when perch is sure (82% is sure enough).
+ const note = under(html, 'src/cart.py', 6);
+ expect(note).toContain('▲');
+ expect(note).toContain('
1 survived mutanton line 6
');
+ expect(note).toContain('
With `>=` instead of `>`, none of the 2 tests reaching it fails.
');
+ expect(note).toContain('
- if percent > 100:\n+ if percent >= 100:
');
+ expect(note).not.toContain('% sure');
+ // The tests that reach the method are listed once, in its bar above the code, not again under every mutant: a survived mutant
+ // is one none of them kills, so a list of which still pass would be the same list.
+ expect(note).not.toContain('Still passes');
+ expect(note).not.toContain('Reached by');
expect(under(html, 'src/cart.py', 2)).toContain('test_discount_10');
+ // The bar above the method says what test to add, from its surest survived mutant.
+ expect(under(html, 'src/cart.py', 2)).toContain('Add a test at the boundary of `>` on line 6, where `>` and `>=` give different results.');
expect(under(html, 'src/cart.py', 5)).not.toContain('Survived mutant');
expect(under(html, 'src/cart.py', 7)).not.toContain('Survived mutant');
// A method no test reaches is said so above its code, and is not a problem: the call graph alone lists nothing.
@@ -157,12 +164,19 @@ describe('coverage HTML report', () => {
expect(under(html, 'tests/test_cart.py', 3)).not.toContain('class="note');
expect(lineClasses(html, 'tests/test_cart.py', 3)).toEqual(['l']);
expect(lineClasses(html, 'tests/test_cart.py', 8)).toEqual(['l', 'weak']);
- // A test file opens on its problems, each linking to its line.
- const file = html.slice(html.indexOf('1');
- expect(problems).toContain('tests/test_cart.py:8');
- expect(file.indexOf('file-problems')).toBeLessThan(file.indexOf('class="code"'));
+ // A source file opens on the methods to add a test to, then its code; a test file opens on its code.
+ const cart = html.slice(html.indexOf('1');
+ expect(methods).toContain('apply_discount');
+ expect(cart.indexOf('file-methods')).toBeLessThan(cart.indexOf('class="code"'));
+ expect(cart).not.toContain('file-problems');
+ const tests = html.slice(html.indexOf('↓ Next survived1 line');
+ expect(legend).toContain(' open every note');
});
it('names each problem on a test', () => {
@@ -230,7 +244,7 @@ describe('coverage HTML report', () => {
expect(score).toContain('3 of 6 mutants killed, 50% on covered code');
expect(card(html, 'No coverage')).toContain('0 of 6');
expect(card(html, 'Survived')).toContain('1 of 6');
- const cartRow = viewOf(html, 'sources').match(/
]*>.*?<\/tr>/s)[0];
expect(cartRow).toContain('3/6');
// The tests tile is how many tests could go.
const tests = card(html, 'Redundant tests');
@@ -255,15 +269,17 @@ describe('coverage HTML report', () => {
const summary = viewOf(html, 'summary');
expect(summary).not.toContain('class="lede"');
// Two lists and no per-file tables: those are the Source files and Tests tabs.
- expect([...summary.matchAll(/
([^<]+?) match[1])).toEqual(['Survived mutants', 'Test problems']);
- // Survived mutants, one row per method. A method no test reaches is not in the list: the call graph alone is not a problem.
- const survivedList = summary.slice(summary.indexOf('
Survived mutants '), summary.indexOf('
Test problems '));
- expect(survivedList).toContain('Survived mutantapply_discount');
- expect(survivedList).not.toContain('>total<');
- // The mutant in words; the code is one click away, at the line.
- expect(survivedList).toContain('
With `>=` instead of `>`, none of the 2 tests reaching it fails.
([^<]+?) match[1])).toEqual(['Where to add tests', 'Test problems']);
+ // Methods to add a test to, one row each with the test to add. A method no test reaches is not in the list: the call graph
+ // alone is not a problem.
+ const methodList = summary.slice(summary.indexOf('
Where to add tests '), summary.indexOf('
Test problems '));
+ expect(methodList).toContain('apply_discountsrc/cart.py:3');
+ expect(methodList).toContain('
1
2
Add a test at the boundary of `>` on line 6, where `>` and `>=` give different results.');
+ expect(methodList).toContain('Copy prompt');
+ expect(methodList).not.toContain('>total<');
+ // The mutants fold under the row, each in words with a link to its line; the code is there.
+ expect(methodList).toContain(`
line 6 With \`>=\` instead of \`>\`, none of the 2 tests reaching it fails.
Test problems '));
expect(testProblems).toContain('Duplicate testtest_discount_20');
// The first review is short lists: each problem once, not every list again.
@@ -276,7 +292,7 @@ describe('coverage HTML report', () => {
expect(discount).toContain('3 of 6 mutants killed');
expect(lineClasses(html, 'src/cart.py', 4)).toEqual(['l', 'part']);
expect(lineClasses(html, 'src/cart.py', 6)).toEqual(['l', 'part', 'gapline']);
- expect(under(html, 'src/cart.py', 6)).toContain('Survived mutant');
+ expect(under(html, 'src/cart.py', 6)).toContain('1 survived mutant');
// No test reaches total.
expect(under(html, 'src/cart.py', 13)).toContain('No test reaches it');
expect(lineClasses(html, 'src/cart.py', 15)).toEqual(['l', 'none']);
@@ -291,6 +307,10 @@ describe('coverage HTML report', () => {
// No second list of what is new: the problem is where it belongs, marked and first.
expect(summary).not.toContain('New since');
expect(summary).toContain('newDuplicate testtest_discount_20');
+ // A method whose survived mutant is new is marked too.
+ const fresh = sampleReport();
+ fresh.diff.findings.new = [edge];
+ expect(viewOf(renderCoverageHtml(fresh), 'summary')).toContain('newapply_discount');
expect(summary.split('test_discount_20')).toHaveLength(2);
// Changes opens on them too.
const changes = viewOf(html, 'changes');
@@ -305,8 +325,8 @@ describe('coverage HTML report', () => {
const summary = viewOf(page, 'summary');
expect(summary).not.toContain('new-badge');
// The lists hold only the changed code's problems.
- const lists = summary.slice(summary.indexOf('
Where to add tests '));
+ expect(lists).toContain('apply_discount');
expect(summary).not.toContain('test_discount_20');
// All problems marks which rows are in changed code, and can show those alone.
const problems = viewOf(page, 'problems');
@@ -332,13 +352,13 @@ describe('coverage HTML report', () => {
const buttons = '
`);
expect(under(html, 'src/cart.py', 6)).toContain(buttons);
expect(html).not.toContain('class="dismiss"');
- // Each list of problems has one button for all of it: the file's Problems box, Risk, and All problems.
+ // Each list of problems has one button for all of it: the file's methods, the summary's, and All problems.
const file = html.slice(html.indexOf('Copy prompt');
- expect(viewOf(html, 'summary')).toContain('
');
expect(viewOf(html, 'problems')).toContain('data-copy-all');
// No app links: the prompt is copied, to paste into whichever agent you use.
expect(html).not.toMatch(/claude-cli:|codex:\/\/|cursor:\/\//);
@@ -378,12 +398,43 @@ describe('coverage HTML report', () => {
it('breaks each test file down by why tests are not worth keeping, and each source file by survived mutants', () => {
const heads = view => [...viewOf(html, view).matchAll(/
');
- // cart.py has the one survived mutant, and no mutant without coverage.
- expect(viewOf(html, 'sources').match(/
.*?<\/tr>/s)[0]).toContain('
1
0
');
+ // cart.py: three mutants killed, one survived, none without coverage.
+ expect(viewOf(html, 'sources').match(/
]*>.*?<\/tr>/s)[0]).toContain('
3
1
0
');
+ });
+
+ it('lays the source files out as a tree, worst first, and lists only the test files with something to fix', () => {
+ const sources = viewOf(html, 'sources');
+ // One directory, src, with its files' numbers summed: 3 of 6 killed, 1 survived; the worse file first.
+ expect(sources).toContain('
');
+ expect(sources).not.toContain('data-closed');
+ // A chain of directories with nothing but the next in them is one row.
+ const deep = sampleReport();
+ deep.files[1].path = 'lib/shop/util/money.py';
+ deep.methods[2].path = deep.files[1].path;
+ expect(viewOf(renderCoverageHtml(deep), 'sources')).toContain('>lib/shop/util/ 1');
+ // Past two hundred files, every directory under the top starts folded.
+ const many = sampleReport();
+ many.files = [...many.files, ...Array.from({ length: 200 }, (_, at) => ({ ...many.files[1], path: `src/more/file${at}.py`, methods: [] }))];
+ const big = viewOf(renderCoverageHtml(many), 'sources');
+ expect(big).toContain('
');
+ expect(big).toContain('
');
+ expect(tests).toContain('
1 test file has nothing to fix.Show them');
+ expect(viewOf(html, 'tests')).not.toContain('nothing to fix');
});
it('fetches nothing from anywhere', () => {
@@ -422,15 +473,15 @@ describe('coverage HTML report', () => {
expect(renderCoverageSite(sampleReport()).index.join('')).toBe(renderCoverageHtml(sampleReport()));
});
- it('names the nearest fifty tests that reach a method and counts the rest', () => {
+ it('names the eight nearest tests that reach a method, the ones it asked, and counts the rest', () => {
const report = sampleReport();
const method = report.methods.find(item => item.id === 'src/cart.py::apply_discount');
const reaching = Array.from({ length: 53 }, (_, at) => ({ ...report.tests[0], id: `tests/test_cart.py::t${at}`, name: `t${at}` }));
report.tests.push(...reaching);
method.tests = reaching.map((test, at) => ({ id: test.id, depth: at < 3 ? 3 : 1 }));
const bar = renderCoverageHtml(report).match(/