@jrpool/kilotest 57.3.0 → 58.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,18 +8,17 @@
8
8
  import {
9
9
  getReport,
10
10
  getTestActInstances,
11
- htmlSafe,
12
11
  isReportError,
13
12
  populateTemplate
14
13
  } from '../../util.ts';
15
14
  import {
15
+ getIssueFactsLines,
16
16
  getPageDataStrings,
17
+ getPageFactsLines,
17
18
  getPathID,
18
- getEngineNamesString,
19
+ getReportData,
19
20
  getTextFragmentHref,
20
- getWCAGLink,
21
- getWeightName,
22
- makeBreakable
21
+ getViolatorFactsLines
23
22
  } from '../util.ts';
24
23
  import {issues as issueSpecs} from 'testaro-issues';
25
24
 
@@ -32,8 +31,17 @@ const populateQuery = async (
32
31
  jobID: string,
33
32
  query: Record<string, any>
34
33
  ) => {
35
- // Get descriptions of the page facts.
36
- const pageDataStrings = await getPageDataStrings(timeStamp, jobID);
34
+ // Get the report.
35
+ const report = await getReport(timeStamp, jobID);
36
+ // If this failed:
37
+ if (isReportError(report)) {
38
+ // Populate the query with the reason.
39
+ query.error = report.error;
40
+ // Stop populating the query.
41
+ return;
42
+ }
43
+ // Otherwise, i.e. if it succeeded, get descriptions of the page facts.
44
+ const pageDataStrings = getPageDataStrings(report);
37
45
  // If this failed:
38
46
  if (pageDataStrings.error !== undefined) {
39
47
  // Populate the query with the reason.
@@ -50,58 +58,28 @@ const populateQuery = async (
50
58
  // Stop populating the query.
51
59
  return;
52
60
  }
53
- const {testInfo, url, urlLink, description} = pageDataStrings;
61
+ const {testInfo, url, description} = pageDataStrings;
54
62
  // Add page facts to the query.
55
63
  query.target = description;
56
- query.urlLink = urlLink;
57
64
  query.testInfo = testInfo;
58
- const issue = issueSpecs[issueID]!;
59
- const {wcag, weight, why} = issue;
60
- query.why = why;
61
- query.priority = getWeightName(weight);
62
- query.wcag = `<a href="${getWCAGLink(wcag)}">${wcag}</a>`;
63
- // Initialize those whose values depend on instance inspection.
64
- query.count = 0;
65
- query.reporters = new Set();
65
+ query.pageFacts = getPageFactsLines(
66
+ pageDataStrings, getReportData(report), ' '.repeat(6)
67
+ ).join('\n');
68
+ // Add the issue facts to the query.
69
+ query.issueFacts = getIssueFactsLines(report, issueID, ' '.repeat(6)).join('\n');
70
+ // Initialize the violator data.
66
71
  let violators: any = {};
67
- // Get the report.
68
- const report = await getReport(timeStamp, jobID);
69
- // If this failed:
70
- if (isReportError(report)) {
71
- // Populate the query with the reason.
72
- query.error = report.error;
73
- // Stop populating the query.
74
- return;
75
- }
76
72
  const {catalog} = report;
77
- // Otherwise, i.e. if it succeeded, for each violating standard instance of the issue:
78
- getTestActInstances(report, {violationsOnly: true, issueID}).forEach(({act, instance}) => {
73
+ const testActInstances = getTestActInstances(report, {violationsOnly: true, issueID});
74
+ // For each violating standard instance of the issue:
75
+ testActInstances.forEach(({instance}) => {
79
76
  const pathID = instance.pathID || '/html';
80
77
  const catalogIndex = String(instance.catalogIndex || '0');
81
- const tagName = catalog[catalogIndex]?.tagName
82
- ?? pathID.split('/').pop()!.replace(/\[.+$/, '').toUpperCase();
78
+ // Initialize the violator data if necessary.
83
79
  violators[catalogIndex] ??= {
84
- pathID: getPathID(catalog, catalogIndex, pathID),
85
- tagName,
86
- text: catalog[catalogIndex]?.text ?? '',
87
- reporters: new Set()
80
+ pathID: getPathID(catalog, catalogIndex, pathID)
88
81
  };
89
- // Ensure that the rule engine is in the sets of reporters of the violator and the issue.
90
- violators[catalogIndex].reporters.add(act.which);
91
- query.reporters.add(act.which);
92
82
  });
93
- // Populate the violator count.
94
- const violatorCount = Object.keys(violators).length;
95
- query.violatorCount = violatorCount === 1 ? '1 violator was' : `${violatorCount} violators were`;
96
- // For each violator:
97
- Object.values(violators).forEach((violatorData: any) => {
98
- // Convert the set of its reporters to a string.
99
- violatorData.reporters = getEngineNamesString(violatorData.reporters);
100
- });
101
- const reporterCount = query.reporters.size;
102
- query.reporterCount = reporterCount === 1 ? '1 rule engine' : `${reporterCount} rule engines`;
103
- // Convert the set of issue reporters to a string.
104
- query.reporters = getEngineNamesString(query.reporters);
105
83
  // Convert the violator data to an array.
106
84
  violators = Object.entries(violators).map((entry: [string, any]) => ({
107
85
  catalogIndex: entry[0],
@@ -115,23 +93,11 @@ const populateQuery = async (
115
93
  let takeMeAdviceNeeded = false;
116
94
  // For each violator:
117
95
  violators.forEach((violator: any, index: number) => {
118
- const {catalogIndex, pathID, reporters, tagName, text} = violator;
96
+ const {catalogIndex, pathID} = violator;
119
97
  // Add a heading to the lines.
120
98
  lines.push(`${margin}<li><h3>Element ${catalogIndex}</h3>`);
121
- lines.push(`${margin} <ul class="pseudoTopLevel">`);
122
- // Add properties of the violator to the lines.
123
- if (pathID) {
124
- lines.push(`${margin} <li>XPath: <code>${makeBreakable(pathID)}</code></li>`);
125
- }
126
- if (tagName) {
127
- lines.push(`${margin} <li>Tag name: <code>${tagName}</code></li>`);
128
- }
129
- if (text && !['HTML', 'HEAD', 'BODY', 'MAIN', 'NOSCRIPT'].includes(tagName)) {
130
- const textString = text.split('\n').join(' … ');
131
- lines.push(`${margin} <li>Text: <q>${htmlSafe(textString)}</q></li>`);
132
- }
133
- lines.push(`${margin} <li>Reported by ${reporters}</li>`);
134
- lines.push(`${margin} </ul>`);
99
+ // Add the violator facts to the lines.
100
+ lines.push(...getViolatorFactsLines(report, issueID, catalogIndex, pathID, `${margin} `, 'pseudoTopLevel'));
135
101
  lines.push(`${margin} <ul class="nav">`);
136
102
  if (catalogIndex) {
137
103
  const catalogItem = catalog[catalogIndex];
@@ -33,7 +33,7 @@ mock.module('../../util.ts', {
33
33
  mock.module('../util.ts', {
34
34
  exports: {
35
35
  ...realWebUtil,
36
- getPageDataStrings: async (...args: any[]) => {
36
+ getPageDataStrings: (...args: any[]) => {
37
37
  if (pageDataStringsOverride !== null) {
38
38
  return pageDataStringsOverride;
39
39
  }
@@ -86,7 +86,7 @@ test('listViolators includes links to listDiagnoses for violators', async () =>
86
86
  test('listViolators excludes cantTell instances from the violator count', async () => {
87
87
  const result = await answer('focusIndicationBad/260101T0000/mix');
88
88
  assert.equal(result.status, 'ok');
89
- assert.ok(result.answerPage.includes('0 violators were'));
89
+ assert.ok(result.answerPage.includes('<li>Violators: 0</li>'));
90
90
  assert.ok(!result.answerPage.includes('<h3>Element'));
91
91
  });
92
92
 
@@ -101,7 +101,7 @@ test('listViolators returns an error status for an unknown issue', async () => {
101
101
  assert.equal(result.status, 'error');
102
102
  });
103
103
 
104
- test('listViolators returns an error when getReport fails after getPageDataStrings succeeds', async () => {
104
+ test('listViolators returns an error when getReport fails', async () => {
105
105
  getReportCallCount = 0;
106
106
  failGetReportOnCall = 1;
107
107
  try {
@@ -114,6 +114,18 @@ test('listViolators returns an error when getReport fails after getPageDataStrin
114
114
  }
115
115
  });
116
116
 
117
+ test('listViolators returns an error when getPageDataStrings fails after getReport succeeds', async () => {
118
+ pageDataStringsOverride = {error: 'Page data strings error'};
119
+ try {
120
+ const result = await answer('linkNoText/260101T0000/mix');
121
+ assert.equal(result.status, 'error');
122
+ assert.equal(result.message, 'Page data strings error');
123
+ }
124
+ finally {
125
+ pageDataStringsOverride = null;
126
+ }
127
+ });
128
+
117
129
  test('listViolators includes take-me-there links for text-linkable violators', async () => {
118
130
  const dbDir = (await import('../../test/dbFixture.ts')).fixtureDBDir;
119
131
  const reportsDir = path.join(dbDir, 'reports');
@@ -260,7 +272,7 @@ test('listViolators handles instances with missing catalogIndex and acts with no
260
272
  const result = await answer('linkNoText/260101T0004/nvi');
261
273
  assert.equal(result.status, 'ok');
262
274
  // Should show plural violator count.
263
- assert.ok(result.answerPage.includes('2 violators were'));
275
+ assert.ok(result.answerPage.includes('<li>Violators: 2</li>'));
264
276
  }
265
277
  finally {
266
278
  await fs.unlink(reportPath).catch(() => {});
@@ -7,6 +7,8 @@
7
7
 
8
8
  import {
9
9
  errorMessage,
10
+ getReport,
11
+ isReportError,
10
12
  isValidAuthCode,
11
13
  objectSort,
12
14
  populateTemplate,
@@ -90,16 +92,18 @@ export const reportDeletionForm = async (
90
92
  // For each report:
91
93
  for (const reportName of reportNames) {
92
94
  const [timeStamp, jobID] = reportName.slice(0, -5).split('-') as [string, string];
93
- // Get a summary of it.
94
- const reportFacts = await getReportData(timeStamp, jobID);
95
+ // Get the report.
96
+ const report = await getReport(timeStamp, jobID);
95
97
  // If this failed:
96
- if (reportFacts.error !== undefined) {
98
+ if (isReportError(report)) {
97
99
  // Return why.
98
100
  return {
99
101
  status: 'error',
100
- message: reportFacts.error
102
+ message: report.error
101
103
  };
102
104
  }
105
+ // Otherwise, i.e. if it succeeded, get a summary of it.
106
+ const reportFacts = getReportData(report);
103
107
  const {issueCount, preventedEngineCount, url} = reportFacts;
104
108
  // Otherwise, i.e. if it succeeded, add the summary to the array.
105
109
  reportSpecs.push({
@@ -33,12 +33,12 @@ export const answer = async (description: string, url: string, reason: string) =
33
33
  message: 'Test request duplicates an already submitted request'
34
34
  };
35
35
  }
36
- // Otherwise, if a report is available about a page with the same description and URL:
36
+ // Otherwise, if a report is available about a page with the same description or URL:
37
37
  else if (result === 'reportExists') {
38
38
  // Report this.
39
39
  return {
40
40
  status: 'error',
41
- message: 'A report about the page is already available'
41
+ message: 'A report about a page with the same description or URL is already available'
42
42
  }
43
43
  }
44
44
  // Otherwise, if the queue of requests awaiting approval is full:
@@ -126,7 +126,7 @@ test('answer returns an error when a report already exists for the page', {timeo
126
126
  'Mixed Outcomes Page', 'https://example.com/mixed', 'Because accessibility'
127
127
  );
128
128
  assert.equal(result.status, 'error');
129
- assert.equal(result.message, 'A report about the page is already available');
129
+ assert.equal(result.message, 'A report about a page with the same description or URL is already available');
130
130
  });
131
131
 
132
132
  test('answer returns an error with a fallback channel when the request queue is full', {timeout: 500}, async () => {
@@ -15,7 +15,7 @@
15
15
  <main>
16
16
  <h1><a href="/">Kilotest</a>: Connect an AI Platform</h1>
17
17
  <h2>Purpose</h2>
18
- <p>This tutorial shows you how to make Kilotest available to AI assistants. Suppose:</p>
18
+ <p>This tutorial shows you how to make Kilotest available to assistants powered by artificial intelligence. Suppose:</p>
19
19
  <ul>
20
20
  <li>You use an AI platform, like Claude, Mistral Vibe, or Perplexity Pro.</li>
21
21
  <li>You want to audit the quality of a web page.</li>
@@ -26,6 +26,7 @@
26
26
  <li><a href="#claude">Claude.ai in a browser or Claude Desktop</a></li>
27
27
  <li><a href="#vibe">Mistral Vibe</a></li>
28
28
  <li><a href="#perplexity">Perplexity Pro in a browser</a></li>
29
+ <li><a href="#other">Other platforms</a></li>
29
30
  </ul>
30
31
  <h2 id="claude"><a href="https://claude.ai">Claude.ai in a browser</a> or <a href="https://downloads.claude.com">Claude Desktop</a></h2>
31
32
  <ol>
@@ -52,7 +53,7 @@
52
53
  <li>Click the <q>Add connector</q> button.</li>
53
54
  <li>Toggle the <q>Ask/Allow</q> buttons to show <q>Allow</q>.</li>
54
55
  </ol>
55
- <h2 id="perplexity">Perplexity Pro in a browser</h2>
56
+ <h2 id="perplexity"><a href="https://www.perplexity.ai/">Perplexity</a> Pro in a browser</h2>
56
57
  <ol>
57
58
  <li>After signing in, click the <q>Customize</q> button in the left sidebar.</li>
58
59
  <li>Click the <q>+ Custom connector</q> menu button.</li>
@@ -65,10 +66,9 @@
65
66
  <li>Click the <q>Add</q> button.</li>
66
67
  </ol>
67
68
  <p>Once you add Kilotest, it <strong>may</strong> become available in the Perplexity app on your computer or mobile device, too.</p>
68
- <h2>Other AI platforms</h2>
69
- <p>You can connect Kilotest to some other AI platforms, too. The instructions above are limited to platforms that are either free or relatively inexpensive (US$20 per month or less). If you discover an AI platform that should be added to this page, please submit a comment below.</p>
70
- <h2>Trouble?</h2>
71
- <p>What if this did not work? Use the comment form below to tell us about it.</p>
69
+ <h2 id="other">Other AI platforms</h2>
70
+ <p>You can connect Kilotest to some other AI platforms, too. The instructions above are limited to consumer platforms that are either free or relatively inexpensive (US$20 per month or less). Business platforms such as ChatGPT Business, and developer platforms such as Devin Desktop, also permit you to enable Kilotest.</p>
71
+ <p>Feel free to submit a comment below about empowering any AI platform to use Kilotest.</p>
72
72
  <p>You can also submit an issue on the <a href="https://github.com/jrpool/kilotest/issues">Kilotest GitHub repository</a>.</p>
73
73
  <section id="suggest-improvement" class="main">
74
74
  <h2>Suggest improvements</h2>
package/web/util.test.ts CHANGED
@@ -9,15 +9,17 @@ import {test, before, after} from 'node:test';
9
9
  import assert from 'node:assert/strict';
10
10
  import path from 'node:path';
11
11
  import fs from 'node:fs/promises';
12
- import {reportsPath} from '../util.ts';
12
+ import {getReport, isReportError, reportsPath} from '../util.ts';
13
13
  import {
14
14
  checkCommentLength,
15
15
  getAgoString,
16
16
  getCountString,
17
+ getEngineIDs,
17
18
  getEngineNamesString,
18
19
  getMultiReportWhats,
19
20
  getPageData,
20
21
  getPageDataStrings,
22
+ getPageFactsLines,
21
23
  getTextFragmentHref,
22
24
  getWCAGLink,
23
25
  getWeightName,
@@ -115,65 +117,79 @@ test('makeBreakable inserts wbr before non-initial slashes', () => {
115
117
  assert.equal(makeBreakable('/api/listReports'), '/api<wbr>/listReports');
116
118
  });
117
119
 
120
+ // Returns a fixture report, failing the test if it is not usable.
121
+ const getFixtureReport = async (timeStamp: string, jobID: string) => {
122
+ const report = await getReport(timeStamp, jobID);
123
+ if (isReportError(report)) {
124
+ assert.fail(report.error);
125
+ }
126
+ return report;
127
+ };
128
+
118
129
  test('getPageData returns page data for a valid report', async () => {
119
- const data = await getPageData('260101T0000', 'mix') as any;
130
+ const data = getPageData(await getFixtureReport('260101T0000', 'mix'));
120
131
  assert.equal(data.description, 'Mixed Outcomes Page');
121
132
  assert.equal(data.url, 'https://example.com/mixed');
122
133
  assert.equal(typeof data.daysAgo, 'number');
123
134
  });
124
135
 
125
- test('getPageData returns an error for a nonexistent report', async () => {
126
- const data = await getPageData('999999T9999', 'xxx') as any;
127
- assert.ok(data.error);
136
+ test('getReport returns an error for a nonexistent report', async () => {
137
+ const report = await getReport('999999T9999', 'xxx');
138
+ assert.ok(isReportError(report));
128
139
  });
129
140
 
130
141
  test('getPageDataStrings returns HTML strings for a valid report', async () => {
131
- const strings = await getPageDataStrings('260101T0000', 'mix') as any;
142
+ const strings = getPageDataStrings(await getFixtureReport('260101T0000', 'mix'));
132
143
  assert.equal(strings.description, 'Mixed Outcomes Page');
133
144
  assert.equal(strings.url, 'https://example.com/mixed');
134
145
  assert.equal(strings.urlLink, '<a href="https://example.com/mixed">https://example.com/mixed</a>');
135
- assert.ok(strings.testInfo.includes('by job <code>mix</code>'));
146
+ assert.ok(strings.testInfo.includes('by job <code>260101T0000-mix</code>'));
136
147
  assert.ok(strings.testInfo.includes('2026-01-01 at 00:00'));
137
148
  });
138
149
 
139
150
  test('getPageDataStrings returns different testInfo for a different timeStamp', async () => {
140
- const strings = await getPageDataStrings('260101T0001', 'ct') as any;
151
+ const strings = getPageDataStrings(await getFixtureReport('260101T0001', 'ct'));
141
152
  assert.equal(strings.description, 'All CantTell Page');
142
- assert.ok(strings.testInfo.includes('by job <code>ct</code>'));
153
+ assert.ok(strings.testInfo.includes('by job <code>260101T0001-ct</code>'));
143
154
  assert.ok(strings.testInfo.includes('2026-01-01 at 00:01'));
144
155
  });
145
156
 
146
- test('getPageDataStrings returns an error for a nonexistent report', async () => {
147
- const strings = await getPageDataStrings('999999T9999', 'xxx');
148
- assert.ok(strings.error);
157
+ test('getPageDataStrings derives the strings from the report object', async () => {
158
+ const report = await getFixtureReport('260101T0000', 'mix');
159
+ report.target.what = 'Custom Page';
160
+ const strings = getPageDataStrings(report);
161
+ assert.equal(strings.description, 'Custom Page');
149
162
  });
150
163
 
151
- test('getPageDataStrings uses provided pageData instead of reading the report', async () => {
152
- const strings = await getPageDataStrings('260101T0000', 'mix', {
153
- description: 'Custom Page',
154
- url: 'https://custom.com',
155
- daysAgo: 1
156
- }) as any;
157
- assert.equal(strings.description, 'Custom Page');
158
- assert.equal(strings.url, 'https://custom.com');
164
+ test('getPageDataStrings reports a 1-day-old report as tested 1 day ago', () => {
165
+ // Construct an end time 1 day and 1 hour ago, so Math.round gives exactly 1.
166
+ const date = new Date(Date.now() - (86400000 + 3600000));
167
+ const endTime = date.toISOString().slice(2, 16);
168
+ const report = {
169
+ id: '260101T0000-mix',
170
+ target: {what: 'Some Page', url: 'https://example.com'},
171
+ jobData: {endTime}
172
+ } as any;
173
+ const strings = getPageDataStrings(report);
159
174
  assert.ok(strings.testInfo.includes('1 day ago'));
160
175
  });
161
176
 
162
- test('getReportData returns an error for a nonexistent report', async () => {
177
+ test('getReportData returns data for a valid report', async () => {
163
178
  const {getReportData} = await import('./util.ts');
164
- const result: any = await getReportData('990101T0000', 'xxx');
165
- assert.ok(result.error);
179
+ const result = getReportData(await getFixtureReport('260101T0000', 'mix'));
180
+ assert.equal(result.url, 'https://example.com/mixed');
181
+ assert.equal(result.jobName, '260101T0000-mix');
166
182
  });
167
183
 
168
184
  test('getReportData falls back to the engine ID for an unknown prevented engine', async () => {
169
185
  const {getReportData} = await import('./util.ts');
170
186
  const prvJSON = await fs.readFile(path.join(reportsPath(), '260101T0006-prv.json'), 'utf8');
171
- const report = JSON.parse(prvJSON);
172
- report.jobData.preventions.unknownEngine = 'mystery failure';
187
+ const reportJSON = JSON.parse(prvJSON);
188
+ reportJSON.jobData.preventions.unknownEngine = 'mystery failure';
173
189
  const reportPath = path.join(reportsPath(), '260103T0000-unk.json');
174
- await fs.writeFile(reportPath, JSON.stringify(report));
190
+ await fs.writeFile(reportPath, JSON.stringify(reportJSON));
175
191
  try {
176
- const result: any = await getReportData('260103T0000', 'unk');
192
+ const result = getReportData(await getFixtureReport('260103T0000', 'unk'));
177
193
  assert.ok(result.preventedEngineNames.includes('unknownEngine'));
178
194
  }
179
195
  finally {
@@ -181,6 +197,44 @@ test('getReportData falls back to the engine ID for an unknown prevented engine'
181
197
  }
182
198
  });
183
199
 
200
+ test('getPageFactsLines returns the about-page list lines for a report', async () => {
201
+ const {getReportData} = await import('./util.ts');
202
+ const report = await getFixtureReport('260101T0000', 'mix');
203
+ const lines = getPageFactsLines(getPageDataStrings(report), getReportData(report), ' ');
204
+ const html = lines.join('\n');
205
+ assert.ok(html.includes('<li>URL: <a href="https://example.com/mixed">'));
206
+ assert.ok(html.includes('<li>Summary of results:'));
207
+ assert.ok(html.includes('<li>Called: 2</li>'));
208
+ assert.ok(html.includes('<li>Violations: 3</li>'));
209
+ assert.ok(html.includes('<li>Issues: 2</li>'));
210
+ });
211
+
212
+ test('getIssueFactsLines returns the about-issue list lines for a report', async () => {
213
+ const {getIssueFactsLines} = await import('./util.ts');
214
+ const report = await getFixtureReport('260101T0000', 'mix');
215
+ const lines = getIssueFactsLines(report, 'linkNoText', ' ');
216
+ const html = lines.join('\n');
217
+ assert.ok(html.includes('<li>Why it matters:'));
218
+ assert.ok(html.includes('<li>Priority:'));
219
+ assert.ok(html.includes('<li>Related WCAG standard: <a href='));
220
+ assert.ok(html.includes('<li>Reported by'));
221
+ assert.ok(html.includes('<li>Violations:'));
222
+ assert.ok(html.includes('<li>Violators:'));
223
+ });
224
+
225
+ test('getViolatorFactsLines returns the about-violator list lines for a report', async () => {
226
+ const {getViolatorFactsLines} = await import('./util.ts');
227
+ const report = await getFixtureReport('260101T0000', 'mix');
228
+ const lines = getViolatorFactsLines(report, 'linkNoText', '0', null, ' ');
229
+ const html = lines.join('\n');
230
+ assert.ok(html.includes('<li>Tag name: <code>'));
231
+ assert.ok(html.includes('<li>Text:'));
232
+ assert.ok(html.includes('<li>Start tag: <code>'));
233
+ assert.ok(html.includes('<li>XPath: <code>'));
234
+ assert.ok(html.includes('<li>Bounding box:'));
235
+ assert.ok(html.includes('<li>Reported by'));
236
+ });
237
+
184
238
  test('getAgoString returns "1 day" for exactly 1 day ago', () => {
185
239
  // Construct a time stamp 1 day and 1 hour ago, so Math.round gives exactly 1.
186
240
  const date = new Date(Date.now() - (86400000 + 3600000));
@@ -238,3 +292,45 @@ test('getMultiReportWhats returns descriptions that have multiple reports', asyn
238
292
  const whats = await getMultiReportWhats();
239
293
  assert.ok(whats.includes('Mixed Outcomes Page'));
240
294
  });
295
+
296
+ // Returns a minimal report for testing rule-engine identification.
297
+ const makeEngineReport = (whiches: string[], preventions: Record<string, string>) => ({
298
+ acts: whiches.map(which => ({type: 'test', which})),
299
+ jobData: {preventions}
300
+ });
301
+
302
+ test('getEngineIDs counts a prevented engine, which has an act, once', () => {
303
+ const ids = getEngineIDs(makeEngineReport(['axe', 'wave'], {wave: 'failed'}));
304
+ assert.deepEqual(ids.calledIDs.sort(), ['axe', 'wave']);
305
+ assert.deepEqual(ids.preventedIDs, ['wave']);
306
+ });
307
+
308
+ test('getEngineIDs counts a prevented engine that has no act', () => {
309
+ const ids = getEngineIDs(makeEngineReport(['axe'], {alfa: 'failed'}));
310
+ assert.deepEqual(ids.calledIDs.sort(), ['alfa', 'axe']);
311
+ assert.deepEqual(ids.preventedIDs, ['alfa']);
312
+ });
313
+
314
+ test('getEngineIDs counts nuVal and nuVnu as one engine when nuVal succeeds', () => {
315
+ const ids = getEngineIDs(makeEngineReport(['nuVal', 'nuVnu'], {}));
316
+ assert.deepEqual(ids.calledIDs, ['nuVal']);
317
+ assert.deepEqual(ids.preventedIDs, []);
318
+ });
319
+
320
+ test('getEngineIDs ignores a nuVal prevention when nuVnu succeeds', () => {
321
+ const ids = getEngineIDs(makeEngineReport(['nuVal', 'nuVnu'], {nuVal: 'failed'}));
322
+ assert.deepEqual(ids.calledIDs, ['nuVal']);
323
+ assert.deepEqual(ids.preventedIDs, []);
324
+ });
325
+
326
+ test('getEngineIDs counts one prevention when both nuVal and nuVnu are prevented', () => {
327
+ const ids = getEngineIDs(makeEngineReport(['nuVal', 'nuVnu'], {nuVal: 'failed', nuVnu: 'failed'}));
328
+ assert.deepEqual(ids.calledIDs, ['nuVal']);
329
+ assert.deepEqual(ids.preventedIDs, ['nuVal']);
330
+ });
331
+
332
+ test('getEngineIDs counts a nuVal prevention when nuVnu was not called', () => {
333
+ const ids = getEngineIDs(makeEngineReport(['nuVal'], {nuVal: 'failed'}));
334
+ assert.deepEqual(ids.calledIDs, ['nuVal']);
335
+ assert.deepEqual(ids.preventedIDs, ['nuVal']);
336
+ });