@crvouga/atlas 1.4.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.
Files changed (27) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/docs/ui.md +32 -2
  3. package/llms-full.txt +32 -2
  4. package/package.json +1 -1
  5. package/packages/visualizer/README.md +28 -0
  6. package/packages/visualizer/src/evidence/artifacts.tsx +17 -8
  7. package/packages/visualizer/src/evidence/behavior-panel.tsx +291 -0
  8. package/packages/visualizer/src/evidence/behavior.test.ts +99 -0
  9. package/packages/visualizer/src/evidence/behavior.ts +268 -0
  10. package/packages/visualizer/src/evidence/check-detail.tsx +4 -0
  11. package/packages/visualizer/src/evidence/feature-page.tsx +82 -36
  12. package/packages/visualizer/src/evidence/gherkin.ts +31 -1
  13. package/packages/visualizer/src/evidence/spec-index.tsx +48 -15
  14. package/packages/visualizer/src/explorer/Explorer.tsx +105 -25
  15. package/packages/visualizer/src/explorer/RunsView.tsx +173 -35
  16. package/packages/visualizer/src/explorer/catalog.tsx +9 -5
  17. package/packages/visualizer/src/explorer/search-notes.tsx +74 -0
  18. package/packages/visualizer/src/explorer/search.ts +16 -6
  19. package/packages/visualizer/src/find/Finder.tsx +127 -81
  20. package/packages/visualizer/src/find/behaviors.ts +205 -0
  21. package/packages/visualizer/src/find/corpus.ts +21 -7
  22. package/packages/visualizer/src/find/doc-search.ts +66 -0
  23. package/packages/visualizer/src/find/query.ts +247 -0
  24. package/packages/visualizer/src/find/rank.ts +195 -47
  25. package/packages/visualizer/src/find/search.test.ts +322 -0
  26. package/packages/visualizer/src/find/semantic-search.ts +153 -0
  27. package/packages/visualizer/src/find/semantic.ts +2 -2
