Repository navigation
Expand file tree
/
Copy pathtestState.ts
More file actions
161 lines (141 loc) · 8.13 KB
/
Copy pathtestState.ts
File metadata and controls
161 lines (141 loc) · 8.13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
/**
* What the workspace shows of the script's tests, worked out from a compile's report: the
* results kept from one run to the next, a test's and a `describe`'s mark, what is said at
* the end of a failing line, the warnings for Problems, and the Testing view's tree —
* folder, file, `describe`, test. Pure over the report, so the tests need no editor.
*/
import type { TestInfo, TestReport, TestResult } from "./compiler/testing";
import type { ScriptWarning, TestMark, TestNote } from "./monaco";
import { buildTree, type TreeNode } from "./tree";
import { english, msg, type Translate } from "./i18n";
export type TestStateName = TestMark["state"];
export interface TestState {
list: TestInfo[];
/** By test id: the last result of each test the script still has. */
results: Map<string, TestResult>;
only: TestReport["only"];
}
export const NO_TESTS: TestState = { list: [], results: new Map(), only: [] };
/**
* The state after a report. A run of some tests leaves the others' results as they were; a
* report that only lists (the script has problems elsewhere, or nothing asked for a run)
* leaves them all. A result whose test is gone goes with it.
*/
export function mergeReport(before: TestState, report: TestReport | null): TestState {
if (!report) return NO_TESTS;
const ids = new Set(report.list.map((t) => t.id));
const results = new Map([...before.results].filter(([id]) => ids.has(id)));
for (const r of report.results) results.set(r.id, r);
return { list: report.list, results, only: report.only };
}
export interface TestCounts { total: number; passed: number; failed: number; skipped: number; notRun: number }
export function countTests(state: TestState, under?: (t: TestInfo) => boolean): TestCounts {
const c: TestCounts = { total: 0, passed: 0, failed: 0, skipped: 0, notRun: 0 };
for (const t of state.list) {
if (t.kind !== "test" || (under && !under(t))) continue;
c.total++;
const r = state.results.get(t.id);
if (!r) c.notRun++;
else c[r.status]++;
}
return c;
}
/** Failed when any of them failed, passed when all that ran passed, skipped when all were skipped, else not run. */
export const stateOfCounts = (c: TestCounts): TestStateName => (c.failed ? "failed" : c.total === 0 || c.notRun === c.total ? "none" : c.passed ? "passed" : c.skipped ? "skipped" : "none");
/** Whether `t` is under the suite `suite`. */
const inside = (suite: TestInfo) => (t: TestInfo) => t.id === suite.id || t.id.startsWith(`${suite.id} > `);
export function stateOf(state: TestState, info: TestInfo): TestStateName {
if (info.kind === "suite") return stateOfCounts(countTests(state, inside(info)));
return state.results.get(info.id)?.status ?? "none";
}
const STATE_WORDS: Record<TestStateName, string> = { none: msg("Not run yet"), passed: msg("Passed"), failed: msg("Failed"), skipped: msg("Skipped"), running: msg("Running…") };
export function marksOf(state: TestState, t: Translate = english): TestMark[] {
return state.list.map((info) => {
const s = stateOf(state, info);
const r = state.results.get(info.id);
return { file: info.file, line: info.line, state: s, id: info.id, title: `${t(STATE_WORDS[s])}${r?.status === "failed" && r.message ? `: ${r.message}` : ""} — ${info.kind === "suite" ? t("click to run these tests") : t("click to run this test")}` };
});
}
/** A failure is said where it is: at the end of the failing `expect`'s line, the two values in full on hover. */
export function notesOf(state: TestState, t: Translate = english): TestNote[] {
const notes: TestNote[] = [];
for (const info of state.list) {
const r = state.results.get(info.id);
if (r?.status !== "failed") continue;
const at = r.at ?? { file: info.file, line: info.line };
const hover = r.expected !== undefined || r.actual !== undefined ? `**${info.name}**\n\n${t("Expected:")} \`${r.expected ?? "—"}\`\n\n${t("Got:")} \`${r.actual ?? "—"}\`` : `**${info.name}**\n\n${r.message ?? t("failed")}`;
notes.push({ file: at.file, line: at.line, text: r.message ?? t("failed"), hover });
}
return notes;
}
/** A failing test is a warning at its line; so is a `test.only` left in. */
export function warningsOf(state: TestState, t: Translate = english): ScriptWarning[] {
const out: ScriptWarning[] = [];
for (const info of state.list) {
const r = state.results.get(info.id);
if (info.kind === "test" && r?.status === "failed") out.push({ file: info.file, line: info.line, message: t("The test \"{name}\" fails: {message}", { name: info.name, message: r.message ?? t("failed") }) });
}
for (const o of state.only) out.push({ file: o.file, line: o.line, message: t("An only is left in: the other tests of this file do not run.") });
return out;
}
/* ── The Testing view's tree ── */
export type TestNode =
| { kind: "folder"; name: string; path: string; children: TestNode[] }
| { kind: "file"; name: string; path: string; children: TestNode[] }
| { kind: "suite" | "test"; info: TestInfo; children: TestNode[] };
/** Folder, file, `describe`, test; only the files that have tests, and the folders those are in. */
export function testTree(list: TestInfo[]): TestNode[] {
const files = [...new Set(list.map((t) => t.file))];
const under = (file: string): TestNode[] => {
const mine = list.filter((t) => t.file === file);
const build = (path: string[]): TestNode[] => mine
.filter((t) => t.path.length === path.length && t.path.every((p, i) => p === path[i]))
.map((info) => ({ kind: info.kind, info, children: info.kind === "suite" ? build([...path, info.name]) : [] }));
return build([]);
};
const walk = (nodes: TreeNode[]): TestNode[] => nodes.map((n) => (n.kind === "folder"
? { kind: "folder" as const, name: n.name, path: n.path, children: walk(n.children) }
: { kind: "file" as const, name: n.name, path: n.path, children: under(n.path) }));
return walk(buildTree(files));
}
/** The ids a node of the tree runs. */
export function idsUnder(node: TestNode): string[] {
if (node.kind === "test" || node.kind === "suite") return [node.info.id];
return node.children.flatMap(idsUnder);
}
/** "P1–P8", "P1, P3": who a line is for, runs of neighbours as a range. */
export function playersLabel(players: readonly number[]): string {
const sorted = [...new Set(players)].sort((a, b) => a - b);
const parts: string[] = [];
for (let i = 0; i < sorted.length; i++) {
let j = i;
while (j + 1 < sorted.length && sorted[j + 1] === sorted[j] + 1) j++;
parts.push(j - i >= 2 ? `P${sorted[i] + 1}–P${sorted[j] + 1}` : sorted.slice(i, j + 1).map((p) => `P${p + 1}`).join(", "));
i = j;
}
return parts.join(", ");
}
/**
* The lines of one frame that say the same thing from the same place for several players, as one line for all of
* them: a trigger of All Players in a game of eight is one row, not eight. The first of them keeps its place.
*/
export function foldPlayers<T extends { frame: number; text: string; player: number; file?: string; line?: number }>(events: readonly T[]): (T & { players: number[] })[] {
const out: (T & { players: number[] })[] = [];
const seen = new Map<string, T & { players: number[] }>();
for (const e of events) {
const key = `${e.frame}|${e.file ?? ""}|${e.line ?? 0}|${e.text}`;
const first = seen.get(key);
if (first) { if (!first.players.includes(e.player)) first.players.push(e.player); continue; }
const row = { ...e, players: [e.player] };
seen.set(key, row);
out.push(row);
}
return out;
}
/** What the status bar says: `12 passed`, `1 failed`, and what is left over. */
export function summary(c: TestCounts, t: Translate = english): { text: string; failed: boolean } | null {
if (c.total === 0) return null;
if (c.failed) return { text: c.passed ? t("{failed} failed, {passed} passed", { failed: c.failed, passed: c.passed }) : t("{failed} failed", { failed: c.failed }), failed: true };
if (c.notRun === c.total) return { text: t("{n, plural, one {# test} other {# tests}}", { n: c.total }), failed: false };
return { text: [t("{n} passed", { n: c.passed }), c.skipped ? t("{n} skipped", { n: c.skipped }) : "", c.notRun ? t("{n} not run", { n: c.notRun }) : ""].filter(Boolean).join(", "), failed: false };
}