@crvouga/atlas 1.16.0 → 1.17.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.
@@ -3,7 +3,9 @@ import type { CheckSlot } from '@crvouga/atlas/reports/evidence-view';
3
3
  import type { EvidenceResult } from '@crvouga/atlas/reports/layer-merge';
4
4
  import { expect, test } from 'bun:test';
5
5
 
6
- import { assessBehavior, assessResult, hasRelease, needsAttention } from './applicability';
6
+ import type { TestResultEntry, TestSummary } from '@crvouga/atlas/reports/test-catalog';
7
+
8
+ import { assessBehavior, assessResult, assessTest, attentionRank, hasRelease, needsAttention } from './applicability';
7
9
  import { behaviorOf, type RevisionSource } from './behavior';
8
10
  import { featureModel } from './gherkin';
9
11
 
@@ -129,3 +131,68 @@ test('a behavior is read by its best completed result and its status is untouche
129
131
  const running = assessBehavior(behaviorOf(ADD, slot([result('l1', {}, 'running')]), null, same, 'k'), same, {});
130
132
  expect(running.verdict).toBe('none');
131
133
  });
134
+
135
+ type Outcome = Partial<TestResultEntry>;
136
+ function testOf(outcomes: Outcome[], fingerprint: string | null = 'f1'): TestSummary {
137
+ return {
138
+ key: 'k', id: 'k', title: 'Lists: Add', specPath: 'specs/a.feature',
139
+ spec: { path: 'specs/a.feature', title: 'Lists', domain: 'lists', kind: 'gherkin', ...(fingerprint ? { fingerprint } : {}) },
140
+ outcomes: outcomes.map((outcome, index) => ({
141
+ at: 1_000, status: 'passed', durationMs: null, environment: 'desktop-web', producer: 'p', kind: 'behavior', parameters: {}, branch: 'main', sha: null, runUrl: null,
142
+ run: 'r', layer: `l${index + 1}`, backend: 'b', detail: '', specRevision: 'f1', ...outcome
143
+ }))
144
+ } as unknown as TestSummary;
145
+ }
146
+
147
+ test('a test whose promise changed after its only result is stale, and the same test read from the catalog agrees with the page', () => {
148
+ const stale = assessTest(testOf([{ specRevision: 'f0' }]), {});
149
+ expect(stale.verdict).toBe('stale');
150
+ expect(stale.reasons[0]).toContain('not proof of the current promise');
151
+ expect(assessTest(testOf([{ specRevision: 'f1' }]), {}).verdict).toBe('applies');
152
+ // The same facts, read from the report, give the same verdict.
153
+ expect(assessResult(result('l1'), revisions('f1', { l1: 'f0' }), {}).verdict).toBe(stale.verdict);
154
+ });
155
+
156
+ test('a test with no recorded revision or no promise fingerprint is unknown, never applying', () => {
157
+ expect(assessTest(testOf([{ specRevision: undefined }]), {}).verdict).toBe('unknown');
158
+ expect(assessTest(testOf([{ specRevision: 'f1' }], null), {}).verdict).toBe('unknown');
159
+ });
160
+
161
+ test('a test with no completed result has none, and its failure is not read at all as stale', () => {
162
+ expect(assessTest(testOf([{ status: 'running' }, { status: 'not-run' }]), {}).verdict).toBe('none');
163
+ expect(assessTest(testOf([]), { build: '9' }).release).toBe('unknown');
164
+ expect(assessTest(testOf([{ status: 'failed' }]), {}).verdict).toBe('applies');
165
+ });
166
+
167
+ test('a test is read against the release from its provenance: build, platform, modified checkout and asserted compatibility', () => {
168
+ const release = { build: '91' };
169
+ expect(assessTest(testOf([{ provenance: { build: '90' } }]), release).release).toBe('other');
170
+ expect(assessTest(testOf([{ provenance: { build: '91' } }]), release).release).toBe('exact');
171
+ expect(assessTest(testOf([{ provenance: { build: '91' } }]), { ...release, platform: 'ios' }).release).toBe('unknown');
172
+ expect(assessTest(testOf([{ provenance: { build: '91' }, freshness: 'modified' }]), release).release).toBe('unknown');
173
+ const asserted = (by: Record<string, string>) => assessTest(testOf([{ provenance: { build: '90', 'applies-to': '91', ...by } }]), release);
174
+ expect(asserted({ 'applies-to-by': 'policy-7' }).release).toBe('asserted');
175
+ expect(asserted({}).release).toBe('unknown');
176
+ // A branch never supplies deployment state.
177
+ expect(assessTest(testOf([{ branch: 'release/91' }]), release).release).toBe('unknown');
178
+ });
179
+
180
+ test('the best of a test\'s current results is kept, and stale evidence sorts before evidence that applies', () => {
181
+ const both = assessTest(testOf([{ specRevision: 'f0', at: 5_000 }, { specRevision: 'f1', at: 1_000 }]), {});
182
+ expect(both.verdict).toBe('applies');
183
+ expect(both.layer).toBe('l2');
184
+ expect(both.others).toBe(1);
185
+ const ranks = [assessTest(testOf([{}]), {}), assessTest(testOf([{ specRevision: 'f0' }]), {}), assessTest(testOf([{ specRevision: undefined }]), {}), assessTest(testOf([]), {})].map(attentionRank);
186
+ expect(ranks).toEqual([3, 0, 1, 2]);
187
+ });
188
+
189
+ test('when evidence was published or enriched is carried apart from when the checks ran, and a time that cannot be read is no time', () => {
190
+ const facts = assessTest(testOf([{ at: 1_000, provenance: { 'published-at': '1700000000000', 'enriched-at': '2026-10-01T00:00:00Z' } }]), {}).facts!;
191
+ expect(facts.executedAtMs).toBe(1_000);
192
+ expect(facts.publishedAtMs).toBe(1_700_000_000_000);
193
+ expect(facts.enrichedAtMs).toBe(Date.parse('2026-10-01T00:00:00Z'));
194
+ const unreadable = assessTest(testOf([{ provenance: { 'published-at': 'yesterday-ish', 'enriched-at': '0' } }]), {}).facts!;
195
+ expect([unreadable.publishedAtMs, unreadable.enrichedAtMs]).toEqual([null, null]);
196
+ // Read from the report, labels at the layer or the check say the same.
197
+ expect(assessResult(result('l1', { layerLabels: { 'published-at': '1700000000000' } }), same, {}).facts!.publishedAtMs).toBe(1_700_000_000_000);
198
+ });
@@ -13,9 +13,16 @@
13
13
  * the app under test was), `platform` (the client family; the environment when absent), `applies-to` (a comma
14
14
  * separated list of builds or commits the producer asserts the evidence is still good for) and `applies-to-by`
