@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.
- package/CHANGELOG.md +7 -1
- package/README.md +8 -2
- package/dist/reports/src/export-evidence.js +2 -10
- package/dist/reports/src/frames.d.ts +3 -1
- package/dist/reports/src/frames.js +21 -0
- package/dist/reports/src/publish.js +2 -11
- package/dist/reports/src/recording-keys.js +4 -0
- package/dist/reports/src/report-types.d.ts +19 -2
- package/dist/reports/src/source-entries.js +2 -12
- package/package.json +4 -3
- package/packages/visualizer/README.md +3 -1
- package/packages/visualizer/src/app/AppShell.tsx +11 -12
- package/packages/visualizer/src/app/gazetteer.module.css +12 -2
- package/packages/visualizer/src/app/gazetteer.tsx +38 -26
- package/packages/visualizer/src/components/Header.module.css +20 -4
- package/packages/visualizer/src/components/Header.tsx +6 -3
- package/packages/visualizer/src/data/model/build.ts +17 -5
- package/packages/visualizer/src/data/model/types.ts +10 -1
- package/packages/visualizer/src/evidence/api.ts +6 -1
- package/packages/visualizer/src/evidence/recording-page.tsx +19 -2
- package/packages/visualizer/src/evidence/recording.module.css +4 -8
- package/packages/visualizer/src/evidence/recording.tsx +74 -83
- package/packages/visualizer/src/evidence/snapshot.tsx +5 -4
- package/packages/visualizer/src/evidence/spec-index.tsx +3 -11
- package/packages/visualizer/src/find/Finder.module.css +168 -0
- package/packages/visualizer/src/find/Finder.tsx +303 -0
- package/packages/visualizer/src/find/corpus.test.ts +77 -0
- package/packages/visualizer/src/find/corpus.ts +368 -0
- package/packages/visualizer/src/find/open.ts +51 -0
- package/packages/visualizer/src/find/rank.test.ts +237 -0
- package/packages/visualizer/src/find/rank.ts +405 -0
- package/packages/visualizer/src/find/semantic.test.ts +62 -0
- package/packages/visualizer/src/find/semantic.ts +93 -0
- package/packages/visualizer/src/find/semantic.worker.ts +40 -0
- package/packages/visualizer/src/looks/Stage.tsx +322 -0
- package/packages/visualizer/src/looks/collect.test.ts +158 -0
- package/packages/visualizer/src/looks/collect.ts +245 -0
- package/packages/visualizer/src/looks/stage.module.css +300 -0
- package/packages/visualizer/src/reachable.test.ts +1 -1
- package/packages/visualizer/src/views/SidePanel.tsx +39 -49
- 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
|
+
});
|