@principal-ai/subsystems-react 0.56.3 → 0.56.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/subsystem/DecorationPanel.d.ts +37 -0
- package/dist/subsystem/DecorationPanel.d.ts.map +1 -0
- package/dist/subsystem/DecorationPanel.js +147 -0
- package/dist/subsystem/DecorationPanel.js.map +1 -0
- package/dist/subsystem/DiagnosticToggle.d.ts +7 -1
- package/dist/subsystem/DiagnosticToggle.d.ts.map +1 -1
- package/dist/subsystem/DiagnosticToggle.js +8 -7
- package/dist/subsystem/DiagnosticToggle.js.map +1 -1
- package/dist/subsystem/GraphSidebar.d.ts +4 -2
- package/dist/subsystem/GraphSidebar.d.ts.map +1 -1
- package/dist/subsystem/GraphSidebar.js +3 -1
- package/dist/subsystem/GraphSidebar.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts +10 -13
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +112 -272
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/TrailStepsNav.d.ts.map +1 -1
- package/dist/subsystem/TrailStepsNav.js +6 -4
- package/dist/subsystem/TrailStepsNav.js.map +1 -1
- package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
- package/dist/subsystem/TrailsPanel.js +14 -10
- package/dist/subsystem/TrailsPanel.js.map +1 -1
- package/dist/subsystem/decoration.d.ts +112 -0
- package/dist/subsystem/decoration.d.ts.map +1 -0
- package/dist/subsystem/decoration.js +54 -0
- package/dist/subsystem/decoration.js.map +1 -0
- package/package.json +3 -3
- package/src/index.ts +37 -0
- package/src/stories/Canvases/C4/C4ToComponentDrill.stories.tsx +38 -71
- package/src/stories/Canvases/Subsystem/GraphTitle.stories.tsx +1 -2
- package/src/stories/Canvases/Subsystem/StepEmphasis.stories.tsx +528 -0
- package/src/stories/Components/DecorationPanel.stories.tsx +160 -0
- package/src/subsystem/DecorationPanel.test.tsx +128 -0
- package/src/subsystem/DecorationPanel.tsx +334 -0
- package/src/subsystem/DiagnosticToggle.tsx +20 -5
- package/src/subsystem/GraphSidebar.test.tsx +18 -0
- package/src/subsystem/GraphSidebar.tsx +6 -1
- package/src/subsystem/SubsystemComponentGraph.sidebar.test.tsx +210 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +184 -414
- package/src/subsystem/TrailStepsNav.test.tsx +32 -0
- package/src/subsystem/TrailStepsNav.tsx +6 -4
- package/src/subsystem/TrailsPanel.test.tsx +20 -0
- package/src/subsystem/TrailsPanel.tsx +11 -1
- package/src/subsystem/decoration.test.ts +72 -0
- package/src/subsystem/decoration.ts +151 -0
- package/src/subsystem/stepEmphasis.test.ts +110 -0
|
@@ -149,12 +149,44 @@ describe('TrailStepsNav', () => {
|
|
|
149
149
|
expect(getByText('A.go')).toBeTruthy();
|
|
150
150
|
expect(getByText('B.go')).toBeTruthy();
|
|
151
151
|
expect(getByText('Read the source seam.')).toBeTruthy();
|
|
152
|
+
const accent = document.createElement('span');
|
|
153
|
+
accent.style.color = defaultEditorTheme.colors.primary;
|
|
154
|
+
const normal = document.createElement('span');
|
|
155
|
+
normal.style.color = defaultEditorTheme.colors.text;
|
|
156
|
+
expect(getByText('A.go').style.color).toBe(normal.style.color);
|
|
157
|
+
expect(getByText('B.go').style.color).toBe(normal.style.color);
|
|
158
|
+
expect(getByText('Read the source seam.').style.color).toBe(accent.style.color);
|
|
159
|
+
expect(getByRole('button', { name: /A\.go/ }).style.paddingLeft).toBe('12px');
|
|
160
|
+
expect(getByRole('button', { name: /B\.go/ }).style.paddingLeft).toBe('22px');
|
|
152
161
|
fireEvent.click(getByRole('button', { name: /B\.go/ }));
|
|
153
162
|
expect(focusedStep).toBe(1);
|
|
154
163
|
fireEvent.click(getByLabelText('Back to trails'));
|
|
155
164
|
expect(cleared).toBe(true);
|
|
156
165
|
});
|
|
157
166
|
|
|
167
|
+
test('returns sibling calls to their parent indentation level', () => {
|
|
168
|
+
const trail: SubsystemTrail = {
|
|
169
|
+
id: 'nested',
|
|
170
|
+
title: 'Nested calls',
|
|
171
|
+
steps: [step('a', 'b', 'A.go'), step('b', 'c', 'B.go'), step('a', 'd', 'A.next')],
|
|
172
|
+
};
|
|
173
|
+
const { getByRole } = renderNav({
|
|
174
|
+
trails: [trail],
|
|
175
|
+
focusedTrailId: trail.id,
|
|
176
|
+
focusedStepIndex: null,
|
|
177
|
+
hoveredTrailStep: null,
|
|
178
|
+
onFocusFlow: () => {},
|
|
179
|
+
onClearFocus: () => {},
|
|
180
|
+
onFocusStep: () => {},
|
|
181
|
+
onHoverStep: () => {},
|
|
182
|
+
onHoverFlow: () => {},
|
|
183
|
+
onLeaveStep: () => {},
|
|
184
|
+
});
|
|
185
|
+
expect(getByRole('button', { name: /A\.go/ }).style.paddingLeft).toBe('12px');
|
|
186
|
+
expect(getByRole('button', { name: /B\.go/ }).style.paddingLeft).toBe('22px');
|
|
187
|
+
expect(getByRole('button', { name: /A\.next/ }).style.paddingLeft).toBe('12px');
|
|
188
|
+
});
|
|
189
|
+
|
|
158
190
|
test('uses rows show the logical hop and the via site', () => {
|
|
159
191
|
const usesTrail: SubsystemTrail = {
|
|
160
192
|
id: 'uses',
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
trailStepDisplay,
|
|
8
8
|
type TrailComponentLabels,
|
|
9
9
|
} from './trailStepDisplay';
|
|
10
|
+
import { buildTrailStack } from './trailStack';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* TrailStepsNav — the trails side panel as a two-step slide: a list of trails,
|
|
@@ -288,6 +289,7 @@ export function TrailStepsNav({
|
|
|
288
289
|
hoveredTrailStep?.trailId === displayedTrail.id &&
|
|
289
290
|
hoveredTrailStep.stepIndex === i;
|
|
290
291
|
const display = trailStepDisplay(step, componentByAlias);
|
|
292
|
+
const stackDepth = buildTrailStack(displayedTrail, i, componentByAlias).frames.length;
|
|
291
293
|
return (
|
|
292
294
|
<button
|
|
293
295
|
key={`${step.from}->${step.to}-${i}`}
|
|
@@ -300,7 +302,7 @@ export function TrailStepsNav({
|
|
|
300
302
|
gap: 8,
|
|
301
303
|
width: '100%',
|
|
302
304
|
minWidth: 0,
|
|
303
|
-
padding:
|
|
305
|
+
padding: `8px 16px 8px ${12 + Math.max(0, stackDepth - 1) * 10}px`,
|
|
304
306
|
border: 'none',
|
|
305
307
|
borderRadius: 0,
|
|
306
308
|
background: active || hovered ? hoverBg : 'transparent',
|
|
@@ -312,8 +314,8 @@ export function TrailStepsNav({
|
|
|
312
314
|
<span
|
|
313
315
|
style={{
|
|
314
316
|
flexShrink: 0,
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
+
// Root steps align with the header icon; nested steps
|
|
318
|
+
// move the number and text together.
|
|
317
319
|
width: 14,
|
|
318
320
|
textAlign: 'center',
|
|
319
321
|
paddingTop: 1,
|
|
@@ -340,7 +342,7 @@ export function TrailStepsNav({
|
|
|
340
342
|
overflowWrap: 'anywhere',
|
|
341
343
|
fontSize: theme.fontSizes[1],
|
|
342
344
|
fontFamily: theme.fonts.body,
|
|
343
|
-
color: theme.colors.
|
|
345
|
+
color: theme.colors.primary,
|
|
344
346
|
lineHeight: 1.35,
|
|
345
347
|
}}
|
|
346
348
|
>
|
|
@@ -115,6 +115,26 @@ function renderPanel(overrides: Partial<TrailsPanelProps> = {}) {
|
|
|
115
115
|
|
|
116
116
|
const COPY_LABEL = 'Copy step 1 of Auth flow for an agent';
|
|
117
117
|
|
|
118
|
+
describe('TrailsPanel trail titles', () => {
|
|
119
|
+
for (const collapsed of [true, false]) {
|
|
120
|
+
test(`shows a decorative trail icon before the ${collapsed ? 'collapsed' : 'expanded'} trail name`, () => {
|
|
121
|
+
const onFocusFlow = mock(() => {});
|
|
122
|
+
const { getByRole } = renderPanel({
|
|
123
|
+
expandedTrails: collapsed ? new Set() : new Set(['auth-flow']),
|
|
124
|
+
onFocusFlow,
|
|
125
|
+
});
|
|
126
|
+
const button = getByRole('button', { name: 'Auth flow', exact: true });
|
|
127
|
+
const icon = button.firstElementChild;
|
|
128
|
+
expect(icon?.tagName.toLowerCase()).toBe('svg');
|
|
129
|
+
expect(icon?.classList.contains('lucide-route')).toBe(true);
|
|
130
|
+
expect(icon?.getAttribute('aria-hidden')).toBe('true');
|
|
131
|
+
expect(icon?.nextElementSibling?.textContent).toBe('Auth flow');
|
|
132
|
+
fireEvent.click(button);
|
|
133
|
+
expect(onFocusFlow).toHaveBeenCalledWith(trails[0]);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
118
138
|
describe('TrailsPanel step copy', () => {
|
|
119
139
|
test('copy icon is hidden until the step row is hovered', () => {
|
|
120
140
|
const { getByText, queryByLabelText } = renderPanel();
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
14
14
|
import type { DragEvent as ReactDragEvent } from 'react';
|
|
15
15
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
16
|
-
import { Check, ChevronDown, Copy, Focus, GripVertical, Minimize2, Pause, Play, Star } from 'lucide-react';
|
|
16
|
+
import { Check, ChevronDown, Copy, Focus, GripVertical, Minimize2, Pause, Play, Route, Star } from 'lucide-react';
|
|
17
17
|
import { IndustryMarkdownSlide } from 'themed-markdown';
|
|
18
18
|
import {
|
|
19
19
|
reorderTargetIndex,
|
|
@@ -316,6 +316,7 @@ function TrailFlow({
|
|
|
316
316
|
display: 'flex',
|
|
317
317
|
alignItems: 'center',
|
|
318
318
|
minWidth: 0,
|
|
319
|
+
gap: 8,
|
|
319
320
|
// Vertical padding, not just the row's min-height: a title that
|
|
320
321
|
// wraps to two+ lines must grow the header past `minHeight`, and
|
|
321
322
|
// without this the text sits flush against the row's top and bottom
|
|
@@ -329,6 +330,15 @@ function TrailFlow({
|
|
|
329
330
|
cursor: 'pointer',
|
|
330
331
|
}}
|
|
331
332
|
>
|
|
333
|
+
<Route
|
|
334
|
+
size={14}
|
|
335
|
+
strokeWidth={2}
|
|
336
|
+
aria-hidden
|
|
337
|
+
style={{
|
|
338
|
+
flexShrink: 0,
|
|
339
|
+
color: theme.colors.textMuted ?? theme.colors.textSecondary,
|
|
340
|
+
}}
|
|
341
|
+
/>
|
|
332
342
|
<span
|
|
333
343
|
style={{
|
|
334
344
|
minWidth: 0,
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { SubsystemTrail } from './model';
|
|
3
|
+
import {
|
|
4
|
+
decorationModelIds,
|
|
5
|
+
decorationTrailId,
|
|
6
|
+
decorationTrailKey,
|
|
7
|
+
findDecorationTrail,
|
|
8
|
+
groupDecorationTrailsByModel,
|
|
9
|
+
type DecorationTrailRef,
|
|
10
|
+
} from './decoration';
|
|
11
|
+
|
|
12
|
+
function ref(modelId: string, trailId: string): DecorationTrailRef {
|
|
13
|
+
return { kind: 'base', modelId, trailId };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const draftTrail: SubsystemTrail = {
|
|
17
|
+
id: 'planned-open',
|
|
18
|
+
title: 'Planned open',
|
|
19
|
+
steps: [],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe('decoration trail refs', () => {
|
|
23
|
+
test('keys are scoped by model', () => {
|
|
24
|
+
expect(decorationTrailKey(ref('model-a', 'open'))).toBe('model-a:open');
|
|
25
|
+
expect(decorationTrailKey(ref('model-a', 'open'))).not.toBe(
|
|
26
|
+
decorationTrailKey(ref('model-b', 'open')),
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('a draft trail keys like a base trail', () => {
|
|
31
|
+
const draft: DecorationTrailRef = {
|
|
32
|
+
kind: 'draft',
|
|
33
|
+
modelId: 'model-a',
|
|
34
|
+
trail: draftTrail,
|
|
35
|
+
};
|
|
36
|
+
expect(decorationTrailKey(draft)).toBe('model-a:planned-open');
|
|
37
|
+
expect(decorationTrailId(draft)).toBe('planned-open');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('groups trails by their own model, preserving authored order', () => {
|
|
41
|
+
const groups = groupDecorationTrailsByModel([
|
|
42
|
+
ref('web', 'open'),
|
|
43
|
+
ref('server', 'handle'),
|
|
44
|
+
ref('web', 'save'),
|
|
45
|
+
ref('worker', 'consume'),
|
|
46
|
+
]);
|
|
47
|
+
expect(groups.map((g) => g.modelId)).toEqual(['web', 'server', 'worker']);
|
|
48
|
+
expect(groups[0]!.trails.map((t) => decorationTrailId(t))).toEqual([
|
|
49
|
+
'open',
|
|
50
|
+
'save',
|
|
51
|
+
]);
|
|
52
|
+
expect(groups[1]!.trails.map((t) => decorationTrailId(t))).toEqual([
|
|
53
|
+
'handle',
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('lists distinct models in first-seen order', () => {
|
|
58
|
+
expect(
|
|
59
|
+
decorationModelIds([
|
|
60
|
+
ref('web', 'open'),
|
|
61
|
+
ref('server', 'handle'),
|
|
62
|
+
ref('web', 'save'),
|
|
63
|
+
]),
|
|
64
|
+
).toEqual(['web', 'server']);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('finds a trail by model and trail id, not trail id alone', () => {
|
|
68
|
+
const refs = [ref('web', 'open'), ref('server', 'open')];
|
|
69
|
+
expect(findDecorationTrail(refs, 'server', 'open')?.modelId).toBe('server');
|
|
70
|
+
expect(findDecorationTrail(refs, 'worker', 'open')).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subsystem decoration — PROTOTYPE contract for the ephemeral, unverified layer.
|
|
3
|
+
*
|
|
4
|
+
* A decoration composes trails from one or more models, carries a markdown
|
|
5
|
+
* description, and may add draft structure. The base model stays verified-only;
|
|
6
|
+
* a decoration is never audited against source.
|
|
7
|
+
*
|
|
8
|
+
* **Each trail ref names its own model.** There is no decoration-level `base`
|
|
9
|
+
* field: a decoration can mix models by construction, and a single-model
|
|
10
|
+
* decoration is just the case where every ref happens to share a `modelId`.
|
|
11
|
+
*
|
|
12
|
+
* A trail can be either:
|
|
13
|
+
* - `base` — a reference to a trail that already exists in a model; or
|
|
14
|
+
* - `draft` — a trail that only exists in the decoration. This is what
|
|
15
|
+
* migration needs when a base trail depends on draft components: the whole
|
|
16
|
+
* trail moves into the decoration rather than dangling in the base.
|
|
17
|
+
*
|
|
18
|
+
* The contract is deliberately host-local and not part of the portable
|
|
19
|
+
* subsystem document. If decorations become shareable, `modelId` needs a
|
|
20
|
+
* portable identity key (the local `sg-...` ids are re-minted).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { SubsystemTrail, SubsystemTrailMechanism } from './model';
|
|
24
|
+
|
|
25
|
+
export interface DecorationStepAnchor {
|
|
26
|
+
/** Claim fingerprint of the anchored step (see `stepClaimKey`). */
|
|
27
|
+
stepKey: string;
|
|
28
|
+
/** Optional per-anchor note. The decoration description carries the prose. */
|
|
29
|
+
note?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface DecorationTrailRefBase {
|
|
33
|
+
/** Store-local model id. The trail id is only unique within this model. */
|
|
34
|
+
modelId: string;
|
|
35
|
+
/** Optional step-level anchors inside this trail. */
|
|
36
|
+
stepAnchors?: DecorationStepAnchor[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A trail that already exists in the base model. */
|
|
40
|
+
export interface DecorationBaseTrailRef extends DecorationTrailRefBase {
|
|
41
|
+
kind: 'base';
|
|
42
|
+
trailId: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A speculative trail that exists only in the decoration. */
|
|
46
|
+
export interface DecorationDraftTrailRef extends DecorationTrailRefBase {
|
|
47
|
+
kind: 'draft';
|
|
48
|
+
trail: SubsystemTrail;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type DecorationTrailRef =
|
|
52
|
+
| DecorationBaseTrailRef
|
|
53
|
+
| DecorationDraftTrailRef;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Draft structure: a component that does not exist in source yet. No
|
|
57
|
+
* `file`/`symbol` — those are the verification coordinates the base model
|
|
58
|
+
* requires, and a draft component deliberately has none.
|
|
59
|
+
*/
|
|
60
|
+
export interface DecorationDraftComponent {
|
|
61
|
+
alias: string;
|
|
62
|
+
name: string;
|
|
63
|
+
/** Intended construct shape (`class`, `function`, …). */
|
|
64
|
+
construct?: string;
|
|
65
|
+
purpose?: string;
|
|
66
|
+
module?: string;
|
|
67
|
+
process?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A speculative edge between base or draft aliases. */
|
|
71
|
+
export interface DecorationDraftEdge {
|
|
72
|
+
from: string;
|
|
73
|
+
to: string;
|
|
74
|
+
mechanism: SubsystemTrailMechanism;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface DecorationDraft {
|
|
78
|
+
components?: DecorationDraftComponent[];
|
|
79
|
+
edges?: DecorationDraftEdge[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface DecorationProvenance {
|
|
83
|
+
source: 'author' | 'agent';
|
|
84
|
+
author?: string;
|
|
85
|
+
/** ISO timestamp. */
|
|
86
|
+
at: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface SubsystemDecoration {
|
|
90
|
+
version: 1;
|
|
91
|
+
id: string;
|
|
92
|
+
title: string;
|
|
93
|
+
/** Markdown, rendered like the model description. */
|
|
94
|
+
description: string;
|
|
95
|
+
/**
|
|
96
|
+
* Ordered trails the decoration cares about. Order is the reading order;
|
|
97
|
+
* `modelId` may vary from ref to ref. A trail is either a base ref or a
|
|
98
|
+
* draft trail that lives only here.
|
|
99
|
+
*/
|
|
100
|
+
trails: DecorationTrailRef[];
|
|
101
|
+
/** Optional draft delta. Absent = a pure reading path. */
|
|
102
|
+
draft?: DecorationDraft;
|
|
103
|
+
provenance: DecorationProvenance;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The trail id for either kind of ref. */
|
|
107
|
+
export function decorationTrailId(ref: DecorationTrailRef): string {
|
|
108
|
+
return ref.kind === 'base' ? ref.trailId : ref.trail.id;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Stable key for a trail ref across models. `trailId` alone is not enough. */
|
|
112
|
+
export function decorationTrailKey(
|
|
113
|
+
ref: Pick<DecorationTrailRef, 'modelId'> & { trailId?: string; trail?: SubsystemTrail },
|
|
114
|
+
): string {
|
|
115
|
+
const trailId = ref.trailId ?? ref.trail?.id ?? '';
|
|
116
|
+
return `${ref.modelId}:${trailId}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Group trail refs by their owning model, preserving first-seen model order
|
|
121
|
+
* and the authored trail order within each group. The UI uses this to render
|
|
122
|
+
* "which trails it cares about" without a separate model list.
|
|
123
|
+
*/
|
|
124
|
+
export function groupDecorationTrailsByModel(
|
|
125
|
+
refs: readonly DecorationTrailRef[],
|
|
126
|
+
): Array<{ modelId: string; trails: DecorationTrailRef[] }> {
|
|
127
|
+
const groups = new Map<string, DecorationTrailRef[]>();
|
|
128
|
+
for (const ref of refs) {
|
|
129
|
+
const existing = groups.get(ref.modelId);
|
|
130
|
+
if (existing) existing.push(ref);
|
|
131
|
+
else groups.set(ref.modelId, [ref]);
|
|
132
|
+
}
|
|
133
|
+
return [...groups].map(([modelId, trails]) => ({ modelId, trails }));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The distinct models a decoration touches, in first-seen order. */
|
|
137
|
+
export function decorationModelIds(
|
|
138
|
+
refs: readonly DecorationTrailRef[],
|
|
139
|
+
): string[] {
|
|
140
|
+
return [...new Set(refs.map((ref) => ref.modelId))];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function findDecorationTrail(
|
|
144
|
+
refs: readonly DecorationTrailRef[],
|
|
145
|
+
modelId: string,
|
|
146
|
+
trailId: string,
|
|
147
|
+
): DecorationTrailRef | undefined {
|
|
148
|
+
return refs.find(
|
|
149
|
+
(ref) => ref.modelId === modelId && decorationTrailId(ref) === trailId,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { SubsystemTrail } from './model';
|
|
3
|
+
import {
|
|
4
|
+
buildStepEmphasisMap,
|
|
5
|
+
emphasisForTrail,
|
|
6
|
+
emphasisKey,
|
|
7
|
+
findStepEmphasis,
|
|
8
|
+
orphanedEmphasis,
|
|
9
|
+
stepClaimKey,
|
|
10
|
+
type StepEmphasisRecord,
|
|
11
|
+
} from './stepEmphasis';
|
|
12
|
+
|
|
13
|
+
function step(overrides: Partial<SubsystemTrail['steps'][number]> = {}): SubsystemTrail['steps'][number] {
|
|
14
|
+
return {
|
|
15
|
+
from: 'ui',
|
|
16
|
+
to: 'api',
|
|
17
|
+
mechanism: 'calls',
|
|
18
|
+
file: 'src/ui/login.tsx',
|
|
19
|
+
span: { startLine: 42, endLine: 42, contentHash: 'abc' },
|
|
20
|
+
purl: 'pkg:github/acme/app#src/ui/login.tsx',
|
|
21
|
+
symbol: 'Login.submit',
|
|
22
|
+
...overrides,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function trail(id: string, steps: SubsystemTrail['steps']): SubsystemTrail {
|
|
27
|
+
return { id, title: id, steps };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function record(trailId: string, stepKey: string): StepEmphasisRecord {
|
|
31
|
+
return {
|
|
32
|
+
trailId,
|
|
33
|
+
stepKey,
|
|
34
|
+
guidance: 'Why this step matters.',
|
|
35
|
+
provenance: { source: 'author', at: '2026-10-19T00:00:00.000Z' },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('stepClaimKey', () => {
|
|
40
|
+
test('is stable for the same claim', () => {
|
|
41
|
+
expect(stepClaimKey(step())).toBe(stepClaimKey(step()));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('ignores annotation, which is not the claim', () => {
|
|
45
|
+
expect(stepClaimKey(step({ annotation: 'why' }))).toBe(
|
|
46
|
+
stepClaimKey(step()),
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('changes when the claim changes', () => {
|
|
51
|
+
const base = stepClaimKey(step());
|
|
52
|
+
expect(base).not.toBe(stepClaimKey(step({ from: 'other' })));
|
|
53
|
+
expect(base).not.toBe(stepClaimKey(step({ to: 'other' })));
|
|
54
|
+
expect(base).not.toBe(stepClaimKey(step({ mechanism: 'feeds' })));
|
|
55
|
+
expect(base).not.toBe(stepClaimKey(step({ file: 'src/other.ts' })));
|
|
56
|
+
expect(base).not.toBe(stepClaimKey(step({ symbol: 'Other.submit' })));
|
|
57
|
+
expect(base).not.toBe(
|
|
58
|
+
stepClaimKey(step({ span: { startLine: 43, endLine: 43, contentHash: 'abc' } })),
|
|
59
|
+
);
|
|
60
|
+
expect(base).not.toBe(
|
|
61
|
+
stepClaimKey(step({ span: { startLine: 42, endLine: 42, contentHash: 'changed' } })),
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('step emphasis lookup', () => {
|
|
67
|
+
test('resolves after steps are reordered', () => {
|
|
68
|
+
const first = step({ symbol: 'first' });
|
|
69
|
+
const second = step({ symbol: 'second' });
|
|
70
|
+
const map = buildStepEmphasisMap([record('flow', stepClaimKey(first))]);
|
|
71
|
+
const reordered = trail('flow', [second, first]);
|
|
72
|
+
expect(findStepEmphasis(map, 'flow', reordered.steps[1]!)).toBeDefined();
|
|
73
|
+
expect(findStepEmphasis(map, 'flow', reordered.steps[0]!)).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('emphasisForTrail reports marks in current step order', () => {
|
|
77
|
+
const a = step({ symbol: 'a' });
|
|
78
|
+
const b = step({ symbol: 'b' });
|
|
79
|
+
const c = step({ symbol: 'c' });
|
|
80
|
+
const map = buildStepEmphasisMap([
|
|
81
|
+
record('flow', stepClaimKey(c)),
|
|
82
|
+
record('flow', stepClaimKey(a)),
|
|
83
|
+
]);
|
|
84
|
+
expect(emphasisForTrail(map, trail('flow', [b, a, c])).map((r) => r.stepIndex)).toEqual([
|
|
85
|
+
1, 2,
|
|
86
|
+
]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('an edited claim orphans its mark rather than re-binding it', () => {
|
|
90
|
+
const original = step();
|
|
91
|
+
const edited = step({ file: 'src/ui/login-v2.tsx' });
|
|
92
|
+
const records = [record('flow', stepClaimKey(original))];
|
|
93
|
+
const orphans = orphanedEmphasis(records, [trail('flow', [edited])]);
|
|
94
|
+
expect(orphans).toHaveLength(1);
|
|
95
|
+
expect(orphans[0]!.stepKey).toBe(stepClaimKey(original));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('live marks are not reported as orphans', () => {
|
|
99
|
+
const live = step();
|
|
100
|
+
const records = [record('flow', stepClaimKey(live))];
|
|
101
|
+
expect(orphanedEmphasis(records, [trail('flow', [live])])).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('keys are scoped by trail id', () => {
|
|
105
|
+
const shared = step();
|
|
106
|
+
const map = buildStepEmphasisMap([record('flow-a', stepClaimKey(shared))]);
|
|
107
|
+
expect(emphasisKey('flow-b', shared)).not.toBe(emphasisKey('flow-a', shared));
|
|
108
|
+
expect(findStepEmphasis(map, 'flow-b', shared)).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
});
|