@crvouga/atlas 1.5.0 → 1.6.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.
@@ -1,18 +1,26 @@
1
- import { Badge, Group, Kbd, List, Modal, Text } from '@mantine/core';
1
+ import { Badge, Button, Group, Kbd, List, Modal, Stack, Text } from '@mantine/core';
2
2
  import { Spotlight, useSpotlight } from '@mantine/spotlight';
3
3
  import { useQueryClient } from '@tanstack/react-query';
4
4
  import { useNavigate, useSearch } from '@tanstack/react-router';
5
- import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
5
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
6
6
  import { useAtlas } from '../app/atlas-context';
7
+ import { useTestCatalog } from '../explorer/catalog';
8
+ import { SearchNotes } from '../explorer/search-notes';
7
9
  import { keys, specKey, useAdapter } from '../data/queries';
8
10
  import { useEvidence } from '../evidence/evidence-context';
9
11
  import { plural } from '../evidence/format';
10
12
  import { StatusMark } from '../evidence/marks';
11
13
  import { kindOfFile } from '../spec-kind';
14
+ import { describeHit } from './behaviors';
12
15
  import { corpusFrom } from './corpus';
13
16
  import { finderStore, isHelpOpen, setFinderOpen, setHelpOpen, toggleFinder, useFinderOpen, useHelpOpen } from './open';
14
- import { FIND_GROUPS, indexCorpus, notationOf, rankIndex, splitQuery, type FindDoc, type FindKind, type FindTarget } from './rank';
15
- import { applySemantic, createWorkerEmbedder, embeddingsAvailable, embedText, textHash, type Embedder } from './semantic';
17
+ import { parseSearch, passesFilters } from './query';
18
+ import { FIND_GROUPS, indexCorpus, notationOf, rankIndex, splitQuery, type FindKind, type FindTarget, type Ranked } from './rank';
19
+ import { applySemantic } from './semantic';
20
+ import { useSemantic } from './semantic-search';
21
+
22
+ /** The finder lists at most this many results; it says so, and offers all of them, when there are more. */
23
+ export const FINDER_LIMIT = 40;
16
24
 
