Files
codegraph/__tests__/ui-trails-model.test.ts
T
Colby McHenryandClaude Opus 5 47576b392e feat(ui): saved trails — a walk you named, kept, and still true after a re-index (CG-60)
Save trail on the trail bar writes the walk to .codegraph/ui/trails/ as one
JSON file, listed on the empty screen and on Entry points above the derived
suggestions, reopened at the symbol you left with the whole path restored.

A hop is stored by qualified name, kind and file — never by node id, which
contains a start line and so changes the first time anybody edits above the
symbol. Every hop is re-resolved against the current index on the way out and
each row says what became of it: still here, moved to another file, now
ambiguous, or gone. A hole is never stitched over: the row opens the longest
run of CONSECUTIVE resolved hops and says which ones those are, because the
trail is a path and a skipped hop would draw a call that does not exist.

This is the first write the viewer makes, and the boundary moved with it:
POST/DELETE answer under /api/ only, must carry X-CodeGraph-UI and
application/json (neither of which a cross-origin form can produce without a
preflight this server answers none of), and --read-only refuses both while
still listing what is there. The blanket "read-only" claim is retired from the
banner, the README, the CLI help and the docs site in favour of the narrower
true one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 08:31:48 -05:00

180 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* What a saved trail's row says, without a browser (CG-60).
*
* The endpoint's own behaviour is pinned in `ui-trails.test.ts` against a real
* index; this is the wording layer, and the rule it exists to protect is that
* **a trail that has decayed never reads as intact**. A saved trail is somebody's
* explanation of a codebase that has since moved underneath it, and a row that
* prints "6 hops" while two of them are gone is a lie by omission at exactly the
* moment the trail needs fixing.
*/
import { describe, it, expect } from 'vitest';
import {
hopStatusWord,
isOpenable,
replacedTrail,
trailDecay,
trailExport,
trailMeta,
trailNameProblem,
trailOpens,
trailTitle,
} from '../ui/src/lib/trails-model';
import type { WireTrail, WireTrailHop, WireTrailHopStatus } from '../ui/src/lib/wire';
function hop(
name: string,
status: WireTrailHopStatus = 'ok',
dir: WireTrailHop['dir'] = 'down'
): WireTrailHop {
const alive = status !== 'missing';
return {
dir,
name,
qualifiedName: name,
kind: 'function',
savedFile: 'src/a.ts',
savedLine: 10,
status,
id: alive ? `function:${name}` : null,
file: alive ? 'src/a.ts' : null,
line: alive ? 10 : null,
note: status === 'ok' ? null : `${name} ${status}`,
};
}
function trail(hops: WireTrailHop[], over: Partial<WireTrail> = {}): WireTrail {
const resolved = hops.filter((h) => h.id !== null);
return {
id: 'a-walk',
name: 'A walk',
note: '',
author: 'Ada',
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-02T00:00:00.000Z',
hops,
resolved: resolved.length,
intact: hops.every((h) => h.status === 'ok'),
encoded: resolved.length > 0 ? resolved.map((h) => `d${h.id}`).join(',') : null,
openFrom: 1,
openCount: resolved.length,
openId: resolved.length > 0 ? (resolved[resolved.length - 1] as WireTrailHop).id : null,
...over,
};
}
describe('trailMeta', () => {
it('reports the SAVED length, whatever became of the hops', () => {
const decayed = trail([hop('a', 'ok', 'start'), hop('b', 'missing'), hop('c')]);
expect(trailMeta(decayed)).toBe('3 hops · Ada');
});
it('drops the author when there is not one', () => {
expect(trailMeta(trail([hop('a', 'ok', 'start')], { author: '' }))).toBe('1 hop');
});
});
describe('trailDecay', () => {
it('is null for a trail nothing has happened to', () => {
expect(trailDecay(trail([hop('a', 'ok', 'start'), hop('b')]))).toBeNull();
});
it('warns about hops that are gone, naming them', () => {
const decay = trailDecay(trail([hop('a', 'ok', 'start'), hop('gone', 'missing')]));
expect(decay?.tone).toBe('warn');
expect(decay?.text).toContain('1 hop moved or renamed');
expect(decay?.text).toContain('gone');
});
it('caps how many it names', () => {
const hops = ['a', 'b', 'c', 'd', 'e'].map((n) => hop(n, 'missing'));
const decay = trailDecay(trail(hops));
expect(decay?.text).toContain('and 2 more');
});
it('notes a move without warning about it — a moved hop still opens', () => {
const decay = trailDecay(trail([hop('a', 'ok', 'start'), hop('b', 'moved')]));
expect(decay?.tone).toBe('note');
expect(decay?.text).toContain('moved to another file');
});
it('puts a missing hop ahead of a merely moved one', () => {
const decay = trailDecay(trail([hop('m', 'moved'), hop('g', 'missing')]));
expect(decay?.text).toContain('moved or renamed');
});
it('warns about an ambiguous hop — the trail may no longer mean what it said', () => {
const decay = trailDecay(trail([hop('a', 'ok', 'start'), hop('b', 'ambiguous')]));
expect(decay?.tone).toBe('warn');
expect(decay?.text).toContain('more than one symbol');
});
});
describe('trailOpens', () => {
it('says nothing when the whole trail opens', () => {
expect(trailOpens(trail([hop('a', 'ok', 'start'), hop('b')]))).toBeNull();
});
it('names the range when only part of it does', () => {
const partial = trail([hop('a'), hop('b'), hop('c')], {
openFrom: 2,
openCount: 2,
});
expect(trailOpens(partial)).toBe('Opens hops 23 of 3.');
});
it('says so plainly when nothing resolves', () => {
const dead = trail([hop('a', 'missing')], { encoded: null, openCount: 0, openId: null });
expect(trailOpens(dead)).toContain('None of this trail resolves');
expect(isOpenable(dead)).toBe(false);
});
});
describe('trailTitle', () => {
it('draws the whole walk with its arrows, and when it was saved', () => {
const walked = trail([hop('a', 'ok', 'start'), hop('b', 'ok', 'down'), hop('c', 'ok', 'up')]);
expect(trailTitle(walked)).toBe('a → b ← c — saved 2026-08-02');
});
});
describe('saving', () => {
it('refuses an empty or over-long name before the round-trip', () => {
expect(trailNameProblem(' ', 120)).toContain('name');
expect(trailNameProblem('x'.repeat(121), 120)).toContain('too long');
expect(trailNameProblem('ok', 120)).toBeNull();
});
it('spots the trail a name would replace, whitespace and all', () => {
const list = [trail([hop('a', 'ok', 'start')], { name: 'A walk' })];
expect(replacedTrail(' A walk ', list)?.name).toBe('A walk');
expect(replacedTrail('Another walk', list)).toBeNull();
});
});
describe('trailExport', () => {
it('exports the SAVED identity of each hop, not todays resolution', () => {
const moved = trail([hop('a', 'ok', 'start'), hop('b', 'moved')]);
const raw = JSON.parse(trailExport(moved));
expect(raw.version).toBe(1);
// `savedFile`, so dropping the file into another checkout re-runs the same
// resolution rather than baking this index's answer in.
expect(raw.hops[1].file).toBe('src/a.ts');
expect(raw.hops[1].qualifiedName).toBe('b');
expect(raw.hops.map((h: { dir: string }) => h.dir)).toEqual(['start', 'down']);
});
it('survives a hop with no id at all', () => {
const raw = JSON.parse(trailExport(trail([hop('gone', 'missing')])));
expect(raw.hops[0].id).toBe('');
});
});
describe('hopStatusWord', () => {
it('has a word for every status', () => {
for (const status of ['ok', 'moved', 'ambiguous', 'missing'] as const) {
expect(hopStatusWord(status)).toBeTruthy();
}
});
});