15
15
  * (the policy, person or tool that makes the assertion). An assertion without `applies-to-by` is not accepted.
16
+ * `published-at` and `enriched-at` say when the evidence was published or enriched (epoch milliseconds or an ISO date).
17
+ * Layers carry no publication time of their own, because a layer's id is the digest of its content and a time of
18
+ * upload in it would make a retried publish a different layer; a producer that knows states it as a label. It is shown
19
+ * apart from when the checks ran and is never the age of the evidence.
20
+ *
21
+ * The same reading serves a feature page (from the report's results, {@link assessBehavior}) and the test explorer (from
22
+ * the catalog's current results, {@link assessTest}).
16
23
  */
17
- import type { LayerHeader } from '@crvouga/atlas/reports/evidence-types';
18
24
  import type { EvidenceResult } from '@crvouga/atlas/reports/layer-merge';
25
+ import type { TestResultEntry, TestSummary } from '@crvouga/atlas/reports/test-catalog';
19
26
 
20
27
  import { compareRevisions, type Behavior, type Revision, type RevisionSource } from './behavior';
21
28
 
@@ -58,6 +65,10 @@ export const VERDICTS: Readonly<Record<Verdict, { readonly label: string; readon
58
65
  export type EvidenceFacts = {
59
66
  /** When the checks ran, epoch milliseconds. The age of evidence counts from here, not from publication or enrichment. */
60
67
  readonly executedAtMs: number;
68
+ /** When the evidence was published, from the `published-at` label, or null when the producer did not say. Never the age of the evidence. */
69
+ readonly publishedAtMs: number | null;
70
+ /** When the evidence was enriched (annotated, captioned, re-indexed), from the `enriched-at` label, or null. Never the age of the evidence. */
71
+ readonly enrichedAtMs: number | null;
61
72
  readonly build: string | null;
62
73
  readonly sha: string | null;
63
74
  /** The `platform` label, else the layer's environment. */
@@ -83,8 +94,10 @@ export type Assessment = {
83
94
  readonly assertion: Assertion | null;
84
95
  /** Sentences saying why, in the order a reader needs them. Empty when there is nothing to add. */
85
96
  readonly reasons: readonly string[];
86
- /** The result this reading is of, or null when there is none. */
97
+ /** The result this reading is of, or null when there is none (or it was read from a test catalog, which holds its layer id instead). */
87
98
  readonly result: EvidenceResult | null;
99
+ /** The id of the layer that recorded the result this reading is of, or null when there is none. */
100
+ readonly layer: string | null;
88
101
  /** How many other completed results were read and set aside for this one. */
89
102
  readonly others: number;
90
103
  };
@@ -107,23 +120,38 @@ function labelsOf(result: EvidenceResult): Readonly<Record<string, string>> {
107
120
  return { ...(result.layer.evidence?.labels ?? {}), ...(result.row.evidence?.labels ?? {}) };
108
121
  }
109
122
 
110
- /** What a result's layer and labels say it ran against. */
111
- export function evidenceFacts(result: EvidenceResult): EvidenceFacts {
112
- const labels = labelsOf(result);
113
- const layer: LayerHeader = result.layer;
123
+ /** A time a label states: epoch milliseconds or an ISO date. Anything else is no time. */
124
+ function labelTime(value: string | undefined): number | null {
125
+ const text = value?.trim();
126
+ if (!text) return null;
127
+ const at = /^\d{9,}$/.test(text) ? Number(text) : /^\d{4}-\d{2}-\d{2}/.test(text) ? Date.parse(text) : Number.NaN;
128
+ return Number.isFinite(at) && at > 0 ? at : null;
129
+ }
130
+
131
+ /** What one result recorded about itself, whether read from the report or from the test catalog. */
132
+ type Recorded = {
133
+ readonly labels: Readonly<Record<string, string>>;
134
+ readonly executedAtMs: number;
135
+ readonly sha: string | null;
136
+ readonly environment: string | null;
137
+ readonly freshness: 'modified' | 'unknown' | null;
138
+ };
139
+
140
+ function factsFrom(recorded: Recorded): EvidenceFacts {
141
+ const { labels } = recorded;
114
142
  return {
115
- executedAtMs: layer.recordedAtMs,
143
+ executedAtMs: recorded.executedAtMs,
144
+ publishedAtMs: labelTime(labels['published-at']),
145
+ enrichedAtMs: labelTime(labels['enriched-at']),
116
146
  build: labels.build?.trim() || null,
117
- sha: layer.sha,
118
- platform: labels.platform?.trim() || layer.environment,
119
- environment: layer.environment,
120
- provenance: layer.freshness ?? null
147
+ sha: recorded.sha,
148
+ platform: labels.platform?.trim() || recorded.environment,
149
+ environment: recorded.environment,
150
+ provenance: recorded.freshness
121
151
  };
122
152
  }
123
153
 
124
- /** The assertion a result's labels make, or null when it makes none. */
125
- export function assertionOf(result: EvidenceResult): Assertion | null {
126
- const labels = labelsOf(result);
154
+ function assertionFrom(labels: Readonly<Record<string, string>>): Assertion | null {
127
155
  const builds = (labels['applies-to'] ?? '')
128
156
  .split(',')
129
157
  .map((each) => each.trim())
@@ -131,6 +159,32 @@ export function assertionOf(result: EvidenceResult): Assertion | null {
131
159
  return builds.length ? { builds, by: labels['applies-to-by']?.trim() || null } : null;
132
160
  }
133
161
 
162
+ const ofResult = (result: EvidenceResult): Recorded => ({
163
+ labels: labelsOf(result),
164
+ executedAtMs: result.layer.recordedAtMs,
165
+ sha: result.layer.sha,
166
+ environment: result.layer.environment,
167
+ freshness: result.layer.freshness ?? null
168
+ });
169
+
170
+ const ofOutcome = (outcome: TestResultEntry): Recorded => ({
171
+ labels: outcome.provenance ?? {},
172
+ executedAtMs: outcome.at,
173
+ sha: outcome.sha,
174
+ environment: outcome.environment,
175
+ freshness: outcome.freshness ?? null
176
+ });
177
+
178
+ /** What a result's layer and labels say it ran against. */
179
+ export function evidenceFacts(result: EvidenceResult): EvidenceFacts {
180
+ return factsFrom(ofResult(result));
181
+ }
182
+
183
+ /** The assertion a result's labels make, or null when it makes none. */
184
+ export function assertionOf(result: EvidenceResult): Assertion | null {
185
+ return assertionFrom(labelsOf(result));
186
+ }
187
+
134
188
  const RANK: Readonly<Record<Verdict, number>> = { applies: 0, unknown: 1, stale: 2, none: 3 };
135
189
 
136
190
  /** How one result's evidence stands against the release. Never reads a branch or a status. */
@@ -193,11 +247,10 @@ function matchRelease(facts: EvidenceFacts, release: ReleaseIdentity, promise: R
193
247
  return { match: 'exact', reasons };
194
248
  }
195
249
 
196
- /** One result against the promise and the release. */
197
- export function assessResult(result: EvidenceResult, revisions: RevisionSource, release: ReleaseIdentity): Assessment {
198
- const facts = evidenceFacts(result);
199
- const revision = compareRevisions(revisions.current, revisions.recorded(result.layer.id));
200
- const assertion = assertionOf(result);
250
+ /** One recorded result against the promise (current and recorded fingerprints) and the release. */
251
+ function read(recorded: Recorded, revision: Revision, release: ReleaseIdentity, source: { result: EvidenceResult | null; layer: string }): Assessment {
252
+ const facts = factsFrom(recorded);
253
+ const assertion = assertionFrom(recorded.labels);
201
254
  const { match, reasons } = matchRelease(facts, release, revision.match, assertion);
202
255
  const promiseReason =
203
256
  revision.match === 'different'
@@ -214,11 +267,36 @@ export function assessResult(result: EvidenceResult, revisions: RevisionSource,
214
267
  facts,
215
268
  assertion,
216
269
  reasons: [...(promiseReason ? [promiseReason] : []), ...reasons],
217
- result,
270
+ result: source.result,
271
+ layer: source.layer,
218
272
  others: 0
219
273
  };
220
274
  }
221
275
 
276
+ /** One result against the promise and the release. */
277
+ export function assessResult(result: EvidenceResult, revisions: RevisionSource, release: ReleaseIdentity): Assessment {
278
+ const revision = compareRevisions(revisions.current, revisions.recorded(result.layer.id));
279
+ return read(ofResult(result), revision, release, { result, layer: result.layer.id });
280
+ }
281
+
282
+ const NOTHING = (release: ReleaseIdentity): Assessment => ({
283
+ verdict: 'none',
284
+ promise: 'unknown',
285
+ release: hasRelease(release) ? 'unknown' : 'not-selected',
286
+ facts: null,
287
+ assertion: null,
288
+ reasons: [],
289
+ result: null,
290
+ layer: null,
291
+ others: 0
292
+ });
293
+
294
+ /** The reading that best applies (applies, then unknown, then stale; the newest execution among equals), with how many were set aside. */
295
+ function best(readings: Assessment[]): Assessment {
296
+ readings.sort((a, b) => RANK[a.verdict] - RANK[b.verdict] || b.facts!.executedAtMs - a.facts!.executedAtMs);
297
+ return { ...readings[0]!, others: readings.length - 1 };
298
+ }
299
+
222
300
  /**
223
301
  * The reading of a behavior's evidence: every completed result is read and the one that best applies is kept
224
302
  * (applies, then unknown, then stale; the newest execution among equals), so a recording on the release's platform is
@@ -227,12 +305,29 @@ export function assessResult(result: EvidenceResult, revisions: RevisionSource,
227
305
  */
228
306
  export function assessBehavior(behavior: Behavior, revisions: RevisionSource, release: ReleaseIdentity): Assessment {
229
307
  const completed = (behavior.slot?.outcomes ?? []).filter((result) => result.row.status === 'passed' || result.row.status === 'failed');
230
- if (completed.length === 0) {
231
- return { verdict: 'none', promise: 'unknown', release: hasRelease(release) ? 'unknown' : 'not-selected', facts: null, assertion: null, reasons: [], result: null, others: 0 };
232
- }
233
- const readings = completed.map((result) => assessResult(result, revisions, release));
234
- readings.sort((a, b) => RANK[a.verdict] - RANK[b.verdict] || b.facts!.executedAtMs - a.facts!.executedAtMs);
235
- return { ...readings[0]!, others: readings.length - 1 };
308
+ if (completed.length === 0) return NOTHING(release);
309
+ return best(completed.map((result) => assessResult(result, revisions, release)));
310
+ }
311
+
312
+ /**
313
+ * The reading of a test of the catalog from its current results: the spec's fingerprint now (`test.spec.fingerprint`) against
314
+ * the one each result recorded (`specRevision`), and the build, commit, platform and provenance each states. The same
315
+ * rules as {@link assessBehavior}; a result without a recorded revision, or a test whose spec has no fingerprint, is unknown.
316
+ * Only completed results (passed or failed) are read; a test with none has `none`. Statuses are unchanged by this.
317
+ */
318
+ export function assessTest(test: TestSummary, release: ReleaseIdentity): Assessment {
319
+ const completed = test.outcomes.filter((outcome) => outcome.status === 'passed' || outcome.status === 'failed');
320
+ if (completed.length === 0) return NOTHING(release);
321
+ const current = test.spec?.fingerprint ?? null;
322
+ return best(completed.map((outcome) => read(ofOutcome(outcome), compareRevisions(current, outcome.specRevision), release, { result: null, layer: outcome.layer })));
323
+ }
324
+
325
+ /**
326
+ * The order the explorer lists tests in when sorted by evidence: what needs a look first (stale, then unknown, then
327
+ * without evidence), evidence that applies last. A rank for sorting only; it is never shown as a status.
328
+ */
329
+ export function attentionRank(assessment: Assessment): number {
330
+ return { stale: 0, unknown: 1, none: 2, applies: 3 }[assessment.verdict];
236
331
  }
237
332
 
238
333
  /** Whether a behavior belongs under "stale or unverified": anything but evidence that applies. */
@@ -1,5 +1,5 @@
1
1
  import { SpecLogo } from '../components/SpecLogo';
2
- import { IconVideo, IconX } from '@tabler/icons-react';
2
+ import { IconAlertTriangle, IconVideo, IconX } from '@tabler/icons-react';
3
3
  import {
4
4
  Accordion,
5
5
  Alert,
@@ -44,6 +44,8 @@ import {
44
44
  } from '@crvouga/atlas/reports/test-catalog';
45
45
  import { Link } from '@tanstack/react-router';
46
46
  import { useDeferredValue, useEffect, useMemo, useState } from 'react';
47
+ import { attentionRank, hasRelease, needsAttention, type Assessment, type ReleaseIdentity } from '../evidence/applicability';
48
+ import { ApplicabilityControls, VerdictBadge } from '../evidence/applicability-view';
47
49
  import { staticEvidence } from '../evidence/api';
48
50
  import { useEvidence } from '../evidence/evidence-context';
49
51
  import { ago, plural, useNow } from '../evidence/format';
@@ -53,6 +55,7 @@ import { behaviorDocs, behaviorId, describeHit, testHasVideo } from '../find/beh
53
55
  import { useDocSearch } from '../find/doc-search';
54
56
  import { hasFilters, passesFilters, withoutToken, type FilterKey, type ParsedSearch } from '../find/query';
55
57
  import type { FindDoc, Ranked } from '../find/rank';
58
+ import { assessTests, releaseOf, useExplorerApplicability } from './applicability';
56
59
  import { useRunDetail, useTestCatalog } from './catalog';
57
60
  import { groupTests, tally, type TestGroupView } from './groups';
58
61
  import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
@@ -80,6 +83,7 @@ const GROUP_LABEL: Record<TestGroup, string> = {
80
83
  };
81
84
  const SORT_LABEL: Record<ExplorerSort, string> = {
82
85
  relevance: 'Best match',
86
+ evidence: 'Stale evidence first',
83
87
  spec: 'Spec',
84
88
  title: 'Title',
85
89
  recent: 'Most recent',
@@ -119,8 +123,12 @@ function useListing(model: CatalogModel | null, search: ExplorerSearch, q: strin
119
123
  const list = useMemo(() => [...docs.values()], [docs]);
120
124
  const found = useDocSearch(list, q);
121
125
  const sort = sortOf(search);
126
+ const { release: releaseBuild, releaseSha, releasePlatform } = search;
127
+ const release = useMemo(() => releaseOf({ release: releaseBuild, releaseSha, releasePlatform }), [releaseBuild, releaseSha, releasePlatform]);
128
+ // Every test's evidence read against its promise and the named release: a reading, never a status.
129
+ const assessments = useMemo(() => (model ? assessTests(model.tests, release) : null), [model?.tests, release]);
122
130
  const listing = useMemo(() => {
123
- if (!model) return null;
131
+ if (!model || !assessments) return null;
124
132
  const query: TestQuery = { ...queryOf(search), q: undefined };
125
133
  // `has:video` typed in the box and the Has video button are the same filter.
126
134
  const operator = { ...found.parsed.filters, video: found.parsed.filters.video || search.video === true };
@@ -129,17 +137,25 @@ function useListing(model: CatalogModel | null, search: ExplorerSearch, q: strin
129
137
  const passes = (test: TestSummary, omit: ReadonlySet<FilterKey>) =>
130
138
  (!hits || hits.has(behaviorId(test.key))) && (!structured || passesFilters(docs.get(test.key)?.facets, operator, omit));
131
139
  let shown = testCatalog(model, { ...query, limit: 20000 }).tests.filter((test) => (!inRun || inRun.has(test.key)) && passes(test, NO_STRUCTURE));
140
+ const attention = (test: TestSummary) => needsAttention(assessments.get(test.key)!);
141
+ if (search.evidence === 'stale') shown = shown.filter(attention);
132
142
  if (sort === 'relevance' && hits) {
133
143
  const order = new Map([...hits.keys()].map((id, at) => [id, at] as const));
134
144
  shown = [...shown].sort((a, b) => (order.get(behaviorId(a.key)) ?? 0) - (order.get(behaviorId(b.key)) ?? 0));
135
145
  }
146
+ // Stale evidence first; the order of the spec list is kept among equals.
147
+ if (sort === 'evidence') shown = [...shown].sort((a, b) => attentionRank(assessments.get(a.key)!) - attentionRank(assessments.get(b.key)!));
136
148
  // The strip and the status and environment counts describe what the other filters leave.
137
149
  const scope = model.tests.filter((test) => matchesQuery(model, test, { ...query, status: [], environment: [] }, inRun) && passes(test, WITHOUT_STATUS_AND_ENVIRONMENT));
138
150
  const byEnvironment = scope.filter((test) => matchesQuery(model, test, { environment: query.environment }) && passes(test, WITHOUT_STATUS));
139
151
  const byStatus = scope.filter((test) => matchesQuery(model, test, { status: query.status }) && passes(test, WITHOUT_ENVIRONMENT));
140
152
  const videoScope = model.tests.filter((test) => matchesQuery(model, test, query, inRun) && passes(test, WITHOUT_VIDEO));
153
+ const evidenceScope = model.tests.filter((test) => matchesQuery(model, test, query, inRun) && passes(test, NO_STRUCTURE));
141
154
  return {
142
155
  shown,
156
+ assessments,
157
+ release,
158
+ stale: evidenceScope.filter(attention).length,
143
159
  withVideo: videoScope.filter((test) => docs.get(test.key)?.facets?.video ?? testHasVideo(test)).length,
144
160
  scope,
145
161
  byEnvironment,
@@ -150,7 +166,7 @@ function useListing(model: CatalogModel | null, search: ExplorerSearch, q: strin
150
166
  runPending: !!search.inRun && run.isPending,
151
167
  runMissing: !!search.inRun && run.isSuccess && run.data === null
152
168
  };
153
- }, [model, search, found, docs, sort, inRun, run.isPending, run.isSuccess, run.data]);
169
+ }, [model, search, found, docs, sort, inRun, assessments, release, run.isPending, run.isSuccess, run.data]);
154
170
  return listing;
155
171
  }
156
172
  /** The project home: the published spec kinds, the insight strip, the toolbar, the grouped list and the open test. */
@@ -301,7 +317,7 @@ export function Explorer() {
301
317
  That run is not in the report any more.
302
318
  </Alert>
303
319
  )}
304
- <TestList tests={listing.shown} search={search} total={model.tests.length} hits={listing.hits} sort={search.sort ?? (listing.hits ? 'relevance' : 'spec')} />
320
+ <TestList tests={listing.shown} search={search} total={model.tests.length} hits={listing.hits} sort={search.sort ?? (listing.hits ? 'relevance' : 'spec')} assessments={listing.assessments} release={listing.release} />
305
321
  </>
306
322
  ))}
307
323
  </Box>
@@ -680,6 +696,19 @@ function Toolbar({
680
696
  {listing.withVideo.toLocaleString()}
681
697
  </Text>
682
698
  </Button>
699
+ <Button
700
+ type="button"
701
+ aria-pressed={search.evidence === 'stale'}
702
+ aria-label={`Stale or unverified evidence: ${plural(listing.stale, 'test', 'tests')}`}
703
+ onClick={() => go({ evidence: search.evidence ? undefined : 'stale' })}
704
+ variant={search.evidence === 'stale' ? 'light' : 'subtle'}
705
+ leftSection={<IconAlertTriangle size={16} aria-hidden="true" />}
706
+ >
707
+ Stale or unverified{' '}
708
+ <Text span inherit>
709
+ {listing.stale.toLocaleString()}
710
+ </Text>
711
+ </Button>
683
712
  {model.facets.producers.length > 1 && (
684
713
  <Box component="label">
685
714
  <VisuallyHidden>Producer</VisuallyHidden>
@@ -788,7 +817,8 @@ function Toolbar({
788
817
  artifactTag: undefined,
789
818
  inherited: undefined,
790
819
  inRun: undefined,
791
- video: undefined
820
+ video: undefined,
821
+ evidence: undefined
792
822
  });
793
823
  }}
794
824
  variant="subtle"
@@ -798,10 +828,47 @@ function Toolbar({
798
828
  )}
799
829
  </Flex>
800
830
  <SearchNotes issues={listing.parsed.issues} semantic={listing.semantic} />
831
+ <ReleaseReading />
801
832
  </Box>
802
833
  );
803
834
  }
804
- function TestList({ tests, search, total, hits, sort }: { tests: readonly TestSummary[]; search: ExplorerSearch; total: number; hits: ReadonlyMap<string, Ranked> | null; sort: ExplorerSort }) {
835
+ /**
836
+ * Names the release the evidence of the tests is read against (build, commit and platform). Folded away until a release is
837
+ * named, because most readers only ask whether evidence is stale.
838
+ */
839
+ function ReleaseReading() {
840
+ const applicability = useExplorerApplicability();
841
+ const named = hasRelease(applicability.release) || !!applicability.release.platform;
842
+ return (
843
+ <Accordion variant="separated" defaultValue={named ? 'release' : null} mt="sm">
844
+ <Accordion.Item value="release">
845
+ <Accordion.Control>Release the evidence is read against</Accordion.Control>
846
+ <Accordion.Panel>
847
+ <ApplicabilityControls state={applicability} filter={false} />
848
+ </Accordion.Panel>
849
+ </Accordion.Item>
850
+ </Accordion>
851
+ );
852
+ }
853
+ function TestList({
854
+ tests,
855
+ search,
856
+ total,
857
+ hits,
858
+ sort,
859
+ assessments,
860
+ release
861
+ }: {
862
+ tests: readonly TestSummary[];
863
+ search: ExplorerSearch;
864
+ total: number;
865
+ hits: ReadonlyMap<string, Ranked> | null;
866
+ sort: ExplorerSort;
867
+ assessments: ReadonlyMap<string, Assessment>;
868
+ release: ReleaseIdentity;
869
+ }) {
870
+ // A reading is shown on every row only when the reader asked about evidence; otherwise only evidence known to be stale.
871
+ const asked = search.evidence === 'stale' || sort === 'evidence' || hasRelease(release);
805
872
  const by = search.group ?? 'spec';
806
873
  const groups = useMemo(() => groupTests(tests, by, sort === 'spec'), [tests, by, sort]);
807
874
  const [overrides, setOverrides] = useState<ReadonlyMap<string, boolean>>(new Map());
@@ -864,6 +931,8 @@ function TestList({ tests, search, total, hits, sort }: { tests: readonly TestSu
864
931
  open={isOpen(group)}
865
932
  selected={search.test}
866
933
  hits={hits}
934
+ assessments={assessments}
935
+ asked={asked}
867
936
  onToggle={() => setOverrides(new Map(overrides).set(`${by}|${group.key}`, !isOpen(group)))}
868
937
  />
869
938
  </Stack>
@@ -878,6 +947,8 @@ function TestGroupSection({
878
947
  open,
879
948
  selected,
880
949
  hits,
950
+ assessments,
951
+ asked,
881
952
  onToggle
882
953
  }: {
883
954
  group: TestGroupView;
@@ -885,6 +956,8 @@ function TestGroupSection({
885
956
  open: boolean;
886
957
  selected: string | undefined;
887
958
  hits: ReadonlyMap<string, Ranked> | null;
959
+ assessments: ReadonlyMap<string, Assessment>;
960
+ asked: boolean;
888
961
  onToggle: () => void;
889
962
  }) {
890
963
  const [shown, setShown] = useState(by === 'none' ? 100 : GROUP_ROWS);
@@ -892,7 +965,7 @@ function TestGroupSection({
892
965
  const rows = (
893
966
  <List spacing="xs" listStyleType="none">
894
967
  {group.tests.slice(0, shown).map((test) => (
895
- <TestRow key={test.key} test={test} selected={selected === test.key} now={now} hit={hits?.get(behaviorId(test.key))} />
968
+ <TestRow key={test.key} test={test} selected={selected === test.key} now={now} hit={hits?.get(behaviorId(test.key))} assessment={assessments.get(test.key)} asked={asked} />
896
969
  ))}
897
970
  {group.tests.length > shown && (
898
971
  <List.Item>
@@ -937,7 +1010,21 @@ function TestGroupSection({
937
1010
  </Box>
938
1011
  );
939
1012
  }
940
- function TestRow({ test, selected, now, hit }: { test: TestSummary; selected: boolean; now: number; hit: Ranked | undefined }) {
1013
+ function TestRow({
1014
+ test,
1015
+ selected,
1016
+ now,
1017
+ hit,
1018
+ assessment,
1019
+ asked
1020
+ }: {
1021
+ test: TestSummary;
1022
+ selected: boolean;
1023
+ now: number;
1024
+ hit: Ranked | undefined;
1025
+ assessment: Assessment | undefined;
1026
+ asked: boolean;
1027
+ }) {
941
1028
  const why = hit ? describeHit(hit) : '';
942
1029
  const failure = test.status === 'failed' ? test.outcomes.find((outcome) => outcome.status === 'failed')?.detail.split('\n')[0] : '';
943
1030
  return (
@@ -966,6 +1053,7 @@ function TestRow({ test, selected, now, hit }: { test: TestSummary; selected: bo
966
1053
  <Group gap="xs">
967
1054
  <EnvironmentChips test={test} />
968
1055
  <Badges test={test} />
1056
+ {assessment && (assessment.verdict === 'stale' || (asked && assessment.verdict !== 'applies')) && <VerdictBadge verdict={assessment.verdict} />}
969
1057
  </Group>
970
1058
  {failure && (
971
1059
  <Text size="xs" c="red.7" lineClamp={2}>
@@ -13,12 +13,15 @@ import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
13
13
  import { formatMs, runKey, type TestResultEntry, type TestSummary } from '@crvouga/atlas/reports/test-catalog';
14
14
  import { Link } from '@tanstack/react-router';
15
15
  import { lazy, Suspense, useState } from 'react';
16
+ import { assessTest } from '../evidence/applicability';
17
+ import { ApplicabilitySummary } from '../evidence/applicability-view';
16
18
  import { CheckDetail } from '../evidence/check-detail';
17
19
  import { ago, exactTime, plural, shortSha, statusWord, useNow } from '../evidence/format';
18
20
  import { useEvidence } from '../evidence/evidence-context';
19
21
  import { Origin, StatusMark } from '../evidence/marks';
20
22
  import { outcomeHeadline, selectedResult } from '../evidence/outcome';
21
23
  import { useTestHistory } from './catalog';
24
+ import { releaseOf } from './applicability';
22
25
  import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
23
26
  import { Badges, runName } from './parts';
24
27
  import { Reliability } from './Reliability';
@@ -129,7 +132,10 @@ function Scope({ pinned, slot }: { pinned: PinnedResult; slot: CheckSlot | undef
129
132
  export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot | undefined }) {
130
133
  const now = useNow();
131
134
  const history = useTestHistory(test.key);
132
- const { layer, source } = useExplorerSearch();
135
+ const search = useExplorerSearch();
136
+ const { layer, source } = search;
137
+ const release = releaseOf(search);
138
+ const applicability = assessTest(test, release);
133
139
  const go = useExplorerNavigate();
134
140
  const pinned = usePinnedResult(layer, test.key);
135
141
  const [recording, setRecording] = useState(false);
@@ -238,6 +244,13 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
238
244
 
239
245
  <Scope pinned={pinned} slot={slot} />
240
246
 
247
+ <Box aria-label="Evidence applicability" component="section" mb="lg">
248
+ <Title order={3} mb="sm" c="dimmed">
249
+ Does this evidence apply?
250
+ </Title>
251
+ <ApplicabilitySummary assessment={applicability} release={release} />
252
+ </Box>
253
+
241
254
  <Box aria-label="Current results" component="section" mb="lg">
242
255
  <Title order={3} mb="sm" c="dimmed">
243
256
  Current result per environment
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @module Explorer evidence applicability
3
+ *
4
+ * The explorer's part of evidence applicability: the release a reader names and the stale-or-unverified filter, held in
5
+ * the explorer's address (`release`, `releaseSha`, `releasePlatform`, `evidence=stale`, the same names a spec page uses),
6
+ * and the reading of every test of the catalog against them. The reading is the one a feature page makes
7
+ * (`evidence/applicability.ts`); it never changes a test's status.
8
+ */
9
+ import { useMemo } from 'react';
10
+ import type { TestSummary } from '@crvouga/atlas/reports/test-catalog';
11
+
12
+ import { assessTest, type Assessment, type ReleaseIdentity } from '../evidence/applicability';
13
+ import type { ApplicabilityState } from '../evidence/applicability-view';
14
+ import { useExplorerNavigate, useExplorerSearch } from './navigate';
15
+ import type { ExplorerSearch } from './search';
16
+
17
+ /** The release an explorer address names; every part is optional. */
18
+ export function releaseOf(search: ExplorerSearch): ReleaseIdentity {
19
+ return {
20
+ ...(search.release ? { build: search.release } : {}),
21
+ ...(search.releaseSha ? { sha: search.releaseSha } : {}),
22
+ ...(search.releasePlatform ? { platform: search.releasePlatform } : {})
23
+ };
24
+ }
25
+
26
+ /** Each test's evidence read against the promise and the release, by test key. */
27
+ export function assessTests(tests: readonly TestSummary[], release: ReleaseIdentity): ReadonlyMap<string, Assessment> {
28
+ return new Map(tests.map((test) => [test.key, assessTest(test, release)] as const));
29
+ }
30
+
31
+ /** The release and the evidence filter of the explorer's address, with the ways to change them. */
32
+ export function useExplorerApplicability(): ApplicabilityState {
33
+ const search = useExplorerSearch();
34
+ const go = useExplorerNavigate();
35
+ const { release: build, releaseSha: sha, releasePlatform: platform } = search;
36
+ const release = useMemo(() => releaseOf({ release: build, releaseSha: sha, releasePlatform: platform }), [build, sha, platform]);
37
+ return {
38
+ release,
39
+ onlyStale: search.evidence === 'stale',
40
+ setRelease: (next) => go({ release: next.build || undefined, releaseSha: next.sha || undefined, releasePlatform: next.platform || undefined }, { replace: true }),
41
+ setOnlyStale: (on) => go({ evidence: on ? 'stale' : undefined }, { replace: true })
42
+ };
43
+ }