17
25
  const KIND_WORD: Record<FindKind, string> = {
18
26
  journey: 'Journey',
@@ -31,6 +39,7 @@ const SHORTCUTS: readonly {
31
39
  { keys: '/', does: 'Find a flow' },
32
40
  { keys: '↑ ↓', does: 'Move through the list' },
33
41
  { keys: 'Enter', does: 'Open' },
42
+ { keys: 'Shift+Enter', does: 'See all results of the search' },
34
43
  { keys: '⌘Enter or Ctrl+Enter', does: 'Open the recording' },
35
44
  { keys: 'Esc', does: 'Close' },
36
45
  { keys: '?', does: 'Show these shortcuts' },
@@ -102,16 +111,6 @@ function useSpecTexts(files: readonly string[], enabled: boolean, open: boolean)
102
111
  const failed = useMemo(() => files.filter((file) => failedFiles.has(file)).length, [files, failedFiles]);
103
112
  return { texts, pending, total: files.length, read, failed };
104
113
  }
105
- /** The vector cache outlives a keystroke, so a live run does not embed the suite again. */
106
- const vectorCache = new Map<string, Float32Array>();
107
- function vectorsFor(docs: readonly FindDoc[]): ReadonlyMap<string, Float32Array> {
108
- const vectors = new Map<string, Float32Array>();
109
- for (const doc of docs) {
110
- const vector = vectorCache.get(textHash(embedText(doc)));
111
- if (vector) vectors.set(doc.id, vector);
112
- }
113
- return vectors;
114
- }
115
114
  export function Finder() {
116
115
  const open = useFinderOpen();
117
116
  const help = useHelpOpen();
@@ -125,49 +124,85 @@ export function Finder() {
125
124
  useEffect(() => {
126
125
  if (open) setWanted(true);
127
126
  }, [open]);
128
- const { texts, pending, total, read, failed } = useSpecTexts(readable, wanted, open);
127
+ const { texts, pending, total: specTotal, read, failed } = useSpecTexts(readable, wanted, open);
128
+ const catalog = useTestCatalog();
129
+ const tests = useMemo(() => (catalog.model ? new Map(catalog.model.tests.map((test) => [test.key, test] as const)) : undefined), [catalog.model]);
129
130
  const docs = useMemo(
130
- () => corpusFrom(view, evidence.files, evidence.titleOf, evidence.view, texts),
131
- [view, evidence.files, evidence.titleOf, evidence.view, texts]
131
+ () => corpusFrom(view, evidence.files, evidence.titleOf, evidence.view, texts, tests),
132
+ [view, evidence.files, evidence.titleOf, evidence.view, texts, tests]
132
133
  );
133
134
  const index = useMemo(() => indexCorpus(docs), [docs]);
134
135
  // What search has not covered yet, so an empty answer is not read as "no such behavior".
135
136
  const unindexed = evidence.filesPending
136
137
  ? 'The spec list is still loading'
137
138
  : pending
138
- ? `${total - read} of ${total} specs are still being read`
139
+ ? `${specTotal - read} of ${specTotal} specs are still being read`
139
140
  : !evidence.ready && !evidence.unavailable
140
141
  ? 'Recorded results are still loading'
141
142
  : '';
142
143
  const [query, setQuery] = useState('');
144
+ const [showAll, setShowAll] = useState(false);
145
+ const [notice, setNotice] = useState<string | null>(null);
143
146
  const store = finderStore;
144
147
  const spotlightState = useSpotlight(store);
145
148
 
146
- const lexical = useMemo(() => rankIndex(index, query), [index, query]);
147
- const [embedded, setEmbedded] = useState<{
148
- readonly query: string;
149
- readonly vector: Float32Array;
150
- } | null>(null);
151
- const [vectors, setVectors] = useState<ReadonlyMap<string, Float32Array>>(() => new Map());
152
- const queryVector = embedded !== null && embedded.query === query.trim() ? embedded.vector : null;
149
+ const parsed = useMemo(() => parseSearch(query), [query]);
150
+ // Every match, best first: the list below is the first FINDER_LIMIT of it, and says so when there are more.
151
+ const everything = useMemo(() => rankIndex(index, query, Number.POSITIVE_INFINITY), [index, query]);
152
+ const lexical = useMemo(() => (showAll ? everything : everything.slice(0, FINDER_LIMIT)), [everything, showAll]);
153
153
  const searchable = useMemo(() => {
154
- const notations = splitQuery(query).notations;
155
- if (notations.size === 0) return docs;
156
- return docs.filter((doc) => notations.has(notationOf(doc)));
157
- }, [docs, query]);
158
- const shown = useMemo(
159
- () => (queryVector ? applySemantic(searchable, lexical, queryVector, vectors) : lexical),
160
- [queryVector, searchable, lexical, vectors]
154
+ const notations = splitQuery(parsed.words).notations;
155
+ return docs.filter((doc) => (notations.size === 0 || notations.has(notationOf(doc))) && passesFilters(doc.facets, parsed.filters));
156
+ }, [docs, parsed]);
157
+ const lexicalDocs = useMemo(() => lexical.map((hit) => hit.doc), [lexical]);
158
+ const literal = parsed.phrases.length + parsed.identifiers.length > 0;
159
+ const semantic = useSemantic(open && parsed.words.length > 0 && !literal, parsed.words, searchable, lexicalDocs);
160
+ const shown = useMemo<Ranked[]>(
161
+ () => (semantic.queryVector ? applySemantic(searchable, lexical, semantic.queryVector, semantic.vectors, showAll ? Number.POSITIVE_INFINITY : FINDER_LIMIT) : lexical),
162
+ [semantic.queryVector, searchable, lexical, semantic.vectors, showAll]
161
163
  );
162
164
  const ordered = useMemo(() => FIND_GROUPS.flatMap((group) => shown.filter((hit) => hit.doc.group === group)), [shown]);
165
+ const total = everything.length;
166
+ const bounded = !showAll && total > FINDER_LIMIT;
167
+ const orderedRef = useRef(ordered);
168
+ orderedRef.current = ordered;
169
+ const selectedId = useRef<string | null>(null);
170
+ useEffect(
171
+ () =>
172
+ store.subscribe((state) => {
173
+ selectedId.current = orderedRef.current[Math.max(0, state.selected)]?.doc.id ?? null;
174
+ }),
175
+ [store]
176
+ );
163
177
 
164
178
  // Rank results ourselves, then let Spotlight own keyboard selection and scrolling.
165
- // Compound Spotlight does not initialize a selection when asynchronous results arrive.
166
- useEffect(() => {
167
- if (open) store.setState((state) => ({ ...state, selected: 0 }));
168
- }, [open, query, store]);
169
- const embedder = useRef<Embedder | null>(null);
170
- const embedFailed = useRef(false);
179
+ // Compound Spotlight does not initialize a selection when asynchronous results arrive. A new query selects its first
180
+ // result; results that change under the same query (specs read, meaning-based re-ranking) keep the selected result
181
+ // selected by identity, and say so when it is gone, so Enter always opens what is highlighted.
182
+ const lastQuery = useRef<string | null>(null);
183
+ useLayoutEffect(() => {
184
+ if (!open) {
185
+ lastQuery.current = null;
186
+ return;
187
+ }
188
+ const first = ordered[0]?.doc.id ?? null;
189
+ if (lastQuery.current !== query) {
190
+ lastQuery.current = query;
191
+ selectedId.current = first;
192
+ setNotice(null);
193
+ setShowAll(false);
194
+ store.setState((state) => ({ ...state, selected: 0 }));
195
+ return;
196
+ }
197
+ const kept = selectedId.current === null ? -1 : ordered.findIndex((hit) => hit.doc.id === selectedId.current);
198
+ if (kept >= 0) {
199
+ if (store.getState().selected !== kept) store.setState((state) => ({ ...state, selected: kept }));
200
+ } else {
201
+ if (selectedId.current !== null) setNotice('The results changed and the selected result is no longer listed, so the first result is selected.');
202
+ selectedId.current = first;
203
+ store.setState((state) => ({ ...state, selected: 0 }));
204
+ }
205
+ }, [open, query, ordered, store]);
171
206
  useEffect(() => {
172
207
  if (open) return;
173
208
  setQuery('');
@@ -198,40 +233,6 @@ export function Finder() {
198
233
  window.addEventListener('keydown', onKey);
199
234
  return () => window.removeEventListener('keydown', onKey);
200
235
  }, []);
201
- useEffect(() => {
202
- if (!open || embedFailed.current || !embeddingsAvailable() || query.trim().length === 0) return;
203
- let cancelled = false;
204
- const needle = query.trim();
205
- embedder.current ??= createWorkerEmbedder();
206
- const rankedFirst = new Set(lexical.map((hit) => hit.doc.id));
207
- const ordered = [...lexical.map((hit) => hit.doc), ...docs.filter((doc) => !rankedFirst.has(doc.id))];
208
- void (async () => {
209
- try {
210
- const embedded = await embedder.current!.embed([needle]);
211
- const nextQuery = embedded[0];
212
- if (cancelled || !nextQuery) return;
213
- setEmbedded({ query: needle, vector: nextQuery });
214
- for (let start = 0; start < ordered.length && !cancelled; start += 8) {
215
- const batch = ordered.slice(start, start + 8);
216
- const missing = batch.filter((doc) => !vectorCache.has(textHash(embedText(doc))));
217
- if (missing.length > 0) {
218
- const batchVectors = await embedder.current!.embed(missing.map((doc) => embedText(doc)));
219
- if (cancelled) return;
220
- missing.forEach((doc, at) => {
221
- const vector = batchVectors[at];
222
- if (vector) vectorCache.set(textHash(embedText(doc)), vector);
223
- });
224
- }
225
- if (!cancelled) setVectors(vectorsFor(docs));
226
- }
227
- } catch {
228
- embedFailed.current = true;
229
- }
230
- })();
231
- return () => {
232
- cancelled = true;
233
- };
234
- }, [open, query, docs, lexical]);
235
236
  const openTarget = (target: FindTarget) => {
236
237
  const run = typeof routeSearch.run === 'string' ? routeSearch.run : undefined;
237
238
  if (target.to === '/chart/$chartId' && target.chartId) {
@@ -253,7 +254,22 @@ export function Finder() {
253
254
  }
254
255
  setFinderOpen(false);
255
256
  };
257
+ // The full results of a search: the explorer's list when the project has one (it filters, counts and sorts everything),
258
+ // else every result in this list.
259
+ const seeAll = () => {
260
+ if (catalog.model && query.trim()) {
261
+ const run = typeof routeSearch.run === 'string' ? routeSearch.run : undefined;
262
+ void navigate({ to: '/', search: { run, q: query.trim() } });
263
+ setFinderOpen(false);
264
+ } else setShowAll(true);
265
+ };
256
266
  const onCommandKey = (event: ReactKeyboardEvent) => {
267
+ if (event.key === 'Enter' && event.shiftKey && !event.metaKey && !event.ctrlKey) {
268
+ event.preventDefault();
269
+ event.stopPropagation();
270
+ seeAll();
271
+ return;
272
+ }
257
273
  if (event.key !== 'Enter' || (!event.metaKey && !event.ctrlKey)) return;
258
274
  event.preventDefault();
259
275
  event.stopPropagation();
@@ -311,9 +327,15 @@ export function Finder() {
311
327
  aria-selected={ordered[Math.max(0, spotlightState.selected)]?.doc.id === hit.doc.id}
312
328
  onClick={() => openTarget(hit.doc.target)}
313
329
  label={hit.doc.title}
314
- description={[hit.snippet !== hit.doc.subtitle ? hit.snippet : '', hit.doc.subtitle].filter(Boolean).join(' · ')}
330
+ description={[hit.snippet !== hit.doc.subtitle ? hit.snippet : '', hit.doc.subtitle, query.trim() ? describeHit(hit) : ''].filter(Boolean).join(' · ')}
315
331
  leftSection={hit.doc.status ? <StatusMark status={hit.doc.status} size={16} /> : undefined}
316
- rightSection={<Badge color="gray">{KIND_WORD[hit.doc.kind]}</Badge>}
332
+ rightSection={
333
+ <Group gap="xs" wrap="nowrap">
334
+ {hit.exact && <Badge color="blue">Exact</Badge>}
335
+ {hit.partial && <Badge color="gray" variant="outline">Partial</Badge>}
336
+ <Badge color="gray">{KIND_WORD[hit.doc.kind]}</Badge>
337
+ </Group>
338
+ }
317
339
  />
318
340
  ))}
319
341
  </Spotlight.ActionsGroup>
@@ -321,13 +343,37 @@ export function Finder() {
321
343
  })}
322
344
  </Spotlight.ActionsList>
323
345
  <Spotlight.Footer>
324
- <Group gap="md" justify="space-between">
325
- <Text size="xs" c="dimmed">
326
- {unindexed ? `Searching what is read so far: ${unindexed}` : failed > 0 ? `${plural(failed, 'spec', 'specs')} could not be read and ${failed === 1 ? 'is' : 'are'} not searched` : '↑↓ move'}
327
- </Text>
328
- <Text size="xs" c="dimmed">
329
- ↵ open · esc close
330
- </Text>
346
+ <Group gap="md" justify="space-between" align="flex-start">
347
+ <Stack gap={0}>
348
+ <Text role="status" size="xs" c="dimmed">
349
+ {query.trim() && total > 0
350
+ ? bounded
351
+ ? `Showing the first ${shown.length} of ${total} results, not all of them.`
352
+ : `${total} ${total === 1 ? 'result' : 'results'}.`
353
+ : '↑↓ move'}
354
+ </Text>
355
+ {(unindexed || failed > 0) && (
356
+ <Text role="status" size="xs" c="dimmed">
357
+ {unindexed ? `Searching what is read so far: ${unindexed}` : `${plural(failed, 'spec', 'specs')} could not be read and ${failed === 1 ? 'is' : 'are'} not searched`}
358
+ </Text>
359
+ )}
360
+ {notice && (
361
+ <Text role="status" size="xs" c="dimmed">
362
+ {notice}
363
+ </Text>
364
+ )}
365
+ <SearchNotes issues={parsed.issues} semantic={query.trim() ? semantic.status : 'idle'} />
366
+ </Stack>
367
+ <Group gap="xs" wrap="wrap" justify="flex-end">
368
+ {bounded && (
369
+ <Button size="compact-xs" variant="light" onClick={seeAll}>
370
+ See all {total} results
371
+ </Button>
372
+ )}
373
+ <Text size="xs" c="dimmed">
374
+ ↵ open · ⇧↵ all results · esc close
375
+ </Text>
376
+ </Group>
331
377
  </Group>
332
378
  </Spotlight.Footer>
333
379
  </Spotlight.Root>
@@ -0,0 +1,205 @@
1
+ /**
2
+ * @module Searchable behaviors, results and runs
3
+ *
4
+ * The documents every search surface matches against, built from one place so they cannot disagree. A recorded
5
+ * behavior (`TestSummary`) is searched by the same fields wherever it appears: the finder's Runs group, the test list
6
+ * and the results of an opened run all call {@link testFields} and {@link testFacets}. They differ only in what the
7
+ * structured filters say about this particular appearance (a run's result has the status, environment and videos of
8
+ * that run, not of the latest combined evidence), so the same words always give the same eligible behaviors.
9
+ */
10
+ import { videoCounts } from '@crvouga/atlas/reports/evidence-detail';
11
+ import type { RunResult, RunSummary, TestSummary } from '@crvouga/atlas/reports/test-catalog';
12
+
13
+ import type { DocFacets, ParsedSearch, SearchIssue, SearchFilters } from './query';
14
+ import { NO_FILTERS, SYNTAX_HELP } from './query';
15
+ import type { FieldName, FindDoc, FindField, FindStatus, Ranked } from './rank';
16
+
17
+ /** True when a behavior's current results reference at least one playable video recording. */
18
+ export function testHasVideo(test: Pick<TestSummary, 'outcomes'>): boolean {
19
+ return test.outcomes.some((outcome) => (videoCounts(outcome.evidence?.videos)?.recordings ?? 0) > 0);
20
+ }
21
+
22
+ /** The id of the finder document of a behavior (`run:` followed by its `TestSummary.key`): the identity every surface shares. */
23
+ export function behaviorId(key: string): string {
24
+ return `run:${key}`;
25
+ }
26
+
27
+ /** What the filters read about a behavior: its current status, where it ran, its domain, spec, tags and branches, and its videos. */
28
+ export function testFacets(test: TestSummary): DocFacets {
29
+ return {
30
+ status: [test.status, ...(test.stats.flaky ? ['flaky'] : [])],
31
+ env: test.environments.length ? test.environments : ['(none)'],
32
+ producer: test.producers,
33
+ domain: [test.spec?.domain || '(no spec)'],
34
+ spec: test.specPath ? [test.specPath] : [],
35
+ tag: [...test.tags, ...(test.artifactTags ?? []), ...(test.inheritedArtifactTags ?? [])].map((tag) => tag.replace(/^#/, '')),
36
+ branch: test.stats.branches,
37
+ video: testHasVideo(test)
38
+ };
39
+ }
40
+
41
+ /** The first line of a failure, for finding a behavior by what went wrong. */
42
+ function failureLines(test: TestSummary): string[] {
43
+ return test.outcomes.filter((outcome) => outcome.status === 'failed' && outcome.detail).map((outcome) => outcome.detail.split('\n')[0]!);
44
+ }
45
+
46
+ /** The searchable texts of a behavior: title, path and identifier, spec and failures, and metadata. Every surface uses these. */
47
+ export function testFields(test: TestSummary): FindField[] {
48
+ const fields: FindField[] = [{ name: 'title', text: test.title }];
49
+ fields.push({ name: 'path', text: [test.specPath ?? '', test.id].filter(Boolean).join('\n') });
50
+ const description = [test.spec?.title ?? '', ...failureLines(test)].filter(Boolean).join('\n');
51
+ if (description) fields.push({ name: 'description', text: description });
52
+ const metadata = [
53
+ test.spec?.domain ?? '',
54
+ ...test.tags,
55
+ ...(test.artifactTags ?? []),
56
+ ...Object.entries(test.labels).flatMap(([key, value]) => [key, value]),
57
+ ...test.environments,
58
+ ...test.producers
59
+ ].filter(Boolean);
60
+ if (metadata.length) fields.push({ name: 'metadata', text: metadata.join('\n') });
61
+ return fields;
62
+ }
63
+
64
+ function findStatus(status: TestSummary['status']): FindStatus {
65
+ return status;
66
+ }
67
+
68
+ /** A behavior as a document. Its id is {@link behaviorId}. */
69
+ export function testDoc(test: TestSummary): FindDoc {
70
+ return {
71
+ id: behaviorId(test.key),
72
+ group: 'Runs',
73
+ kind: 'run',
74
+ title: test.title,
75
+ subtitle: test.specPath ?? '',
76
+ fields: testFields(test),
77
+ status: findStatus(test.status),
78
+ related: [],
79
+ target: { to: '/', search: { test: test.key } },
80
+ recording: null,
81
+ facets: testFacets(test)
82
+ };
83
+ }
84
+
85
+ const documents = new WeakMap<readonly TestSummary[], ReadonlyMap<string, FindDoc>>();
86
+
87
+ /**
88
+ * The documents of every behavior of a report by `TestSummary.key`, built once per list of tests. Every surface that
89
+ * searches behaviors (the test list, the results of a run) searches this same set, so the same words are judged against
90
+ * the same vocabulary and the same behaviors are eligible.
91
+ */
92
+ export function behaviorDocs(tests: readonly TestSummary[]): ReadonlyMap<string, FindDoc> {
93
+ let found = documents.get(tests);
94
+ if (!found) {
95
+ found = new Map(tests.map((test) => [test.key, testDoc(test)] as const));
96
+ documents.set(tests, found);
97
+ }
98
+ return found;
99
+ }
100
+
101
+ /**
102
+ * The result of a behavior within one run as a document: the behavior's own fields (so it matches exactly when the
103
+ * behavior does), with the status, environment, producer and videos of this result for the filters. Without the
104
+ * behavior in the report any more, its title, spec and environment are all there is to match.
105
+ */
106
+ export function resultDoc(result: RunResult, test: TestSummary | undefined): FindDoc {
107
+ const base: FindDoc = test
108
+ ? testDoc(test)
109
+ : {
110
+ id: behaviorId(result.key),
111
+ group: 'Runs',
112
+ kind: 'run',
113
+ title: result.title,
114
+ subtitle: result.specPath ?? '',
115
+ fields: [
116
+ { name: 'title', text: result.title },
117
+ { name: 'path', text: [result.specPath ?? '', result.id].filter(Boolean).join('\n') },
118
+ { name: 'metadata', text: [result.environment ?? '', result.producer].filter(Boolean).join('\n') }
119
+ ],
120
+ status: findStatus(result.status),
121
+ related: [],
122
+ target: { to: '/', search: { test: result.key } },
123
+ recording: null
124
+ };
125
+ const facets = base.facets;
126
+ return {
127
+ ...base,
128
+ status: findStatus(result.status),
129
+ facets: {
130
+ status: [result.status, ...(facets?.status.includes('flaky') ? ['flaky'] : [])],
131
+ env: [result.environment ?? '(none)'],
132
+ producer: [result.producer],
133
+ domain: facets?.domain ?? ['(no spec)'],
134
+ spec: result.specPath ? [result.specPath] : [],
135
+ tag: facets?.tag ?? [],
136
+ branch: facets?.branch ?? [],
137
+ video: (videoCounts(result.videos)?.recordings ?? 0) > 0
138
+ }
139
+ };
140
+ }
141
+
142
+ /** What a run is searched by: its label, commit and branch, environments, producers, and the tests that failed in it. */
143
+ export function runDoc(run: RunSummary, label: string): FindDoc {
144
+ const status = run.counts.failed > 0 ? 'failed' : run.counts.running > 0 ? 'running' : run.counts['not-run'] > 0 ? 'not-run' : 'passed';
145
+ return {
146
+ id: `runs:${run.key}`,
147
+ group: 'Runs',
148
+ kind: 'run',
149
+ title: label,
150
+ subtitle: [run.branch, run.sha?.slice(0, 7)].filter(Boolean).join(' @ '),
151
+ fields: [
152
+ { name: 'title', text: label },
153
+ { name: 'path', text: [run.key, run.branch ?? '', run.sha ?? '', run.runId ?? ''].filter(Boolean).join('\n') },
154
+ { name: 'description', text: run.failing.flatMap((failure) => [failure.title, failure.detail.split('\n')[0] ?? '']).filter(Boolean).join('\n') },
155
+ { name: 'metadata', text: [...run.environments, ...run.producers].join('\n') }
156
+ ],
157
+ status,
158
+ related: [],
159
+ target: { to: '/', search: {} },
160
+ recording: null,
161
+ facets: {
162
+ status: [...(run.counts.failed > 0 ? ['failed'] : []), ...(run.counts.running > 0 ? ['running'] : []), ...(run.counts['not-run'] > 0 ? ['not-run'] : []), ...(run.counts.passed > 0 ? ['passed'] : [])],
163
+ env: run.environments.length ? run.environments : ['(none)'],
164
+ producer: run.producers,
165
+ domain: [],
166
+ spec: [],
167
+ tag: [],
168
+ branch: run.branch ? [run.branch] : [],
169
+ video: (run.videos?.recordings ?? 0) > 0
170
+ }
171
+ };
172
+ }
173
+
174
+ /**
175
+ * A search for a list that has no domain, spec or tag (the runs): those filters are removed and said so, instead of
176
+ * silently emptying the list or pretending to filter.
177
+ */
178
+ export function forRuns(parsed: ParsedSearch): ParsedSearch {
179
+ const issues: SearchIssue[] = [...parsed.issues];
180
+ const dropped = parsed.tokens.filter((token) => token.key === 'domain' || token.key === 'spec' || token.key === 'tag');
181
+ for (const token of dropped) issues.push({ token: token.text, message: `does not apply to runs, so it filters nothing here. Open a run to filter its tests by ${token.key}:. ${SYNTAX_HELP}` });
182
+ const filters: SearchFilters = { ...parsed.filters, domain: NO_FILTERS.domain, spec: NO_FILTERS.spec, tag: NO_FILTERS.tag };
183
+ return { ...parsed, filters, tokens: parsed.tokens.filter((token) => !dropped.includes(token)), issues };
184
+ }
185
+
186
+ const FIELD_WORD: Record<FieldName, string> = {
187
+ title: 'title',
188
+ steps: 'steps',
189
+ description: 'description',
190
+ metadata: 'tags and details',
191
+ path: 'path or identifier'
192
+ };
193
+
194
+ /**
195
+ * Why a document is in the results, for a person: an exact phrase or identifier, the fields the words were found
196
+ * in, and whether a close spelling or only the meaning brought it. Empty for a hit that carries no reason (a browse).
197
+ */
198
+ export function describeHit(hit: Pick<Ranked, 'fields' | 'exact' | 'literals' | 'fuzzy' | 'partial' | 'semantic' | 'related'>): string {
199
+ const parts: string[] = [];
200
+ if (hit.exact && hit.literals?.length) parts.push(`Exact: ${hit.literals.map((literal) => `“${literal}”`).join(', ')}`);
201
+ if (hit.semantic) parts.push('Similar meaning');
202
+ else if (hit.fields?.length) parts.push(`${hit.partial ? 'Some words in' : 'Matches'} ${hit.fields.map((field) => FIELD_WORD[field]).join(', ')}${hit.fuzzy ? ' (close spelling)' : ''}`);
203
+ if (hit.related && !parts.length) parts.push('Related to a match');
204
+ return parts.join(' · ');
205
+ }
@@ -5,6 +5,8 @@ import type { AtlasView, ItemStatus } from '../data/model';
5
5
  import { worstOf } from '../data/model/status';
6
6
  import { featureModel, scenarioLabel, slotsByScenario } from '../evidence/gherkin';
7
7
  import { kindOfFile, outlinePath, specFile } from '../spec-kind';
8
+ import type { TestSummary } from '@crvouga/atlas/reports/test-catalog';
9
+ import { testFacets, testFields } from './behaviors';
8
10
  import type { FindDoc, FindField, FindStatus, FindTarget } from './rank';
9
11
 
10
12
  export type CorpusChart = { readonly id: string; readonly name: string };
@@ -67,6 +69,8 @@ export type CorpusSource = {
67
69
  readonly texts: ReadonlyMap<string, string>;
68
70
  /** The project home is the test explorer (no statecharts): a check without a published spec opens there. */
69
71
  readonly explorer?: boolean;
72
+ /** The report's tests by key. A check that is one of them is searched by the same fields and filters as in the test list. */
73
+ readonly tests?: ReadonlyMap<string, TestSummary>;
70
74
  };
71
75
 
72
76
  function itemStatus(status: ItemStatus): FindStatus {
@@ -130,9 +134,17 @@ function actionsIn(text: string): string[] {
130
134
  return [...new Set([...text.matchAll(ACTION)].map((match) => match[1]!))];
131
135
  }
132
136
 
133
- function runDoc(slot: CorpusSlot, title: string, kind: string | null, file: string, explorer = false): FindDoc {
137
+ /** A check's fields: those the test list searches, else what the check itself says. */
138
+ function checkFields(slot: CorpusSlot, title: string, file: string, test: TestSummary | undefined): { fields: FindField[]; facets?: FindDoc['facets'] } {
139
+ if (!test) return { fields: fieldsOf(title, '', slot.detail, file) };
140
+ const fields = testFields(test);
141
+ return { fields: title === test.title ? fields : [{ name: 'title', text: title }, ...fields], facets: testFacets(test) };
142
+ }
143
+
144
+ function runDoc(slot: CorpusSlot, title: string, kind: string | null, file: string, explorer = false, test?: TestSummary): FindDoc {
145
+ const { fields, facets } = checkFields(slot, title, file, test);
134
146
  if (explorer) {
135
- return { id: `run:${slot.key}`, group: 'Runs', kind: 'run', title, subtitle: file, fields: fieldsOf(title, '', slot.detail, file), status: slot.status, related: [], target: { to: '/', search: { test: slot.key } }, recording: null };
147
+ return { id: `run:${slot.key}`, group: 'Runs', kind: 'run', title, subtitle: file, fields, status: slot.status, related: [], target: { to: '/', search: { test: slot.key } }, recording: null, ...(facets ? { facets } : {}) };
136
148
  }
137
149
  const recording = timelineTarget(kind, file, [slot]);
138
150
  return {
@@ -141,12 +153,13 @@ function runDoc(slot: CorpusSlot, title: string, kind: string | null, file: stri
141
153
  kind: 'run',
142
154
  title,
143
155
  subtitle: file,
144
- fields: fieldsOf(title, '', slot.detail, file),
156
+ fields,
145
157
  status: slot.status,
146
158
  related: [],
147
159
  // Enter opens the recording when this check has a timeline.
148
160
  target: recording ?? (kind ? specTarget(kind, file, slot.id) : { to: '/' as const, search: {} }),
149
- recording
161
+ recording,
162
+ ...(facets ? { facets } : {})
150
163
  };
151
164
  }
152
165
 
@@ -231,7 +244,7 @@ export function buildCorpus(source: CorpusSource): FindDoc[] {
231
244
  const id = `run:${slot.key}`;
232
245
  if (seenRuns.has(id)) return;
233
246
  seenRuns.add(id);
234
- docs.push(runDoc(slot, title, kind, file, explorer));
247
+ docs.push(runDoc(slot, title, kind, file, explorer, source.tests?.get(slot.key)));
235
248
  };
236
249
 
237
250
  for (const file of source.files) {
@@ -326,7 +339,7 @@ export function buildCorpus(source: CorpusSource): FindDoc[] {
326
339
  }
327
340
 
328
341
  /** The view and the evidence the page already holds, in the shape `buildCorpus` reads. */
329
- export function corpusFrom(view: AtlasView | null, files: readonly string[], titleOf: (file: string) => string, evidence: EvidenceView, texts: ReadonlyMap<string, string>): FindDoc[] {
342
+ export function corpusFrom(view: AtlasView | null, files: readonly string[], titleOf: (file: string) => string, evidence: EvidenceView, texts: ReadonlyMap<string, string>, tests?: ReadonlyMap<string, TestSummary>): FindDoc[] {
330
343
  const slots: CorpusSlot[] = [];
331
344
  const take = (slot: CheckSlot) => {
332
345
  slots.push({
@@ -370,6 +383,7 @@ export function corpusFrom(view: AtlasView | null, files: readonly string[], tit
370
383
  files: files.map((path) => ({ path, title: titleOf(path) })),
371
384
  slots,
372
385
  texts,
373
- explorer: !files.some(isStatechartFile)
386
+ explorer: !files.some(isStatechartFile),
387
+ ...(tests ? { tests } : {})
374
388
  });
375
389
  }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @module Searching a list of documents
3
+ *
4
+ * What the test list, the runs list and the results of an opened run call to narrow themselves by a typed query. It is
5
+ * the finder's engine (`rank.ts`) applied to a whole list: every document that is eligible for the words, phrases and
6
+ * identifiers of the query, best first, with the reason it matched; meaning-based additions when an embedder is
7
+ * available. The structured filters (`status:`, `has:video`…) are applied by the caller with `passesFilters`, so a
8
+ * count can leave one of them out; nothing here limits or pages the result.
9
+ */
10
+ import { useMemo } from 'react';
11
+
12
+ import { applySemantic } from './semantic';
13
+ import { indexCorpus, matchText, type FindDoc, type FindIndex, type Ranked } from './rank';
14
+ import { parseSearch, passesFilters, type ParsedSearch } from './query';
15
+ import { useSemantic, type SemanticStatus } from './semantic-search';
16
+
17
+ /** The semantic part of a search to add to the words, when there is one. */
18
+ export type SemanticInput = {
19
+ readonly queryVector: Float32Array | null;
20
+ readonly vectors: ReadonlyMap<string, Float32Array>;
21
+ /** The documents a paraphrase may add (those that pass the filters). */
22
+ readonly docs: readonly FindDoc[];
23
+ };
24
+
25
+ /**
26
+ * Every document eligible for the text of a search (words, quoted phrases, identifiers), best first, by document id; null
27
+ * when the search has no text. A document missing a distinctive word is not eligible. With a semantic input, documents
28
+ * close in meaning are added (not when the search asks for an exact phrase or identifier).
29
+ */
30
+ export function searchList(index: FindIndex, parsed: ParsedSearch, semantic?: SemanticInput): Map<string, Ranked> | null {
31
+ const lexical = matchText(index, parsed, { snippets: false });
32
+ if (lexical === null) return null;
33
+ const plain: Ranked[] = lexical.map(({ order: _order, misses: _misses, ...hit }) => hit);
34
+ const literal = parsed.phrases.length + parsed.identifiers.length > 0;
35
+ const list = semantic?.queryVector && !literal ? applySemantic(semantic.docs, plain, semantic.queryVector, semantic.vectors, Number.POSITIVE_INFINITY) : plain;
36
+ return new Map(list.map((hit) => [hit.doc.id, hit]));
37
+ }
38
+
39
+ /** What {@link useDocSearch} reads out. */
40
+ export type DocSearch = {
41
+ readonly parsed: ParsedSearch;
42
+ /** Eligible documents by id, best first; null when the query has no text to match (it may still have filters). */
43
+ readonly hits: ReadonlyMap<string, Ranked> | null;
44
+ /** How the meaning-based part stands: idle when there is nothing to match by meaning. */
45
+ readonly status: SemanticStatus;
46
+ };
47
+
48
+ /**
49
+ * Parses `query` and searches `docs` with it: lexical results at once, meaning-based additions when they arrive. The
50
+ * index is only built when the query has text.
51
+ */
52
+ export function useDocSearch(docs: readonly FindDoc[], query: string | undefined): DocSearch {
53
+ const parsed = useMemo(() => parseSearch(query ?? ''), [query]);
54
+ const hasText = parsed.words.length > 0 || parsed.phrases.length > 0 || parsed.identifiers.length > 0;
55
+ const index = useMemo(() => (hasText ? indexCorpus(docs) : null), [docs, hasText]);
56
+ const lexical = useMemo(() => (index ? searchList(index, parsed) : null), [index, parsed]);
57
+ const candidates = useMemo(() => (hasText ? docs.filter((doc) => passesFilters(doc.facets, parsed.filters)) : []), [docs, parsed, hasText]);
58
+ const lexicalDocs = useMemo(() => (lexical ? [...lexical.values()].map((hit) => hit.doc) : []), [lexical]);
59
+ const wantsMeaning = hasText && parsed.words.length > 0 && parsed.phrases.length + parsed.identifiers.length === 0;
60
+ const semantic = useSemantic(wantsMeaning, parsed.words, candidates, lexicalDocs);
61
+ const hits = useMemo(
62
+ () => (index ? searchList(index, parsed, { queryVector: semantic.queryVector, vectors: semantic.vectors, docs: candidates }) : null),
63
+ [index, parsed, semantic.queryVector, semantic.vectors, candidates]
64
+ );
65
+ return { parsed, hits, status: wantsMeaning ? semantic.status : 'idle' };
66
+ }