@crvouga/atlas 0.2.0 → 0.3.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +7 -1
  2. package/README.md +8 -2
  3. package/dist/reports/src/export-evidence.js +2 -10
  4. package/dist/reports/src/frames.d.ts +3 -1
  5. package/dist/reports/src/frames.js +21 -0
  6. package/dist/reports/src/publish.js +2 -11
  7. package/dist/reports/src/recording-keys.js +4 -0
  8. package/dist/reports/src/report-types.d.ts +19 -2
  9. package/dist/reports/src/source-entries.js +2 -12
  10. package/package.json +4 -3
  11. package/packages/visualizer/README.md +3 -1
  12. package/packages/visualizer/src/app/AppShell.tsx +11 -12
  13. package/packages/visualizer/src/app/gazetteer.module.css +12 -2
  14. package/packages/visualizer/src/app/gazetteer.tsx +38 -26
  15. package/packages/visualizer/src/components/Header.module.css +20 -4
  16. package/packages/visualizer/src/components/Header.tsx +6 -3
  17. package/packages/visualizer/src/data/model/build.ts +17 -5
  18. package/packages/visualizer/src/data/model/types.ts +10 -1
  19. package/packages/visualizer/src/evidence/api.ts +6 -1
  20. package/packages/visualizer/src/evidence/recording-page.tsx +19 -2
  21. package/packages/visualizer/src/evidence/recording.module.css +4 -8
  22. package/packages/visualizer/src/evidence/recording.tsx +74 -83
  23. package/packages/visualizer/src/evidence/snapshot.tsx +5 -4
  24. package/packages/visualizer/src/evidence/spec-index.tsx +3 -11
  25. package/packages/visualizer/src/find/Finder.module.css +168 -0
  26. package/packages/visualizer/src/find/Finder.tsx +303 -0
  27. package/packages/visualizer/src/find/corpus.test.ts +77 -0
  28. package/packages/visualizer/src/find/corpus.ts +368 -0
  29. package/packages/visualizer/src/find/open.ts +51 -0
  30. package/packages/visualizer/src/find/rank.test.ts +237 -0
  31. package/packages/visualizer/src/find/rank.ts +405 -0
  32. package/packages/visualizer/src/find/semantic.test.ts +62 -0
  33. package/packages/visualizer/src/find/semantic.ts +93 -0
  34. package/packages/visualizer/src/find/semantic.worker.ts +40 -0
  35. package/packages/visualizer/src/looks/Stage.tsx +322 -0
  36. package/packages/visualizer/src/looks/collect.test.ts +158 -0
  37. package/packages/visualizer/src/looks/collect.ts +245 -0
  38. package/packages/visualizer/src/looks/stage.module.css +300 -0
  39. package/packages/visualizer/src/reachable.test.ts +1 -1
  40. package/packages/visualizer/src/views/SidePanel.tsx +39 -49
  41. package/packages/visualizer/src/app/search-query.tsx +0 -14