@@ -0,0 +1,268 @@
1
+ /**
2
+ * @module Behavior coverage
3
+ *
4
+ * Reads a feature the way a product reader does: each scenario (or each concrete example of an outline) is one
5
+ * behavior, and each behavior is in exactly one coverage state. The states keep apart what a green count would blur:
6
+ * a passing test with a playable recording of the current promise is `demonstrated`; a passing test with no
7
+ * recording, with a recording of an older revision of the spec, or only in API environments is not. Nothing here
8
+ * decides an outcome: statuses and winners come from the combined report, and a state is a reading of them.
9
+ * A missing fingerprint, recording or result is unknown or missing, never a pass.
10
+ */
11
+ import type { CheckSlot } from '@crvouga/atlas/reports/evidence-view';
12
+ import type { EvidenceResult } from '@crvouga/atlas/reports/layer-merge';
13
+
14
+ import { exampleIndexFor, exampleRows, scenarioLabel, type FeatureScenario } from './gherkin';
15
+
16
+ /** Where a behavior stands, from the most reassuring to the least. */
17
+ export type CoverageState =
18
+ | 'demonstrated'
19
+ | 'outdated-recording'
20
+ | 'recording-missing'
21
+ | 'no-recording'
22
+ | 'api-only'
23
+ | 'failed'
24
+ | 'running'
25
+ | 'skipped'
26
+ | 'parked'
27
+ | 'unexecuted';
28
+
29
+ /** What each state is called and why, in the order a summary lists them. */
30
+ export const COVERAGE: Readonly<Record<CoverageState, { readonly label: string; readonly meaning: string; readonly color: string }>> = {
31
+ demonstrated: {
32
+ label: 'Demonstrated',
33
+ meaning: 'A passing result with a recording of the current promise.',
34
+ color: 'green'
35
+ },
36
+ 'outdated-recording': {
37
+ label: 'Recording is older',
38
+ meaning: 'A passing result, but the recording is of an earlier revision of the spec.',
39
+ color: 'orange'
40
+ },
41
+ 'recording-missing': {
42
+ label: 'Recording missing',
43
+ meaning: 'A passing result whose expected recording was not published or cannot be played.',
44
+ color: 'orange'
45
+ },
46
+ 'no-recording': {
47
+ label: 'No recording',
48
+ meaning: 'A passing result with nothing to watch.',
49
+ color: 'gray'
50
+ },
51
+ 'api-only': {
52
+ label: 'API only',
53
+ meaning: 'A passing result recorded only through the API, so there is no screen to watch.',
54
+ color: 'gray'
55
+ },
56
+ failed: {
57
+ label: 'Failed',
58
+ meaning: 'The latest result failed.',
59
+ color: 'red'
60
+ },
61
+ running: {
62
+ label: 'Running',
63
+ meaning: 'A run is checking it now.',
64
+ color: 'blue'
65
+ },
66
+ skipped: {
67
+ label: 'Skipped',
68
+ meaning: 'It was reported as skipped or not run.',
69
+ color: 'gray'
70
+ },
71
+ parked: {
72
+ label: 'Parked',
73
+ meaning: 'The scenario is tagged as parked or work in progress, and has no result.',
74
+ color: 'gray'
75
+ },
76
+ unexecuted: {
77
+ label: 'Not executed',
78
+ meaning: 'No test reported a result for it.',
79
+ color: 'gray'
80
+ }
81
+ };
82
+
83
+ /** The order a coverage summary lists states in. */
84
+ export const COVERAGE_ORDER = Object.keys(COVERAGE) as CoverageState[];
85
+
86
+ /** How the spec the evidence was recorded against compares with the spec now. */
87
+ export type Revision = {
88
+ /** Fingerprint of the spec now (the promise). Null when the outline has none. */
89
+ readonly promise: string | null;
90
+ /** Fingerprint of the spec when the evidence was recorded. Null when the layer did not record one. */
91
+ readonly evidence: string | null;
92
+ /** `same`, `different`, or `unknown` when either side is missing. */
93
+ readonly match: 'same' | 'different' | 'unknown';
94
+ };
95
+
96
+ /** Compares two fingerprints: unknown unless both are there. */
97
+ export function compareRevisions(promise: string | null | undefined, evidence: string | null | undefined): Revision {
98
+ const now = promise || null;
99
+ const then = evidence || null;
100
+ return {
101
+ promise: now,
102
+ evidence: then,
103
+ match: now === null || then === null ? 'unknown' : now === then ? 'same' : 'different'
104
+ };
105
+ }
106
+
107
+ /** The tags that mark a scenario as deliberately not running, and the state each gives it. */
108
+ const TAG_STATE: Readonly<Record<string, CoverageState>> = {
109
+ parked: 'parked',
110
+ wip: 'parked',
111
+ pending: 'parked',
112
+ skip: 'skipped',
113
+ skipped: 'skipped',
114
+ ignore: 'skipped'
115
+ };
116
+
117
+ /** The tags that say a recording was picked to stand for a behavior. */
118
+ const REPRESENTATIVE = new Set(['demo', 'representative']);
119
+
120
+ /** Whether a recorded result or its scenario is tagged as the representative demo. */
121
+ export function isRepresentative(tags: readonly string[]): boolean {
122
+ return tags.some((tag) => REPRESENTATIVE.has(tag.replace(/^[#@]/, '').toLowerCase()));
123
+ }
124
+
125
+ /** A result's playable video references: those that name bytes. */
126
+ function playable(result: EvidenceResult): number {
127
+ return result.row.evidence?.videos?.filter((video) => video.available).length ?? 0;
128
+ }
129
+
130
+ /** Environments a reader cannot watch: a result recorded only through these has no screen. */
131
+ function isApi(environment: string | null): boolean {
132
+ return environment !== null && /^(api|http|rest|graphql|service|backend)(-|$)/.test(environment);
133
+ }
134
+
135
+ /** What is known about one behavior. */
136
+ export type Behavior = {
137
+ /** Stable within the page: the scenario and its example. */
138
+ readonly key: string;
139
+ readonly scenario: FeatureScenario;
140
+ /** The example label of an outline's row (what the producer named it), or null for a plain scenario. */
141
+ readonly example: string | null;
142
+ /** The recorded check, or null when nothing reported on it. */
143
+ readonly slot: CheckSlot | null;
144
+ readonly state: CoverageState;
145
+ /** The result to watch: the newest of those with a playable recording, preferring one of the current revision. */
146
+ readonly demo: EvidenceResult | null;
147
+ /** The result the revision line and provenance describe: the demo, else the newest. */
148
+ readonly subject: EvidenceResult | null;
149
+ readonly revision: Revision;
150
+ /** Whether the scenario or the recorded result is tagged as the representative demo. */
151
+ readonly representative: boolean;
152
+ };
153
+
154
+ /** What the page knows about the spec as it is now and as each layer recorded it. */
155
+ export type RevisionSource = {
156
+ /** The spec's fingerprint now. */
157
+ readonly current: string | null;
158
+ /** The fingerprint a layer recorded for this spec, if it did. */
159
+ readonly recorded: (layerId: string) => string | undefined;
160
+ };
161
+
162
+ /**
163
+ * The state of one behavior from its recorded check (or none). A failing, running or skipped result is that, whatever
164
+ * recordings exist. A pass is `demonstrated` only with a playable recording that is not known to be of another
165
+ * revision; a recording whose revision cannot be compared is still shown, with the revision stated as unknown.
166
+ */
167
+ export function behaviorOf(
168
+ scenario: FeatureScenario,
169
+ slot: CheckSlot | null,
170
+ example: string | null,
171
+ revisions: RevisionSource,
172
+ key: string
173
+ ): Behavior {
174
+ const outcomes = slot?.outcomes ?? [];
175
+ const revisionOf = (result: EvidenceResult | null) =>
176
+ compareRevisions(revisions.current, result ? revisions.recorded(result.layer.id) : undefined);
177
+ const withVideo = outcomes.filter((result) => result.row.status === 'passed' && playable(result) > 0);
178
+ const demo = withVideo.find((result) => revisionOf(result).match !== 'different') ?? withVideo[0] ?? null;
179
+ const subject = demo ?? outcomes[0] ?? null;
180
+ const revision = revisionOf(subject);
181
+ const representative =
182
+ isRepresentative(scenario.tags) ||
183
+ outcomes.some((result) => isRepresentative([...(result.row.evidence?.tags ?? []), ...(result.row.evidence?.artifactTags ?? [])]));
184
+ const base = {
185
+ key,
186
+ scenario,
187
+ example,
188
+ slot,
189
+ demo,
190
+ subject,
191
+ revision,
192
+ representative
193
+ };
194
+ if (!slot || outcomes.length === 0) {
195
+ const tagged = scenario.tags.map((tag) => TAG_STATE[tag.toLowerCase()]).find((state) => state !== undefined);
196
+ return { ...base, state: tagged ?? 'unexecuted' };
197
+ }
198
+ if (slot.status === 'failed') return { ...base, state: 'failed' };
199
+ if (slot.status === 'running') return { ...base, state: 'running' };
200
+ if (slot.status === 'not-run') return { ...base, state: 'skipped' };
201
+ if (demo)
202
+ return {
203
+ ...base,
204
+ state: revision.match === 'different' ? 'outdated-recording' : 'demonstrated'
205
+ };
206
+ const passed = outcomes.filter((result) => result.row.status === 'passed');
207
+ // A recording was promised and is not here, or is referenced without bytes: that is a gap, not an API test.
208
+ if (
209
+ passed.some((result) => result.row.recording && !['uploaded', 'referenced', 'not-applicable'].includes(result.row.recording.state)) ||
210
+ passed.some((result) => (result.row.evidence?.videos?.length ?? 0) > 0)
211
+ ) {
212
+ return { ...base, state: 'recording-missing' };
213
+ }
214
+ if (passed.length > 0 && passed.every((result) => isApi(result.layer.environment))) return { ...base, state: 'api-only' };
215
+ return { ...base, state: 'no-recording' };
216
+ }
217
+
218
+ /**
219
+ * Every behavior of a scenario. A plain scenario is one. An outline is one per example row, never the template as an
220
+ * extra: a recorded example is filed under the row it names; a row nothing reported on is `unexecuted` (or parked);
221
+ * a recorded example whose label names no row is kept as its own behavior and the unclaimed rows are counted against
222
+ * the recorded results that remain, so the total never exceeds the rows by inventing a template.
223
+ */
224
+ export function scenarioBehaviors(
225
+ scenario: FeatureScenario,
226
+ slots: readonly CheckSlot[],
227
+ feature: string,
228
+ revisions: RevisionSource
229
+ ): Behavior[] {
230
+ const rows = exampleRows(scenario);
231
+ if (rows.length === 0) {
232
+ // A scenario with no examples fills one slot per environment/parameter set, which the slot already groups.
233
+ const slot = slots[0] ?? null;
234
+ if (slots.length <= 1) return [behaviorOf(scenario, slot, null, revisions, scenario.name)];
235
+ return slots.map((each) => behaviorOf(scenario, each, null, revisions, each.key));
236
+ }
237
+ const claimed = new Map<number, CheckSlot[]>();
238
+ const loose: CheckSlot[] = [];
239
+ for (const slot of slots) {
240
+ const label = scenarioLabel(feature, slot.title).slice(scenario.name.length + 3);
241
+ const at = exampleIndexFor(scenario, label);
242
+ if (at < 0) loose.push(slot);
243
+ else claimed.set(at, [...(claimed.get(at) ?? []), slot]);
244
+ }
245
+ const behaviors: Behavior[] = [];
246
+ const unmatched = rows.map((_, at) => at).filter((at) => !claimed.has(at));
247
+ // Results whose wording matches no row stand in for the rows nothing claimed, in order, before any row is called unexecuted.
248
+ const spare = [...loose];
249
+ rows.forEach((entry, at) => {
250
+ const found = claimed.get(at) ?? (unmatched.includes(at) && spare.length ? [spare.shift()!] : []);
251
+ const label = found[0] ? scenarioLabel(feature, found[0].title).slice(scenario.name.length + 3) : entry.row.join(', ');
252
+ if (found.length === 0) behaviors.push(behaviorOf(scenario, null, label, revisions, `${scenario.name}:${at}`));
253
+ for (const slot of found) behaviors.push(behaviorOf(scenario, slot, label, revisions, slot.key));
254
+ });
255
+ for (const slot of spare)
256
+ behaviors.push(behaviorOf(scenario, slot, scenarioLabel(feature, slot.title).slice(scenario.name.length + 3), revisions, slot.key));
257
+ return behaviors;
258
+ }
259
+
260
+ /** Behaviors counted by state. Every state is present, so an absent one reads as zero, not as unknown. */
261
+ export type CoverageCounts = Readonly<Record<CoverageState, number>>;
262
+
263
+ /** Counts behaviors by coverage state; only `demonstrated` is a demonstrated pass. */
264
+ export function coverageCounts(behaviors: readonly Behavior[]): CoverageCounts {
265
+ const counts = Object.fromEntries(COVERAGE_ORDER.map((state) => [state, 0])) as Record<CoverageState, number>;
266
+ for (const behavior of behaviors) counts[behavior.state] += 1;
267
+ return counts;
268
+ }
@@ -61,12 +61,15 @@ export function ResultRow({
61
61
  slot,
62
62
  open,
63
63
  onToggle,
64
+ badge,
64
65
  children
65
66
  }: {
66
67
  label: string;
67
68
  slot: CheckSlot | null;
68
69
  open: boolean;
69
70
  onToggle: () => void;
71
+ /** A short extra mark after the label, such as a coverage state. */
72
+ badge?: ReactNode;
70
73
  children: ReactNode;
71
74
  }) {
72
75
  const now = useNow();
@@ -96,6 +99,7 @@ export function ResultRow({
96
99
  </Flex>
97
100
  )}
98
101
  </Text>
102
+ {badge}
99
103
  {slot?.live && (
100
104
  <Text span inherit>
101
105
  Running now
@@ -1,8 +1,11 @@
1
1
  import type { CheckSlot, SpecEvidence } from '@crvouga/atlas/reports/evidence-view';
2
2
  import { Alert, Box, Container, List, Stack, Text, Title } from '@mantine/core';
3
3
  import { useMemo } from 'react';
4
- import type { SpecKind } from '../spec-kind';
4
+ import { outlinePath, type SpecKind } from '../spec-kind';
5
5
  import { CheckDetail, PlainSteps, ResultRow, type CheckSelection, type SelectCheck } from './check-detail';
6
+ import { behaviorOf, coverageCounts, scenarioBehaviors, type Behavior, type RevisionSource } from './behavior';
7
+ import { BehaviorPanel, CoverageBadge, CoverageSummary } from './behavior-panel';
8
+ import { useEvidence } from './evidence-context';
6
9
  import { featureModel, scenarioLabel, slotsByScenario, type FeatureModel, type FeatureScenario } from './gherkin';
7
10
  import { Bounded } from './marks';
8
11
  import { SpecHeader } from './spec-header';
@@ -23,7 +26,10 @@ function parse(text: string): {
23
26
  try {
24
27
  return { feature: featureModel(text), error: null };
25
28
  } catch (error) {
26
- return { feature: null, error: error instanceof Error ? error.message : String(error) };
29
+ return {
30
+ feature: null,
31
+ error: error instanceof Error ? error.message : String(error)
32
+ };
27
33
  }
28
34
  }
29
35
  /** A feature read as its rules and scenarios, each scenario with what was recorded when it ran. */
@@ -35,43 +41,81 @@ export function FeaturePage({ file, text, kind, entry, selection, select }: Prop
35
41
  () => (feature ? slotsByScenario(feature, slots) : { byScenario: new Map<string, CheckSlot[]>(), leftOver: slots }),
36
42
  [feature, slots]
37
43
  );
38
- const scenarios = feature?.rules.flatMap((rule) => rule.scenarios.filter((scenario) => !scenario.background)) ?? [];
39
- const planned =
40
- scenarios.reduce((count, scenario) => count + Math.max(1, filed.byScenario.get(scenario.name)?.length ?? 0), 0) + filed.leftOver.length;
44
+ const { specs, evidence } = useEvidence();
45
+ const specPath = outlinePath(file);
46
+ const revisions = useMemo<RevisionSource>(() => {
47
+ const recorded = new Map(evidence.map((layer) => [layer.layer.id, layer.fingerprints[specPath]]));
48
+ return {
49
+ current: specs.find((spec) => spec.path === specPath)?.fingerprint ?? null,
50
+ recorded: (id) => recorded.get(id)
51
+ };
52
+ }, [specs, evidence, specPath]);
53
+ const behaviors = useMemo(() => {
54
+ const byScenario = new Map<FeatureScenario, Behavior[]>();
55
+ for (const scenario of feature?.rules.flatMap((rule) => rule.scenarios.filter((each) => !each.background)) ?? []) {
56
+ byScenario.set(scenario, scenarioBehaviors(scenario, filed.byScenario.get(scenario.name) ?? [], feature!.name, revisions));
57
+ }
58
+ // A result no scenario claims is still a behavior that was checked, so it is counted.
59
+ const stray: FeatureScenario = {
60
+ keyword: 'Scenario',
61
+ name: '',
62
+ description: '',
63
+ background: false,
64
+ tags: [],
65
+ steps: [],
66
+ examples: []
67
+ };
68
+ const other = filed.leftOver.map((slot) => behaviorOf({ ...stray, name: slot.title }, slot, null, revisions, slot.key));
69
+ return { byScenario, all: [...[...byScenario.values()].flat(), ...other] };
70
+ }, [feature, filed, revisions]);
71
+ const counts = useMemo(() => coverageCounts(behaviors.all), [behaviors]);
72
+ const planned = behaviors.all.length;
41
73
  const toggle = (id: string) => select({ check: selection.check === id ? null : id, layer: null });
42
- const rows = (scenario: FeatureScenario) => {
43
- const written = <PlainSteps steps={scenario.steps} examples={scenario.examples} />;
44
- const recorded = (filed.byScenario.get(scenario.name) ?? []).filter((slot) => !runningOnly || slot.status === 'running');
45
- if (!recorded.length) {
46
- if (runningOnly) return null;
47
- const id = `scenario:${scenario.name}`;
74
+ const rows = (scenario: FeatureScenario) =>
75
+ (behaviors.byScenario.get(scenario) ?? []).map((behavior) => {
76
+ if (runningOnly && behavior.slot?.status !== 'running') return null;
77
+ const { slot } = behavior;
78
+ const written = <PlainSteps steps={scenario.steps} examples={scenario.examples} />;
79
+ const id = slot ? slot.id : `scenario:${scenario.name}${behavior.example ? `:${behavior.example}` : ''}`;
80
+ const label = slot
81
+ ? scenarioLabel(feature!.name, slot.title)
82
+ : behavior.example
83
+ ? `${scenario.name} — ${behavior.example}`
84
+ : scenario.name;
48
85
  return (
49
- <ResultRow key={id} label={scenario.name} slot={null} open={selection.check === id} onToggle={() => toggle(id)}>
50
- <Text component="div" c="dimmed">
51
- No completed test report for this scenario yet.
52
- </Text>
53
- {written}
86
+ <ResultRow
87
+ key={id}
88
+ label={label}
89
+ slot={slot}
90
+ badge={<CoverageBadge state={behavior.state} />}
91
+ open={selection.check === id}
92
+ onToggle={() => toggle(id)}
93
+ >
94
+ <BehaviorPanel
95
+ behavior={behavior}
96
+ start={selection.check === id && selection.layer ? 'inspect' : undefined}
97
+ inspect={
98
+ slot ? (
99
+ <CheckDetail
100
+ slot={slot}
101
+ layerId={selection.check === id ? selection.layer : undefined}
102
+ select={(patch) => select({ check: id, ...patch })}
103
+ reads="steps"
104
+ written={written}
105
+ />
106
+ ) : (
107
+ <>
108
+ <Text component="div" c="dimmed">
109
+ No completed test report for this scenario yet.
110
+ </Text>
111
+ {written}
112
+ </>
113
+ )
114
+ }
115
+ />
54
116
  </ResultRow>
55
117
  );
56
- }
57
- return recorded.map((slot) => (
58
- <ResultRow
59
- key={slot.key}
60
- label={scenarioLabel(feature!.name, slot.title)}
61
- slot={slot}
62
- open={selection.check === slot.id}
63
- onToggle={() => toggle(slot.id)}
64
- >
65
- <CheckDetail
66
- slot={slot}
67
- layerId={selection.check === slot.id ? selection.layer : undefined}
68
- select={(patch) => select({ check: slot.id, ...patch })}
69
- reads="steps"
70
- written={written}
71
- />
72
- </ResultRow>
73
- ));
74
- };
118
+ });
75
119
  return (
76
120
  <Container component="article" size="xl" w="100%" py="xl">
77
121
  <SpecHeader
@@ -87,6 +131,7 @@ export function FeaturePage({ file, text, kind, entry, selection, select }: Prop
87
131
  </Text>
88
132
  )}
89
133
  </SpecHeader>
134
+ <CoverageSummary counts={counts} total={behaviors.all.length} />
90
135
  <SourceText text={text} file={file} withResults />
91
136
  <Box component="section" id="spec-results" aria-label="Recorded results" tabIndex={-1}>
92
137
  <TestFilter slots={slots} />
@@ -112,7 +157,8 @@ export function FeaturePage({ file, text, kind, entry, selection, select }: Prop
112
157
  .map((scenario, at) => (
113
158
  <Stack key={at} gap="sm" miw={0}>
114
159
  <Text component="div" c="dimmed">
115
- Before each scenario{scenario.name ? `: ${scenario.name}` : ''}
160
+ Before each scenario
161
+ {scenario.name ? `: ${scenario.name}` : ''}
116
162
  </Text>
117
163
  <PlainSteps steps={scenario.steps} />
118
164
  </Stack>
@@ -13,6 +13,8 @@ export type FeatureScenario = {
13
13
  readonly name: string;
14
14
  readonly description: string;
15
15
  readonly background: boolean;
16
+ /** The scenario's own tags, without the `@` (a parked or skipped scenario says so here). */
17
+ readonly tags: readonly string[];
16
18
  readonly steps: readonly FeatureStep[];
17
19
  readonly examples: readonly FeatureExamples[];
18
20
  };
@@ -50,13 +52,14 @@ export function featureModel(text: string): FeatureModel | null {
50
52
  for (const entry of child.rule ? child.rule.children : [child]) {
51
53
  const background = entry.background;
52
54
  const scenario = entry.scenario;
53
- if (background) group.scenarios.push({ keyword: background.keyword, name: background.name, description: prose(background.description), background: true, steps: steps(background.steps), examples: [] });
55
+ if (background) group.scenarios.push({ keyword: background.keyword, name: background.name, description: prose(background.description), background: true, tags: [], steps: steps(background.steps), examples: [] });
54
56
  if (scenario) {
55
57
  group.scenarios.push({
56
58
  keyword: scenario.keyword,
57
59
  name: scenario.name,
58
60
  description: prose(scenario.description),
59
61
  background: false,
62
+ tags: [...new Set([...feature.tags, ...(child.rule?.tags ?? []), ...scenario.tags].map((tag) => tag.name.replace(/^@/, '')))],
60
63
  steps: steps(scenario.steps),
61
64
  examples: scenario.examples.map((example) => ({ name: example.name, header: cells(example.tableHeader), rows: example.tableBody.map(cells) }))
62
65
  });
@@ -96,3 +99,30 @@ export function slotsByScenario(feature: FeatureModel, slots: readonly CheckSlot
96
99
  }
97
100
  return { byScenario, leftOver };
98
101
  }
102
+
103
+ /** The rows of an outline's examples, each with the names of its columns. */
104
+ export function exampleRows(scenario: Pick<FeatureScenario, 'examples'>): { readonly header: readonly string[]; readonly row: readonly string[] }[] {
105
+ return scenario.examples.flatMap((example) => example.rows.map((row) => ({ header: example.header, row })));
106
+ }
107
+
108
+ /** Whether a recorded example label (what follows `Scenario — `) names one row: by its cells, joined, or by `column=value` pairs. */
109
+ function namesRow(label: string, entry: { readonly header: readonly string[]; readonly row: readonly string[] }) {
110
+ const joined = [entry.row.join(', '), entry.row.join(' '), entry.row.join(' | '), entry.header.map((name, at) => `${name}=${entry.row[at] ?? ''}`).join(', ')];
111
+ return joined.includes(label) || entry.row.includes(label);
112
+ }
113
+
114
+ /** The index (in {@link exampleRows}) of the row a recorded label stands for, or -1 when the label names no single row (the producer worded it its own way, or two rows read alike). */
115
+ export function exampleIndexFor(scenario: Pick<FeatureScenario, 'examples'>, label: string): number {
116
+ const rows = exampleRows(scenario);
117
+ const found = rows.flatMap((entry, at) => (namesRow(label, entry) ? [at] : []));
118
+ return found.length === 1 ? found[0]! : -1;
119
+ }
120
+
121
+ /** The steps of a scenario with one example's values put in for its `<column>` placeholders, so the concrete inputs read as written. */
122
+ export function fillExample(steps: readonly FeatureStep[], entry: { readonly header: readonly string[]; readonly row: readonly string[] }): FeatureStep[] {
123
+ const fill = (text: string) => text.replace(/<([^<>]+)>/g, (whole, name: string) => {
124
+ const at = entry.header.indexOf(name);
125
+ return at < 0 ? whole : (entry.row[at] ?? whole);
126
+ });
127
+ return steps.map((step) => ({ ...step, text: fill(step.text), docString: step.docString === null ? null : fill(step.docString), table: step.table.map((row) => row.map(fill)) }));
128
+ }
@@ -1,7 +1,7 @@
1
1
  import { addSummaries, NO_EVIDENCE, type SpecSummary } from '@crvouga/atlas/reports/evidence-view';
2
- import { Box, Button, Group, NavLink, Stack, Text, Title } from '@mantine/core';
2
+ import { Box, Button, Group, NavLink, SegmentedControl, Stack, Text, TextInput, Title } from '@mantine/core';
3
3
  import { Link, useSearch } from '@tanstack/react-router';
4
- import { useMemo } from 'react';
4
+ import { useMemo, useState } from 'react';
5
5
  import { folderOf, outlinePath, specFile, type SpecKind } from '../spec-kind';
6
6
  import { useUiStore, type ResultFilter } from '../state/ui-store';
7
7
  import { useEvidence } from './evidence-context';
@@ -17,30 +17,42 @@ const FILTERS: readonly {
17
17
  { id: 'failed', label: 'Failing', keeps: (summary) => summary.counts.failed > 0 },
18
18
  { id: 'none', label: 'None', keeps: (summary) => summary.status === 'none' }
19
19
  ];
20
+ const NO_DOMAIN = 'No domain declared';
20
21
  /** Every spec of one kind, grouped by folder, each with what the combined report holds for it. */
21
22
  export function SpecIndex({ kind }: { kind: SpecKind }) {
22
23
  const evidence = useEvidence();
23
24
  const search = useSearch({ strict: false });
24
25
  const filter = useUiStore((s) => s.resultFilter);
25
26
  const setFilter = useUiStore((s) => s.setResultFilter);
27
+ const [query, setQuery] = useState('');
28
+ const [grouping, setGrouping] = useState<'folder' | 'domain'>('folder');
26
29
  const open = typeof search.spec === 'string' ? specFile(search.spec) : '';
30
+ // The domain a spec declares in its metadata is used as given; a spec that declares none is not given one.
31
+ const domains = useMemo(() => new Map(evidence.specs.map((spec) => [spec.path, spec.domain])), [evidence.specs]);
27
32
  const rows = useMemo(
28
33
  () =>
29
34
  evidence.files.filter(kind.accepts).map((file) => ({
30
35
  file,
31
36
  title: evidence.titleOf(file),
37
+ domain: domains.get(outlinePath(file)) || '',
32
38
  summary: evidence.view.specs.get(outlinePath(file))?.summary ?? NO_EVIDENCE
33
39
  })),
34
40
  // `titleOf` reads the outline the view was built from.
35
- [evidence.files, evidence.view, kind]
41
+ [evidence.files, evidence.view, kind, domains]
36
42
  );
37
43
  const keeps = FILTERS.find((entry) => entry.id === filter)!.keeps;
38
- const visible = rows.filter((row) => keeps(row.summary));
44
+ const needle = query.trim().toLowerCase();
45
+ const visible = rows.filter(
46
+ (row) => keeps(row.summary) && (!needle || `${row.title} ${row.file} ${row.domain}`.toLowerCase().includes(needle))
47
+ );
39
48
  const groups = useMemo(() => {
40
- const byFolder = new Map<string, typeof visible>();
41
- for (const row of visible) byFolder.set(folderOf(row.file), [...(byFolder.get(folderOf(row.file)) ?? []), row]);
42
- return [...byFolder];
43
- }, [visible]);
49
+ const byGroup = new Map<string, typeof visible>();
50
+ for (const row of visible) {
51
+ const group = grouping === 'domain' ? row.domain || NO_DOMAIN : folderOf(row.file);
52
+ byGroup.set(group, [...(byGroup.get(group) ?? []), row]);
53
+ }
54
+ return [...byGroup];
55
+ }, [visible, grouping]);
44
56
  return (
45
57
  <Box aria-label={`${kind.label} ${kind.many}`} component="nav" p="md">
46
58
  <Group role="group" aria-label="Show" gap="xs" mb="md">
@@ -59,19 +71,40 @@ export function SpecIndex({ kind }: { kind: SpecKind }) {
59
71
  </Button>
60
72
  ))}
61
73
  </Group>
74
+ <Group gap="md" align="end" mb="md">
75
+ <TextInput
76
+ label={`Search ${kind.many}`}
77
+ type="search"
78
+ value={query}
79
+ onChange={(event) => setQuery(event.currentTarget.value)}
80
+ flex={1}
81
+ miw="12rem"
82
+ />
83
+ <SegmentedControl
84
+ aria-label="Group by"
85
+ value={grouping}
86
+ onChange={(value) => setGrouping(value === 'domain' ? 'domain' : 'folder')}
87
+ data={[
88
+ { label: 'Folder', value: 'folder' },
89
+ { label: 'Domain', value: 'domain' }
90
+ ]}
91
+ />
92
+ </Group>
62
93
  {evidence.filesPending && <Text component="div">Loading…</Text>}
63
94
  {evidence.filesError && <Text component="div">The file list could not be loaded.</Text>}
64
95
  {!evidence.filesPending && !evidence.filesError && visible.length === 0 && (
65
96
  <Text component="div">
66
97
  {rows.length === 0
67
98
  ? `No ${kind.many} in this spec set.`
68
- : filter === 'running'
69
- ? `No running ${kind.many}.`
70
- : filter === 'failed'
71
- ? `No failing ${kind.many}.`
72
- : filter === 'results'
73
- ? `No ${kind.many} with results yet.`
74
- : `Every ${kind.one} has results.`}
99
+ : needle
100
+ ? `No ${kind.many} match "${query.trim()}".`
101
+ : filter === 'running'
102
+ ? `No running ${kind.many}.`
103
+ : filter === 'failed'
104
+ ? `No failing ${kind.many}.`
105
+ : filter === 'results'
106
+ ? `No ${kind.many} with results yet.`
107
+ : `Every ${kind.one} has results.`}
75
108
  </Text>
76
109
  )}
77
110
  {groups.map(([folder, entries]) => (