@@ -0,0 +1,368 @@
1
+ import type { CheckSlot, EvidenceView } from '@crvouga/atlas/reports/evidence-view';
2
+ import type { RunStatus } from '@crvouga/atlas/schema';
3
+
4
+ import type { AtlasView, ItemStatus } from '../data/model';
5
+ import { worstOf } from '../data/model/status';
6
+ import { featureModel, scenarioLabel, slotsByScenario } from '../evidence/gherkin';
7
+ import { kindOfFile, outlinePath, specFile } from '../spec-kind';
8
+ import type { FindDoc, FindField, FindStatus, FindTarget } from './rank';
9
+
10
+ export type CorpusChart = { readonly id: string; readonly name: string };
11
+
12
+ export type CorpusState = {
13
+ readonly name: string;
14
+ readonly chartId: string;
15
+ readonly kind: string;
16
+ readonly description: string;
17
+ readonly status: ItemStatus;
18
+ };
19
+
20
+ export type CorpusTransition = {
21
+ readonly id: string;
22
+ readonly chartId: string;
23
+ readonly event: string;
24
+ readonly source: string;
25
+ readonly target: string | null;
26
+ readonly status: ItemStatus;
27
+ readonly carriedBy?: string | null;
28
+ };
29
+
30
+ export type CorpusPath = {
31
+ readonly id: string;
32
+ readonly name: string;
33
+ readonly status: RunStatus;
34
+ readonly events: readonly string[];
35
+ };
36
+
37
+ export type CorpusJourney = {
38
+ readonly id: string;
39
+ readonly name: string;
40
+ readonly description: string;
41
+ readonly chartId: string;
42
+ readonly events: readonly string[];
43
+ readonly endsIn: readonly string[];
44
+ readonly status: ItemStatus;
45
+ readonly paths: readonly CorpusPath[];
46
+ };
47
+
48
+ export type CorpusFile = { readonly path: string; readonly title: string };
49
+
50
+ export type CorpusSlot = {
51
+ readonly key: string;
52
+ readonly id: string;
53
+ readonly title: string;
54
+ readonly specPath: string | null;
55
+ readonly status: FindStatus;
56
+ readonly detail: string;
57
+ readonly recording: boolean;
58
+ };
59
+
60
+ export type CorpusSource = {
61
+ readonly charts: readonly CorpusChart[];
62
+ readonly states: readonly CorpusState[];
63
+ readonly transitions: readonly CorpusTransition[];
64
+ readonly journeys: readonly CorpusJourney[];
65
+ readonly files: readonly CorpusFile[];
66
+ readonly slots: readonly CorpusSlot[];
67
+ readonly texts: ReadonlyMap<string, string>;
68
+ };
69
+
70
+ function itemStatus(status: ItemStatus): FindStatus {
71
+ if (status === 'passed') return 'passed';
72
+ if (status === 'failed' || status === 'flaky') return 'failed';
73
+ if (status === 'not-yet-run') return 'running';
74
+ return 'none';
75
+ }
76
+
77
+ function runStatus(status: RunStatus): FindStatus {
78
+ if (status === 'passed') return 'passed';
79
+ if (status === 'failed' || status === 'flaky') return 'failed';
80
+ return 'not-run';
81
+ }
82
+
83
+ function chartTarget(chartId: string, search: FindTarget['search']): FindTarget {
84
+ return { to: '/chart/$chartId', chartId, search };
85
+ }
86
+
87
+ function specTarget(kind: string, spec: string, check?: string, recording = false): FindTarget {
88
+ return {
89
+ to: '/specs/$kind',
90
+ kind,
91
+ search: { spec, ...(check ? { check } : {}), ...(recording ? { view: 'recording' as const } : {}) }
92
+ };
93
+ }
94
+
95
+ /** The first slot that has a timeline, opened as a recording. */
96
+ function timelineTarget(kind: string | null, file: string, slots: readonly CorpusSlot[]): FindTarget | null {
97
+ if (!kind) return null;
98
+ const slot = slots.find((item) => item.recording);
99
+ return slot ? specTarget(kind, file, slot.id, true) : null;
100
+ }
101
+
102
+ function fieldsOf(title: string, steps: string, description: string, path: string): FindField[] {
103
+ const fields: FindField[] = [{ name: 'title', text: title }];
104
+ if (steps) fields.push({ name: 'steps', text: steps });
105
+ if (description) fields.push({ name: 'description', text: description });
106
+ if (path) fields.push({ name: 'path', text: path });
107
+ return fields;
108
+ }
109
+
110
+ function places(names: readonly string[]): string {
111
+ const unique = [...new Set(names.filter(Boolean))];
112
+ if (unique.length <= 3) return unique.join(', ');
113
+ return `${unique.slice(0, 3).join(', ')}, and ${unique.length - 3} more`;
114
+ }
115
+
116
+ function slotsFor(file: string, slots: readonly CorpusSlot[]): CorpusSlot[] {
117
+ return slots.filter((slot) => slot.specPath === outlinePath(file) || (slot.specPath !== null && specFile(slot.specPath) === file));
118
+ }
119
+
120
+ function worstStatus(statuses: readonly FindStatus[]): FindStatus {
121
+ const rank: Record<FindStatus, number> = { failed: 3, running: 2, 'not-run': 1, passed: 0, none: 0 };
122
+ return statuses.reduce((worst, status) => (rank[status] > rank[worst] ? status : worst), 'passed');
123
+ }
124
+
125
+ const ACTION = /^\s*action\s+([A-Za-z_][A-Za-z0-9_]*)/gm;
126
+
127
+ function actionsIn(text: string): string[] {
128
+ return [...new Set([...text.matchAll(ACTION)].map((match) => match[1]!))];
129
+ }
130
+
131
+ function runDoc(slot: CorpusSlot, title: string, kind: string | null, file: string): FindDoc {
132
+ const recording = timelineTarget(kind, file, [slot]);
133
+ return {
134
+ id: `run:${slot.key}`,
135
+ group: 'Runs',
136
+ kind: 'run',
137
+ title,
138
+ subtitle: file,
139
+ fields: fieldsOf(title, '', slot.detail, file),
140
+ status: slot.status,
141
+ related: [],
142
+ // Enter opens the recording when this check has a timeline.
143
+ target: recording ?? (kind ? specTarget(kind, file, slot.id) : { to: '/' as const, search: {} }),
144
+ recording
145
+ };
146
+ }
147
+
148
+ /** Every flow, spec and recorded run the finder can open. */
149
+ export function buildCorpus(source: CorpusSource): FindDoc[] {
150
+ const chartName = new Map(source.charts.map((chart) => [chart.id, chart.name]));
151
+ const docs: FindDoc[] = [];
152
+ const seenRuns = new Set<string>();
153
+
154
+ for (const state of source.states) {
155
+ if (state.kind !== 'screen' && state.kind !== 'final') continue;
156
+ docs.push({
157
+ id: `screen:${state.chartId}:${state.name}`,
158
+ group: 'Flows',
159
+ kind: 'screen',
160
+ title: state.name,
161
+ subtitle: chartName.get(state.chartId) ?? '',
162
+ fields: fieldsOf(state.name, '', state.description, ''),
163
+ status: itemStatus(state.status),
164
+ related: [],
165
+ target: chartTarget(state.chartId, { screen: state.name }),
166
+ recording: null
167
+ });
168
+ }
169
+
170
+ const events = new Map<string, CorpusTransition[]>();
171
+ for (const transition of source.transitions) {
172
+ if (!transition.event) continue;
173
+ const key = `${transition.chartId}\0${transition.event}`;
174
+ const group = events.get(key);
175
+ if (group) group.push(transition);
176
+ else events.set(key, [transition]);
177
+ }
178
+ for (const group of events.values()) {
179
+ const first = group.find((transition) => !transition.carriedBy) ?? group[0]!;
180
+ const where = places(group.flatMap((transition) => [transition.source, transition.target ?? '']));
181
+ docs.push({
182
+ id: `event:${first.chartId}:${first.event}`,
183
+ group: 'Flows',
184
+ kind: 'event',
185
+ title: first.event,
186
+ subtitle: where,
187
+ fields: fieldsOf(first.event, '', where, ''),
188
+ status: itemStatus(worstOf(group.map((transition) => transition.status), 'spec-only')),
189
+ related: [],
190
+ target: chartTarget(first.chartId, { event: first.id }),
191
+ recording: null
192
+ });
193
+ }
194
+
195
+ for (const journey of source.journeys) {
196
+ const pathIds = journey.paths.map((path) => `path:${journey.id}:${path.id}`);
197
+ docs.push({
198
+ id: `journey:${journey.id}`,
199
+ group: 'Flows',
200
+ kind: 'journey',
201
+ title: journey.name,
202
+ subtitle: chartName.get(journey.chartId) ?? '',
203
+ fields: fieldsOf(journey.name, journey.events.join('\n'), [journey.description, ...journey.endsIn].filter(Boolean).join('\n'), ''),
204
+ status: itemStatus(journey.status),
205
+ related: pathIds,
206
+ target: chartTarget(journey.chartId, { journey: journey.id }),
207
+ recording: null
208
+ });
209
+ for (const path of journey.paths) {
210
+ docs.push({
211
+ id: `path:${journey.id}:${path.id}`,
212
+ group: 'Runs',
213
+ kind: 'run',
214
+ title: path.name || journey.name,
215
+ subtitle: journey.name,
216
+ fields: fieldsOf(path.name || journey.name, path.events.join('\n'), '', ''),
217
+ status: runStatus(path.status),
218
+ related: [],
219
+ target: chartTarget(journey.chartId, { journey: journey.id }),
220
+ recording: null
221
+ });
222
+ }
223
+ }
224
+
225
+ const pushRun = (slot: CorpusSlot, title: string, kind: string | null, file: string) => {
226
+ const id = `run:${slot.key}`;
227
+ if (seenRuns.has(id)) return;
228
+ seenRuns.add(id);
229
+ docs.push(runDoc(slot, title, kind, file));
230
+ };
231
+
232
+ for (const file of source.files) {
233
+ const kind = kindOfFile(file.path);
234
+ if (!kind) continue;
235
+ const text = source.texts.get(file.path);
236
+ const mine = slotsFor(file.path, source.slots);
237
+ if (kind.id === 'gherkin' && text) {
238
+ let feature: ReturnType<typeof featureModel> = null;
239
+ try {
240
+ feature = featureModel(text);
241
+ } catch {
242
+ feature = null;
243
+ }
244
+ if (feature) {
245
+ const filed = slotsByScenario(feature, mine as unknown as CheckSlot[]);
246
+ docs.push({
247
+ id: `spec:${file.path}`,
248
+ group: 'Specs',
249
+ kind: 'spec',
250
+ title: feature.name || file.title,
251
+ subtitle: file.path,
252
+ fields: fieldsOf(feature.name || file.title, '', feature.description, file.path),
253
+ status: null,
254
+ related: [],
255
+ target: specTarget(kind.id, file.path),
256
+ recording: null
257
+ });
258
+ for (const rule of feature.rules) {
259
+ for (const scenario of rule.scenarios) {
260
+ if (scenario.background) continue;
261
+ const recorded = (filed.byScenario.get(scenario.name) ?? []) as unknown as CorpusSlot[];
262
+ const steps = [
263
+ ...scenario.steps.map((step) => `${step.keyword} ${step.text}`.trim()),
264
+ ...scenario.examples.flatMap((example) => example.rows.flat())
265
+ ].join('\n');
266
+ docs.push({
267
+ id: `scenario:${file.path}:${scenario.name}`,
268
+ group: 'Specs',
269
+ kind: 'scenario',
270
+ title: scenario.name,
271
+ subtitle: feature.name || file.title,
272
+ fields: fieldsOf(scenario.name, steps, [rule.name ?? '', rule.description, scenario.description].filter(Boolean).join('\n'), file.path),
273
+ status: recorded.length ? worstStatus(recorded.map((slot) => slot.status)) : null,
274
+ related: recorded.map((slot) => `run:${slot.key}`),
275
+ target: specTarget(kind.id, file.path, recorded[0]?.id ?? `scenario:${scenario.name}`),
276
+ recording: timelineTarget(kind.id, file.path, recorded)
277
+ });
278
+ }
279
+ }
280
+ for (const slot of mine) pushRun(slot, scenarioLabel(feature.name, slot.title), kind.id, file.path);
281
+ continue;
282
+ }
283
+ }
284
+ if (kind.id === 'quint' && text) {
285
+ for (const name of actionsIn(text)) {
286
+ docs.push({
287
+ id: `action:${file.path}:${name}`,
288
+ group: 'Specs',
289
+ kind: 'action',
290
+ title: name,
291
+ subtitle: file.title,
292
+ fields: fieldsOf(name, '', '', file.path),
293
+ status: null,
294
+ related: mine.map((slot) => `run:${slot.key}`),
295
+ target: specTarget(kind.id, file.path, mine[0]?.id),
296
+ recording: timelineTarget(kind.id, file.path, mine)
297
+ });
298
+ }
299
+ }
300
+ docs.push({
301
+ id: `spec:${file.path}`,
302
+ group: 'Specs',
303
+ kind: 'spec',
304
+ title: file.title,
305
+ subtitle: file.path,
306
+ fields: fieldsOf(file.title, '', text ?? '', file.path),
307
+ status: null,
308
+ related: mine.map((slot) => `run:${slot.key}`),
309
+ target: specTarget(kind.id, file.path, mine[0]?.id),
310
+ recording: timelineTarget(kind.id, file.path, mine)
311
+ });
312
+ for (const slot of mine) pushRun(slot, slot.title, kind.id, file.path);
313
+ }
314
+
315
+ for (const slot of source.slots) {
316
+ const file = slot.specPath ? specFile(slot.specPath) : '';
317
+ pushRun(slot, slot.title, file ? kindOfFile(file)?.id ?? null : null, file);
318
+ }
319
+ return docs;
320
+ }
321
+
322
+ /** The view and the evidence the page already holds, in the shape `buildCorpus` reads. */
323
+ export function corpusFrom(view: AtlasView | null, files: readonly string[], titleOf: (file: string) => string, evidence: EvidenceView, texts: ReadonlyMap<string, string>): FindDoc[] {
324
+ const slots: CorpusSlot[] = [];
325
+ const take = (slot: CheckSlot) => {
326
+ slots.push({
327
+ key: slot.key,
328
+ id: slot.id,
329
+ title: slot.title,
330
+ specPath: slot.specPath,
331
+ status: slot.status,
332
+ detail: slot.outcomes.map((outcome) => outcome.row.detail).filter(Boolean).join('\n'),
333
+ recording: slot.outcomes.some((outcome) => outcome.row.testId !== null)
334
+ });
335
+ };
336
+ for (const entry of evidence.specs.values()) for (const slot of entry.slots) take(slot);
337
+ for (const slot of evidence.unassigned) take(slot);
338
+ return buildCorpus({
339
+ charts: view ? [...view.charts.values()].map((chart) => ({ id: chart.id, name: chart.name })) : [],
340
+ states: view ? [...view.states.values()].map((state) => ({ name: state.name, chartId: state.chartId, kind: state.kind, description: state.description, status: state.status })) : [],
341
+ transitions: view
342
+ ? [...view.transitions.values()].map((transition) => ({
343
+ id: transition.id,
344
+ chartId: transition.chartId,
345
+ event: transition.event,
346
+ source: transition.source,
347
+ target: transition.target,
348
+ status: transition.status,
349
+ carriedBy: transition.carriedBy
350
+ }))
351
+ : [],
352
+ journeys: view
353
+ ? view.journeys.map((journey) => ({
354
+ id: journey.id,
355
+ name: journey.name,
356
+ description: journey.description,
357
+ chartId: journey.chartIds[0] ?? '',
358
+ events: journey.events,
359
+ endsIn: journey.endsIn,
360
+ status: journey.status,
361
+ paths: journey.runPaths.map((path) => ({ id: path.id, name: path.name, status: path.status, events: path.steps.map((step) => step.event) }))
362
+ }))
363
+ : [],
364
+ files: files.map((path) => ({ path, title: titleOf(path) })),
365
+ slots,
366
+ texts
367
+ });
368
+ }
@@ -0,0 +1,51 @@
1
+ import { useSyncExternalStore } from 'react';
2
+
3
+ let open = false;
4
+ let help = false;
5
+ const listeners = new Set<() => void>();
6
+
7
+ function emit() {
8
+ for (const listener of listeners) listener();
9
+ }
10
+
11
+ export function subscribeFinder(listener: () => void) {
12
+ listeners.add(listener);
13
+ return () => listeners.delete(listener);
14
+ }
15
+
16
+ export function isFinderOpen() {
17
+ return open;
18
+ }
19
+
20
+ export function isHelpOpen() {
21
+ return help;
22
+ }
23
+
24
+ /** Recording keys stay put while either layer is up. */
25
+ export function finderBlocksKeys() {
26
+ return open || help;
27
+ }
28
+
29
+ export function setFinderOpen(value: boolean) {
30
+ open = value;
31
+ if (value) help = false;
32
+ emit();
33
+ }
34
+
35
+ export function toggleFinder() {
36
+ setFinderOpen(!open);
37
+ }
38
+
39
+ export function setHelpOpen(value: boolean) {
40
+ help = value;
41
+ if (value) open = false;
42
+ emit();
43
+ }
44
+
45
+ export function useFinderOpen() {
46
+ return useSyncExternalStore(subscribeFinder, isFinderOpen, () => false);
47
+ }
48
+
49
+ export function useHelpOpen() {
50
+ return useSyncExternalStore(subscribeFinder, isHelpOpen, () => false);
51
+ }
@@ -0,0 +1,237 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { buildCorpus, type CorpusSource } from './corpus';
4
+ import { canonical, notationOf, rank, stem, type FindDoc } from './rank';
5
+
6
+ const FEATURE = `Feature: Working the list
7
+ A person keeps a list of todos.
8
+
9
+ Scenario: Finish every todo and clear them
10
+ When they tap "Mark all done"
11
+ And they clear completed
12
+
13
+ Scenario: Add a todo
14
+ When they add a todo
15
+
16
+ Scenario: Evening routine
17
+ When they tap "Mark all done"
18
+ `;
19
+
20
+ const QUINT = `module todo {
21
+ action add = true
22
+ action mark_all_done = true
23
+ action clear_completed = true
24
+ }
25
+ `;
26
+
27
+ function todoSource(): CorpusSource {
28
+ const screens = Array.from({ length: 12 }, (_, index) => ({
29
+ name: `Open todo list ${index + 1}`,
30
+ chartId: 'app',
31
+ kind: 'screen',
32
+ description: '',
33
+ status: 'passed' as const
34
+ }));
35
+ return {
36
+ charts: [{ id: 'app', name: 'Todo' }],
37
+ states: [...screens, { name: 'No todos', chartId: 'app', kind: 'screen', description: '', status: 'passed' }],
38
+ transitions: [
39
+ { id: 'mark', chartId: 'app', event: 'Marks all done', source: 'Has items', target: 'Done', status: 'passed' },
40
+ { id: 'add-carried', chartId: 'app', event: 'Adds a todo', source: 'Desktop', target: 'Has items', status: 'passed', carriedBy: 'phone' },
41
+ { id: 'add', chartId: 'app', event: 'Adds a todo', source: 'No todos', target: 'Has items', status: 'passed' },
42
+ { id: 'add-again', chartId: 'app', event: 'Adds a todo', source: 'Has items', target: 'Has items', status: 'passed' },
43
+ { id: 'clear', chartId: 'app', event: 'Clears completed todos', source: 'Has items', target: 'No todos', status: 'passed' },
44
+ { id: 'throw', chartId: 'app', event: 'Throw the list away', source: 'Has items', target: 'No todos', status: 'passed' }
45
+ ],
46
+ journeys: [
47
+ {
48
+ id: 'weekend',
49
+ name: 'Plans a weekend',
50
+ description: 'A person finishes it before Monday.',
51
+ chartId: 'app',
52
+ events: ['Adds a todo', 'Marks all done'],
53
+ endsIn: ['Done'],
54
+ status: 'failed',
55
+ paths: [
56
+ { id: 'slipped', name: 'Saturday slipped', status: 'failed', events: ['Adds a todo', 'Marks all done'] },
57
+ { id: 'held', name: 'Saturday held', status: 'passed', events: ['Adds a todo', 'Marks all done'] }
58
+ ]
59
+ }
60
+ ],
61
+ files: [
62
+ { path: 'working-the-list.feature', title: 'Working the list' },
63
+ { path: 'todo.qnt', title: 'todo.qnt' },
64
+ { path: 'todo-app.scxml', title: 'Todo app' }
65
+ ],
66
+ slots: [
67
+ {
68
+ key: 'lists/evening',
69
+ id: 'lists/evening',
70
+ title: 'Evening routine',
71
+ specPath: 'specs/working-the-list.feature',
72
+ status: 'passed',
73
+ detail: 'Recorded on Saturday.',
74
+ recording: true
75
+ }
76
+ ],
77
+ texts: new Map([
78
+ ['working-the-list.feature', FEATURE],
79
+ ['todo.qnt', QUINT]
80
+ ])
81
+ };
82
+ }
83
+
84
+ const docs = buildCorpus(todoSource());
85
+
86
+ function titles(query: string) {
87
+ return rank(docs, query).map((hit) => hit.doc.title);
88
+ }
89
+
90
+ function above(query: string, earlier: string, later: string) {
91
+ const list = titles(query);
92
+ const from = list.indexOf(earlier);
93
+ const to = list.indexOf(later);
94
+ expect(from, list.join(' | ')).toBeGreaterThanOrEqual(0);
95
+ expect(to, list.join(' | ')).toBeGreaterThan(from);
96
+ }
97
+
98
+ describe('everyday words', () => {
99
+ test('stems and classes meet', () => {
100
+ expect(stem('marking')).toBe('mark');
101
+ expect(stem('marked')).toBe('mark');
102
+ expect(stem('marks')).toBe('mark');
103
+ expect(stem('todos')).toBe('todo');
104
+ expect(canonical('marking')).toBe('complete');
105
+ expect(canonical('completed')).toBe('complete');
106
+ expect(canonical('finished')).toBe('complete');
107
+ expect(canonical('adds')).toBe('add');
108
+ expect(canonical('clears')).toBe('remove');
109
+ });
110
+
111
+ test('marking a todo complete finds the flows that finish the list', () => {
112
+ const list = titles('Marking todo as complete');
113
+ for (const title of ['Marks all done', 'Finish every todo and clear them', 'mark_all_done', 'Plans a weekend']) {
114
+ above('Marking todo as complete', title, 'No todos');
115
+ }
116
+ expect(list.indexOf('Marks all done'), list.join(' | ')).toBeGreaterThanOrEqual(0);
117
+ const finish = rank(docs, 'Marking todo as complete').find((hit) => hit.doc.title === 'Finish every todo and clear them');
118
+ expect(finish?.snippet).toContain('Mark all done');
119
+ });
120
+
121
+ test('a matched scenario lists its recorded check', () => {
122
+ const hits = rank(docs, 'Marking todo as complete');
123
+ const scenario = hits.findIndex((hit) => hit.doc.kind === 'scenario' && hit.doc.title === 'Evening routine');
124
+ const run = hits.findIndex((hit) => hit.doc.id === 'run:lists/evening');
125
+ expect(scenario, hits.map((hit) => hit.doc.title).join(' | ')).toBeGreaterThanOrEqual(0);
126
+ expect(run).toBe(scenario + 1);
127
+ expect(hits[run]?.doc.title).toBe('Evening routine');
128
+ expect(hits[run]?.snippet).toContain('Mark all done');
129
+ });
130
+
131
+ test('adding a todo finds the add flows', () => {
132
+ above('Add new todo', 'Adds a todo', 'No todos');
133
+ above('Add new todo', 'Add a todo', 'No todos');
134
+ });
135
+
136
+ test('removing a todo finds clearing', () => {
137
+ above('remove todo', 'Clears completed todos', 'No todos');
138
+ above('remove todo', 'clear_completed', 'No todos');
139
+ above('remove todo', 'Clears completed todos', 'Throw the list away');
140
+ });
141
+
142
+ test('a mistyped phrase still finds the flow', () => {
143
+ above('makring todo complte', 'Marks all done', 'No todos');
144
+ });
145
+
146
+ test('an empty box lists journeys and then failing checks', () => {
147
+ const list = titles('');
148
+ expect(list[0]).toBe('Plans a weekend');
149
+ expect(list).toContain('Saturday slipped');
150
+ expect(list).not.toContain('Saturday held');
151
+ expect(list).not.toContain('Open todo list 1');
152
+ expect(titles('the')).toEqual(list);
153
+ });
154
+ });
155
+
156
+ function quiet(title: string): FindDoc {
157
+ return {
158
+ id: title,
159
+ group: 'Specs',
160
+ kind: 'spec',
161
+ title,
162
+ subtitle: '',
163
+ fields: [{ name: 'title', text: title }],
164
+ status: null,
165
+ related: [],
166
+ target: { to: '/', search: {} },
167
+ recording: null
168
+ };
169
+ }
170
+
171
+ test('a kind word keeps only that kind of spec', () => {
172
+ const marking = titles('Marking todo as complete');
173
+ for (const query of ['gherkin', 'features', 'scenarios', 'cucumber']) {
174
+ const list = titles(query);
175
+ expect(list, query).toContain('Add a todo');
176
+ expect(list, query).toContain('Finish every todo and clear them');
177
+ expect(list, query).not.toContain('Adds a todo');
178
+ expect(list, query).not.toContain('mark_all_done');
179
+ expect(rank(docs, query).every((hit) => notationOf(hit.doc) === 'gherkin')).toBe(true);
180
+ }
181
+ for (const query of ['quint', 'models', 'qnt']) {
182
+ const list = titles(query);
183
+ expect(list, query).toContain('add');
184
+ expect(list, query).toContain('mark_all_done');
185
+ expect(list, query).not.toContain('Add a todo');
186
+ expect(list, query).not.toContain('Adds a todo');
187
+ }
188
+ for (const query of ['statecharts', 'statechart', 'scxml', 'state machine']) {
189
+ const list = titles(query);
190
+ expect(list, query).toContain('Adds a todo');
191
+ expect(list, query).toContain('Plans a weekend');
192
+ expect(list, query).not.toContain('Add a todo');
193
+ expect(list, query).not.toContain('mark_all_done');
194
+ expect(rank(docs, query).every((hit) => notationOf(hit.doc) === 'statechart')).toBe(true);
195
+ }
196
+ const gherkinAdd = titles('add a feature');
197
+ expect(gherkinAdd).toContain('Add a todo');
198
+ expect(gherkinAdd).not.toContain('Adds a todo');
199
+ expect(gherkinAdd).not.toContain('add');
200
+ const quintAdd = titles('quint add');
201
+ expect(quintAdd).toContain('add');
202
+ expect(quintAdd).not.toContain('Add a todo');
203
+ expect(titles('gherkn add todo')).toContain('Add a todo');
204
+ expect(titles('gherkn add todo')).not.toContain('Adds a todo');
205
+ expect(marking).toContain('Marks all done');
206
+ expect(marking).toContain('Finish every todo and clear them');
207
+ });
208
+
209
+ test('decision tables, alloy, and openslo filter the same way', () => {
210
+ const typed = (title: string, notation: string): FindDoc => ({
211
+ id: title,
212
+ group: 'Specs',
213
+ kind: 'spec',
214
+ title,
215
+ subtitle: '',
216
+ fields: [{ name: 'title', text: title }],
217
+ status: null,
218
+ related: [],
219
+ target: { to: '/specs/$kind', kind: notation, search: { spec: title } },
220
+ recording: null
221
+ });
222
+ const kinds = [typed('Ship the decision', 'dmn'), typed('Keep the alloy', 'alloy'), typed('Hold the slo', 'openslo'), typed('Add a todo', 'gherkin')];
223
+ expect(rank(kinds, 'dmn').map((hit) => hit.doc.title)).toEqual(['Ship the decision']);
224
+ expect(rank(kinds, 'decision table').map((hit) => hit.doc.title)).toEqual(['Ship the decision']);
225
+ expect(rank(kinds, 'alloy').map((hit) => hit.doc.title)).toEqual(['Keep the alloy']);
226
+ expect(rank(kinds, 'openslo').map((hit) => hit.doc.title)).toEqual(['Hold the slo']);
227
+ const both = rank(kinds, 'gherkin alloy').map((hit) => hit.doc.title);
228
+ expect(both).toContain('Add a todo');
229
+ expect(both).toContain('Keep the alloy');
230
+ expect(both).not.toContain('Ship the decision');
231
+ });
232
+
233
+ test('a word that is in most documents cannot win on its own', () => {
234
+ const crowd = [quiet('Marks all done'), ...Array.from({ length: 8 }, (_, index) => quiet(`Todo note ${index}`))];
235
+ const list = rank(crowd, 'complete the todo').map((hit) => hit.doc.title);
236
+ expect(list[0]).toBe('Marks all done');
237
+ });