mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29:42 +02:00
Drawing only the selected tab's family hid every other connection until you clicked into one. All families are drawn again (still as routed trees); the selected tab's families (what it spawned, and the family it was spawned into) get .lineage-family--focus: 2.5px instead of 1.5px, full opacity instead of 0.75, a larger end dot, and drawn last so nothing covers them. Lanes follow strip order (cycling through CodemanLineage.MAX_LANES, 3), never the selection, so a family's lines never move when you click a tab. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
700 lines
28 KiB
TypeScript
700 lines
28 KiB
TypeScript
/**
|
|
* Session lineage lines (tab → tab it spawned).
|
|
*
|
|
* Geometry: every decision about WHERE a route runs lives in computeLineageTree
|
|
* (constants.js), so it is pinned here without a browser. The rules that matter:
|
|
* a route never crosses a tab, siblings share one trunk, rows are joined through a
|
|
* spine left of every tab, and families drawn together sit in separate lanes.
|
|
*
|
|
* Rendering: session-lineage.js is loaded for real in a vm sandbox with a tiny fake
|
|
* DOM, to pin the focus rule (only the SELECTED tab's family is drawn), the
|
|
* per-parent colours, and the reserved routing room.
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import vm from 'node:vm';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
type Rect = { left: number; top: number; width: number; height: number };
|
|
type Point = [number, number];
|
|
type Route = { id: string; points: Point[]; d: string; endX: number; endY: number };
|
|
type TreeInput = {
|
|
parent: Rect | null;
|
|
children: Array<{ id: string; rect: Rect | null }>;
|
|
strip?: Rect;
|
|
tabs?: Rect[];
|
|
orientation?: 'horizontal' | 'vertical';
|
|
lane?: number;
|
|
laneCount?: number;
|
|
radius?: number;
|
|
};
|
|
type LineageHelper = {
|
|
computeTree: (input: TreeInput) => { routes: Route[] } | null;
|
|
computeRows: (rects: Rect[]) => Array<{ top: number; bottom: number }>;
|
|
CORNER_RADIUS_PX: number;
|
|
LANE_STEP_PX: number;
|
|
SPINE_INSET_PX: number;
|
|
VERTICAL_TRACK_INSET_PX: number;
|
|
COLORS: string[];
|
|
};
|
|
|
|
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
|
const read = (file: string) => readFileSync(resolve(PUBLIC, file), 'utf8');
|
|
const lineageJs = read('session-lineage.js');
|
|
const stylesCss = read('styles.css');
|
|
const appJs = read('app.js');
|
|
|
|
function loadLineageHelper(): LineageHelper {
|
|
const context = vm.createContext({ window: {}, globalThis: {} });
|
|
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
|
|
return (context.window as { CodemanLineage: LineageHelper }).CodemanLineage;
|
|
}
|
|
|
|
const tab = (left: number, top = 4, width = 120, height = 30): Rect => ({ left, top, width, height });
|
|
|
|
/**
|
|
* A wrapped strip shaped like the owner's 2026-10-06 report: five rows, 12px row gap,
|
|
* the 20px spine channel reserved on the left, 14px of bottom padding.
|
|
*/
|
|
function wrappedStrip() {
|
|
const strip: Rect = { left: 100, top: 0, width: 1000, height: 0 };
|
|
const widths = [
|
|
[150, 120, 190, 140, 150, 170],
|
|
[140, 140, 140, 140, 140, 90, 90],
|
|
[130, 110, 100, 100, 110, 120, 140],
|
|
[180, 140, 80, 140, 170, 120, 100],
|
|
[150, 120, 110, 90, 110, 100, 120],
|
|
];
|
|
const rows: Rect[][] = [];
|
|
let top = 4;
|
|
for (const row of widths) {
|
|
let left = strip.left + 20;
|
|
const cells: Rect[] = [];
|
|
for (const w of row) {
|
|
cells.push(tab(left, top, w));
|
|
left += w + 2;
|
|
}
|
|
rows.push(cells);
|
|
top += 30 + 12;
|
|
}
|
|
strip.height = top - 12 + 14;
|
|
return { strip, rows, all: rows.flat() };
|
|
}
|
|
|
|
/** Does an axis-aligned segment pass through the INSIDE of a rect (touching an edge is fine)? */
|
|
function crossesRect([x1, y1]: Point, [x2, y2]: Point, r: Rect): boolean {
|
|
const eps = 0.5;
|
|
const left = r.left + eps;
|
|
const right = r.left + r.width - eps;
|
|
const top = r.top + eps;
|
|
const bottom = r.top + r.height - eps;
|
|
if (x1 === x2) return x1 > left && x1 < right && Math.max(y1, y2) > top && Math.min(y1, y2) < bottom;
|
|
return y1 > top && y1 < bottom && Math.max(x1, x2) > left && Math.min(x1, x2) < right;
|
|
}
|
|
|
|
const center = (r: Rect) => r.left + r.width / 2;
|
|
const bottom = (r: Rect) => r.top + r.height;
|
|
|
|
describe('lineage tree geometry: header strip', () => {
|
|
it('routes each child from the parent bottom-center to the child bottom-center', () => {
|
|
const helper = loadLineageHelper();
|
|
const strip: Rect = { left: 0, top: 0, width: 1200, height: 50 };
|
|
const parent = tab(0);
|
|
const geom = helper.computeTree({
|
|
parent,
|
|
children: [
|
|
{ id: 'a', rect: tab(400) },
|
|
{ id: 'b', rect: tab(800) },
|
|
],
|
|
strip,
|
|
})!;
|
|
|
|
expect(geom.routes.map((r) => r.id)).toEqual(['a', 'b']);
|
|
for (const route of geom.routes) {
|
|
expect(route.points[0]).toEqual([60, 34]);
|
|
expect(route.d.startsWith('M 60 34')).toBe(true);
|
|
expect(route.d).not.toContain('NaN');
|
|
}
|
|
expect([geom.routes[0].endX, geom.routes[0].endY]).toEqual([460, 34]);
|
|
expect([geom.routes[1].endX, geom.routes[1].endY]).toEqual([860, 34]);
|
|
});
|
|
|
|
it('keeps a one-row family in the gap under the row, never over the terminal', () => {
|
|
const helper = loadLineageHelper();
|
|
// Tabs end at 34, the strip (with its reserved bottom padding) at 50.
|
|
const strip: Rect = { left: 0, top: 0, width: 1200, height: 50 };
|
|
const geom = helper.computeTree({ parent: tab(0), children: [{ id: 'a', rect: tab(400) }], strip })!;
|
|
const [route] = geom.routes;
|
|
|
|
expect(route.points).toEqual([
|
|
[60, 34],
|
|
[60, 42],
|
|
[460, 42],
|
|
[460, 34],
|
|
]);
|
|
for (const [, y] of route.points) {
|
|
expect(y).toBeGreaterThanOrEqual(34);
|
|
expect(y).toBeLessThanOrEqual(50);
|
|
}
|
|
});
|
|
|
|
it('never crosses a tab in a wrapped strip, wherever parent and children sit', () => {
|
|
// The reported bug: a parent on row 3 with children on rows 3-5 drew curves
|
|
// straight through every lower row's labels and into the terminal.
|
|
const helper = loadLineageHelper();
|
|
const { strip, rows, all } = wrappedStrip();
|
|
const parent = rows[2][1];
|
|
const children = [rows[0][3], rows[2][2], rows[2][5], rows[3][0], rows[3][6], rows[4][1], rows[4][6]].map(
|
|
(rect, i) => ({ id: `c${i}`, rect })
|
|
);
|
|
const geom = helper.computeTree({ parent, children, strip, tabs: all })!;
|
|
|
|
expect(geom.routes).toHaveLength(children.length);
|
|
for (const route of geom.routes) {
|
|
for (let i = 1; i < route.points.length; i++) {
|
|
for (const r of all) {
|
|
expect(crossesRect(route.points[i - 1], route.points[i], r), `${route.id} segment ${i} vs tab`).toBe(false);
|
|
}
|
|
}
|
|
// Nothing hangs below the strip into the terminal.
|
|
for (const [, y] of route.points) expect(y).toBeLessThanOrEqual(strip.top + strip.height);
|
|
}
|
|
});
|
|
|
|
it('joins rows through one spine, left of every tab and inside the strip', () => {
|
|
const helper = loadLineageHelper();
|
|
const { strip, rows, all } = wrappedStrip();
|
|
const geom = helper.computeTree({
|
|
parent: rows[2][1],
|
|
children: [
|
|
{ id: 'below', rect: rows[4][3] },
|
|
{ id: 'above', rect: rows[0][2] },
|
|
],
|
|
strip,
|
|
tabs: all,
|
|
})!;
|
|
const minTabLeft = Math.min(...all.map((r) => r.left));
|
|
|
|
const spines = geom.routes.map((r) => r.points[2][0]);
|
|
expect(new Set(spines).size).toBe(1);
|
|
expect(spines[0]).toBeGreaterThan(strip.left);
|
|
expect(spines[0]).toBeLessThan(minTabLeft);
|
|
expect(spines[0]).toBe(strip.left + helper.SPINE_INSET_PX);
|
|
});
|
|
|
|
it('shares one trunk: sibling routes start identically and branch at their own row', () => {
|
|
const helper = loadLineageHelper();
|
|
const { strip, rows, all } = wrappedStrip();
|
|
const geom = helper.computeTree({
|
|
parent: rows[1][2],
|
|
children: [
|
|
{ id: 'r3a', rect: rows[3][1] },
|
|
{ id: 'r3b', rect: rows[3][4] },
|
|
{ id: 'r4', rect: rows[4][2] },
|
|
],
|
|
strip,
|
|
tabs: all,
|
|
})!;
|
|
const [a, b, c] = geom.routes;
|
|
|
|
// Parent stem, the run along the parent's gap, and the spine are common to all.
|
|
expect(a.points.slice(0, 3)).toEqual(b.points.slice(0, 3));
|
|
expect(a.points.slice(0, 3)).toEqual(c.points.slice(0, 3));
|
|
// The two row-3 children also share that row's gap line until they branch.
|
|
expect(a.points[3]).toEqual(b.points[3]);
|
|
expect(a.points[4][1]).toBe(b.points[4][1]);
|
|
expect(c.points[3][1]).toBeGreaterThan(a.points[3][1]);
|
|
});
|
|
|
|
it("hangs a row's gap under its TALLEST tab, so siblings in one row share a bus", () => {
|
|
const helper = loadLineageHelper();
|
|
const strip: Rect = { left: 0, top: 0, width: 1200, height: 52 };
|
|
// The active tab is 2px taller than its neighbours.
|
|
const tabs = [tab(0), tab(140, 4, 120, 32), tab(280), tab(420)];
|
|
const rows = helper.computeRows(tabs);
|
|
expect(rows).toEqual([{ top: 4, bottom: 36 }]);
|
|
|
|
const geom = helper.computeTree({
|
|
parent: tabs[0],
|
|
children: [
|
|
{ id: 'tall', rect: tabs[1] },
|
|
{ id: 'short', rect: tabs[3] },
|
|
],
|
|
strip,
|
|
tabs,
|
|
})!;
|
|
expect(geom.routes[0].points[2][1]).toBe(geom.routes[1].points[2][1]);
|
|
expect(geom.routes[0].points[2][1]).toBe(44);
|
|
});
|
|
|
|
it('puts two families drawn together in separate lanes', () => {
|
|
const helper = loadLineageHelper();
|
|
const { strip, rows, all } = wrappedStrip();
|
|
const input = { parent: rows[2][1], children: [{ id: 'x', rect: rows[3][2] }], strip, tabs: all, laneCount: 2 };
|
|
const first = helper.computeTree({ ...input, lane: 0 })!.routes[0];
|
|
const second = helper.computeTree({ ...input, lane: 1 })!.routes[0];
|
|
|
|
expect(second.points[1][1] - first.points[1][1]).toBeCloseTo(helper.LANE_STEP_PX, 1);
|
|
expect(second.points[2][0]).toBeGreaterThan(first.points[2][0]);
|
|
expect([second.endX, second.endY]).toEqual([first.endX, first.endY]);
|
|
});
|
|
|
|
it('rounds corners, and draws them square with radius 0', () => {
|
|
const helper = loadLineageHelper();
|
|
const { strip, rows, all } = wrappedStrip();
|
|
const input = { parent: rows[2][1], children: [{ id: 'x', rect: rows[4][4] }], strip, tabs: all };
|
|
|
|
expect(helper.computeTree(input)!.routes[0].d).toContain(' Q ');
|
|
expect(helper.computeTree({ ...input, radius: 0 })!.routes[0].d).not.toContain(' Q ');
|
|
expect(helper.CORNER_RADIUS_PX).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('leaves out a child scrolled out of the strip, and draws nothing for a scrolled-out parent', () => {
|
|
const helper = loadLineageHelper();
|
|
// `.session-tabs` scrolls, so a scrolled-out tab still HAS a rect, one lying
|
|
// over the logo or the header buttons. It must not be drawn to.
|
|
const strip: Rect = { left: 200, top: 0, width: 600, height: 50 };
|
|
|
|
const partial = helper.computeTree({
|
|
parent: tab(300),
|
|
children: [
|
|
{ id: 'in', rect: tab(600) },
|
|
{ id: 'out', rect: tab(1400) },
|
|
],
|
|
strip,
|
|
})!;
|
|
expect(partial.routes.map((r) => r.id)).toEqual(['in']);
|
|
expect(helper.computeTree({ parent: tab(-300), children: [{ id: 'a', rect: tab(400) }], strip })).toBeNull();
|
|
});
|
|
|
|
it('returns null for a missing or degenerate parent, and drops a degenerate child', () => {
|
|
const helper = loadLineageHelper();
|
|
const strip: Rect = { left: 0, top: 0, width: 1200, height: 50 };
|
|
|
|
expect(helper.computeTree({ parent: null, children: [{ id: 'a', rect: tab(0) }], strip })).toBeNull();
|
|
expect(helper.computeTree({ parent: { left: 0, top: 0, width: 0, height: 0 }, children: [], strip })).toBeNull();
|
|
const geom = helper.computeTree({
|
|
parent: tab(0),
|
|
children: [
|
|
{ id: 'zero', rect: { left: 400, top: 4, width: 0, height: 30 } },
|
|
{ id: 'none', rect: null },
|
|
{ id: 'ok', rect: tab(400) },
|
|
],
|
|
strip,
|
|
})!;
|
|
expect(geom.routes.map((r) => r.id)).toEqual(['ok']);
|
|
});
|
|
|
|
it('still draws when no strip rect is supplied (clipping is opt-in)', () => {
|
|
const helper = loadLineageHelper();
|
|
const geom = helper.computeTree({ parent: tab(0), children: [{ id: 'far', rect: tab(9000, 46) }] })!;
|
|
|
|
expect(geom.routes).toHaveLength(1);
|
|
expect(geom.routes[0].d).not.toContain('NaN');
|
|
});
|
|
});
|
|
|
|
describe('lineage tree geometry: vertical rail', () => {
|
|
const strip: Rect = { left: 100, top: 20, width: 320, height: 320 };
|
|
const parent: Rect = { left: 132, top: 40, width: 260, height: 40 };
|
|
|
|
it('routes every sibling down ONE track in the empty left gutter', () => {
|
|
const helper = loadLineageHelper();
|
|
const geom = helper.computeTree({
|
|
parent,
|
|
children: [
|
|
{ id: 'a', rect: { ...parent, top: 120 } },
|
|
{ id: 'b', rect: { ...parent, top: 200 } },
|
|
],
|
|
strip,
|
|
orientation: 'vertical',
|
|
})!;
|
|
|
|
const tracks = geom.routes.map((r) => r.points[1][0]);
|
|
expect(new Set(tracks).size).toBe(1);
|
|
expect(tracks[0]).toBe(strip.left + helper.VERTICAL_TRACK_INSET_PX);
|
|
expect(tracks[0]).toBeLessThan(parent.left);
|
|
for (const route of geom.routes) {
|
|
expect(route.points[0]).toEqual([parent.left, 60]);
|
|
expect(route.endX).toBe(parent.left);
|
|
}
|
|
expect(geom.routes.map((r) => r.endY)).toEqual([140, 220]);
|
|
});
|
|
|
|
it('keeps the same shape when the child sits above its parent', () => {
|
|
const helper = loadLineageHelper();
|
|
const low = { ...parent, top: 220 };
|
|
const geom = helper.computeTree({
|
|
parent: low,
|
|
children: [{ id: 'up', rect: { ...parent, top: 60 } }],
|
|
strip,
|
|
orientation: 'vertical',
|
|
})!;
|
|
const [route] = geom.routes;
|
|
|
|
expect(route.points[0]).toEqual([low.left, 240]);
|
|
expect([route.endX, route.endY]).toEqual([low.left, 80]);
|
|
});
|
|
|
|
it('clips by the visible Y range after the rail scrolls', () => {
|
|
const helper = loadLineageHelper();
|
|
const scrolled: Rect = { left: 100, top: 100, width: 320, height: 300 };
|
|
const visible: Rect = { left: 132, top: 160, width: 260, height: 40 };
|
|
const above: Rect = { left: 132, top: 20, width: 260, height: 40 };
|
|
const below: Rect = { left: 132, top: 460, width: 260, height: 40 };
|
|
|
|
expect(
|
|
helper.computeTree({
|
|
parent: above,
|
|
children: [{ id: 'v', rect: visible }],
|
|
strip: scrolled,
|
|
orientation: 'vertical',
|
|
})
|
|
).toBeNull();
|
|
expect(
|
|
helper.computeTree({
|
|
parent: visible,
|
|
children: [{ id: 'b', rect: below }],
|
|
strip: scrolled,
|
|
orientation: 'vertical',
|
|
})!.routes
|
|
).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('lineage wiring', () => {
|
|
it('passes the resolved DOM orientation into geometry and reserves a vertical gutter', () => {
|
|
expect(lineageJs).toContain("getAttribute('data-tab-orientation')");
|
|
expect(lineageJs).toMatch(/computeTree\(\{[\s\S]{0,260}orientation/);
|
|
const selector = "html[data-tab-orientation='vertical'] .tab-rail .session-tabs {";
|
|
const verticalRailBlock = stylesCss.slice(stylesCss.indexOf(selector), stylesCss.indexOf(selector) + 600);
|
|
expect(verticalRailBlock).toContain('--lineage-vertical-gutter');
|
|
expect(verticalRailBlock).toContain('padding-left');
|
|
});
|
|
|
|
it('reserves the header strip routing room in CSS', () => {
|
|
const block = (selector: string) => {
|
|
const start = stylesCss.indexOf(selector);
|
|
expect(start, `${selector} not found`).toBeGreaterThan(-1);
|
|
return stylesCss.slice(start, stylesCss.indexOf('}', start));
|
|
};
|
|
expect(block('.session-tabs.lineage-tree {')).toContain('padding-bottom');
|
|
const wrapped = block('.session-tabs.lineage-tree.tabs-auto-wrap {');
|
|
expect(wrapped).toContain('row-gap');
|
|
expect(wrapped).toContain('padding-left');
|
|
});
|
|
|
|
it('syncs the routing room before the wrap is measured, and redraws on selection', () => {
|
|
const overflow = appJs.slice(appJs.indexOf(' updateTabOverflowMode() {'));
|
|
expect(overflow.indexOf('_syncLineageGutter')).toBeGreaterThan(-1);
|
|
expect(overflow.indexOf('_syncLineageGutter')).toBeLessThan(overflow.indexOf('shouldAutoWrapTabs'));
|
|
const activeTab = appJs.slice(
|
|
appJs.indexOf(' _updateActiveTabImmediate(sessionId) {'),
|
|
appJs.indexOf(' _scrollActiveTabIntoView(sessionId')
|
|
);
|
|
expect(activeTab).toMatch(/_lineageTotalEdges > 0\) this\.updateConnectionLines\(\)/);
|
|
});
|
|
|
|
it('exposes a colour palette whose first entry defers to the skin blue', () => {
|
|
const colors = loadLineageHelper().COLORS;
|
|
// '' = no override: session-lineage.js sets no inline --lineage-color and the
|
|
// CSS falls back to the skin-tuned --session-blue, so a lone family stays blue.
|
|
expect(colors[0]).toBe('');
|
|
expect(colors.length).toBeGreaterThanOrEqual(6);
|
|
expect(new Set(colors).size).toBe(colors.length);
|
|
for (const c of colors.slice(1)) expect(c).toMatch(/^#[0-9a-f]{6}$/i);
|
|
});
|
|
});
|
|
|
|
// ─── Rendering through the real session-lineage.js ────────────────────────────
|
|
|
|
type FakeNode = {
|
|
tag: string;
|
|
attrs: Record<string, string>;
|
|
style: Record<string, string> & { setProperty: (k: string, v: string) => void };
|
|
children: FakeNode[];
|
|
setAttribute: (k: string, v: string) => void;
|
|
appendChild: (n: FakeNode) => void;
|
|
};
|
|
type LineageApp = Record<string, Function> & {
|
|
sessions: Map<string, { parentSessionId: string | null; status: string }>;
|
|
sessionOrder: string[];
|
|
activeSessionId: string | null;
|
|
activeWebviewId: string | null;
|
|
_lineageEdgeCount: number;
|
|
_lineageTotalEdges: number;
|
|
};
|
|
|
|
function fakeNode(tag: string): FakeNode {
|
|
const style = {} as FakeNode['style'];
|
|
style.setProperty = (k, v) => void (style[k] = v);
|
|
const node: FakeNode = {
|
|
tag,
|
|
attrs: {},
|
|
style,
|
|
children: [],
|
|
setAttribute: (k, v) => void (node.attrs[k] = v),
|
|
appendChild: (n) => void node.children.push(n),
|
|
};
|
|
return node;
|
|
}
|
|
|
|
/**
|
|
* Load session-lineage.js for real, on a one-row strip where every session is a
|
|
* 120px tab, 140px apart. The colour memo is plain state on the app instance.
|
|
*/
|
|
function loadLineageApp(sessions: Record<string, string | null>, status: Record<string, string> = {}) {
|
|
function CodemanApp(this: unknown) {}
|
|
const ids = Object.keys(sessions);
|
|
const rect = (i: number) => ({ left: i * 140, top: 4, width: 120, height: 30, right: i * 140 + 120, bottom: 34 });
|
|
const stripClasses = new Set<string>();
|
|
const listeners: Record<string, (e: { propertyName: string }) => void> = {};
|
|
const strip = {
|
|
addEventListener: (type: string, fn: (e: { propertyName: string }) => void) => void (listeners[type] = fn),
|
|
getBoundingClientRect: () => ({ left: 0, top: 0, width: 4000, height: 50, right: 4000, bottom: 50 }),
|
|
querySelector: (sel: string) => {
|
|
const id = /data-id="([^"]+)"/.exec(sel)?.[1];
|
|
const i = id ? ids.indexOf(id) : -1;
|
|
return i < 0 ? null : { getBoundingClientRect: () => rect(i) };
|
|
},
|
|
querySelectorAll: () => ids.map((id, i) => ({ getAttribute: () => id, getBoundingClientRect: () => rect(i) })),
|
|
classList: {
|
|
contains: (c: string) => stripClasses.has(c),
|
|
toggle: (c: string, on: boolean) => void (on ? stripClasses.add(c) : stripClasses.delete(c)),
|
|
},
|
|
};
|
|
const sandbox: Record<string, unknown> = {
|
|
window: {},
|
|
globalThis: {},
|
|
CodemanApp,
|
|
MobileDetection: { getDeviceType: () => 'desktop' },
|
|
CSS: { escape: (v: string) => v },
|
|
document: {
|
|
documentElement: { getAttribute: () => 'horizontal' },
|
|
getElementById: (id: string) => (id === 'sessionTabs' ? strip : null),
|
|
createElementNS: (_ns: string, tag: string) => fakeNode(tag),
|
|
},
|
|
};
|
|
const context = vm.createContext(sandbox);
|
|
for (const file of ['constants.js', 'session-lineage.js']) {
|
|
vm.runInContext(read(file), context, { filename: file });
|
|
}
|
|
const app = new (CodemanApp as unknown as new () => LineageApp)();
|
|
app.sessions = new Map(ids.map((id) => [id, { parentSessionId: sessions[id], status: status[id] ?? 'idle' }]));
|
|
app.sessionOrder = ids;
|
|
app.activeSessionId = null;
|
|
app.activeWebviewId = null;
|
|
app._lineageLinesEnabled = () => true;
|
|
app.isSessionSidebarActive = () => false;
|
|
app._isVerticalTabList = () => false;
|
|
return { app, stripClasses, listeners };
|
|
}
|
|
|
|
/** Draw with `active` selected; report each family group and its routes. */
|
|
function draw(app: LineageApp, active: string | null) {
|
|
app.activeSessionId = active;
|
|
const svg = fakeNode('svg');
|
|
app._appendLineageConnectionLines(svg, new Map());
|
|
return svg.children.map((group) => ({
|
|
parent: group.attrs['data-parent-tab'],
|
|
focus: group.attrs.class.split(' ').includes('lineage-family--focus'),
|
|
paths: group.children.filter((n) => n.tag === 'path'),
|
|
dots: group.children.filter((n) => n.tag === 'circle'),
|
|
}));
|
|
}
|
|
|
|
const family = (families: ReturnType<typeof draw>, parent: string) => families.find((f) => f.parent === parent)!;
|
|
|
|
const childIds = (families: ReturnType<typeof draw>) =>
|
|
families.flatMap((f) => f.paths.map((p) => p.attrs['data-child-tab']));
|
|
|
|
describe('lineage focus: every family is drawn, the selected one emphasized', () => {
|
|
const fleet = { w1: null, a: 'w1', b: 'w1', w2: null, c: 'w2', w3: null };
|
|
const focused = (families: ReturnType<typeof draw>) => families.filter((f) => f.focus).map((f) => f.parent);
|
|
|
|
it('draws every family whichever tab is selected, the selected one last and emphasized', () => {
|
|
const { app } = loadLineageApp(fleet);
|
|
const families = draw(app, 'w1');
|
|
|
|
// Drawn last, so no other family's line covers it.
|
|
expect(families.map((f) => f.parent)).toEqual(['w2', 'w1']);
|
|
expect(focused(families)).toEqual(['w1']);
|
|
expect(childIds(families).sort()).toEqual(['a', 'b', 'c']);
|
|
expect(app._lineageEdgeCount).toBe(3);
|
|
expect(app._lineageTotalEdges).toBe(3);
|
|
});
|
|
|
|
it('emphasizes the parent and siblings of a selected child', () => {
|
|
const { app } = loadLineageApp(fleet);
|
|
expect(focused(draw(app, 'a'))).toEqual(['w1']);
|
|
expect(focused(draw(app, 'c'))).toEqual(['w2']);
|
|
});
|
|
|
|
it('emphasizes both families of a tab that is a child AND a parent', () => {
|
|
const { app } = loadLineageApp({ w1: null, a: 'w1', x: 'a', y: 'a', w2: null, c: 'w2' });
|
|
const families = draw(app, 'a');
|
|
|
|
expect(focused(families)).toEqual(['w1', 'a']);
|
|
expect(families.map((f) => f.parent)).toEqual(['w2', 'w1', 'a']);
|
|
});
|
|
|
|
it('still draws everything, emphasizing nothing, for a tab without lineage or a web tab', () => {
|
|
const { app } = loadLineageApp(fleet);
|
|
const plain = draw(app, 'w3');
|
|
expect(plain.map((f) => f.parent)).toEqual(['w1', 'w2']);
|
|
expect(focused(plain)).toEqual([]);
|
|
app.activeWebviewId = 'dash';
|
|
const web = draw(app, 'w1');
|
|
expect(web).toHaveLength(2);
|
|
expect(focused(web)).toEqual([]);
|
|
});
|
|
|
|
it('keeps every family in its lane when the selection moves', () => {
|
|
// Lanes follow strip order, so selecting another tab changes only the emphasis
|
|
// and the draw order, never where a family's lines run.
|
|
const { app } = loadLineageApp(fleet);
|
|
const before = family(draw(app, 'w1'), 'w2').paths.map((p) => p.attrs.d);
|
|
const after = family(draw(app, 'w2'), 'w2').paths.map((p) => p.attrs.d);
|
|
expect(after).toEqual(before);
|
|
});
|
|
|
|
it('keeps the data-agent-id the entrance animation looks for, and a dot per child', () => {
|
|
const { app } = loadLineageApp(fleet);
|
|
const w1 = family(draw(app, 'w1'), 'w1');
|
|
|
|
expect(w1.paths.map((p) => p.attrs['data-agent-id'])).toEqual(['lineage:a', 'lineage:b']);
|
|
expect(w1.dots.map((d) => d.attrs['data-child-tab'])).toEqual(['a', 'b']);
|
|
});
|
|
|
|
it('puts working routes first, so an idle sibling draws the shared trunk solid', () => {
|
|
const { app } = loadLineageApp(fleet, { b: 'working' });
|
|
const w1 = family(draw(app, 'w1'), 'w1');
|
|
|
|
expect(w1.paths.map((p) => p.attrs['data-child-tab'])).toEqual(['b', 'a']);
|
|
expect(w1.paths[0].attrs.class).toContain('lineage-line--working');
|
|
expect(w1.paths[1].attrs.class).not.toContain('lineage-line--working');
|
|
});
|
|
|
|
it('makes the emphasized family thicker in CSS, after the base rule', () => {
|
|
const base = stylesCss.indexOf('.connection-line.lineage-line {');
|
|
const focus = stylesCss.indexOf('.lineage-family--focus .connection-line.lineage-line {');
|
|
expect(base).toBeGreaterThan(-1);
|
|
expect(focus).toBeGreaterThan(base);
|
|
const width = (at: number) => Number(/stroke-width:\s*([\d.]+)/.exec(stylesCss.slice(at))![1]);
|
|
expect(width(focus)).toBeGreaterThan(width(base));
|
|
});
|
|
});
|
|
|
|
describe('lineage routing room', () => {
|
|
it('is reserved while any family exists, whatever is selected', () => {
|
|
const { app, stripClasses } = loadLineageApp({ w1: null, a: 'w1', w2: null });
|
|
app.activeSessionId = 'w2';
|
|
app._syncLineageGutter();
|
|
expect(stripClasses.has('lineage-tree')).toBe(true);
|
|
expect(app._lineageTotalEdges).toBe(1);
|
|
});
|
|
|
|
it('is released with no lineage, with the setting off, and in a vertical list', () => {
|
|
const none = loadLineageApp({ w1: null, w2: null });
|
|
none.app._syncLineageGutter();
|
|
expect(none.stripClasses.has('lineage-tree')).toBe(false);
|
|
|
|
const off = loadLineageApp({ w1: null, a: 'w1' });
|
|
off.app._syncLineageGutter();
|
|
expect(off.stripClasses.has('lineage-tree')).toBe(true);
|
|
off.app._lineageLinesEnabled = () => false;
|
|
off.app._syncLineageGutter();
|
|
expect(off.stripClasses.has('lineage-tree')).toBe(false);
|
|
expect(off.app._lineageTotalEdges).toBe(0);
|
|
|
|
const rail = loadLineageApp({ w1: null, a: 'w1' });
|
|
rail.app._isVerticalTabList = () => true;
|
|
rail.app._syncLineageGutter();
|
|
expect(rail.stripClasses.has('lineage-tree')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('lineage redraw after a selection resizes tabs', () => {
|
|
it('redraws when a size transition in the strip ends, and only for size properties', () => {
|
|
// The active tab reveals its icons by transitioning padding, so it keeps
|
|
// widening after the selection redraw; without this the trunk hung under the
|
|
// tab's old position.
|
|
const { app, listeners } = loadLineageApp({ w1: null, a: 'w1' });
|
|
let redraws = 0;
|
|
app.updateConnectionLines = () => void redraws++;
|
|
app._lineageTotalEdges = 1;
|
|
app._installLineageStripScrollListener();
|
|
|
|
listeners.transitionend({ propertyName: 'padding-left' });
|
|
listeners.transitionend({ propertyName: 'width' });
|
|
expect(redraws).toBe(2);
|
|
listeners.transitionend({ propertyName: 'opacity' });
|
|
listeners.transitionend({ propertyName: 'transform' });
|
|
expect(redraws).toBe(2);
|
|
app._lineageTotalEdges = 0;
|
|
listeners.transitionend({ propertyName: 'padding-left' });
|
|
expect(redraws).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('lineage colours', () => {
|
|
/** Colour of each drawn child's path, after selecting each tab in `selections` in turn. */
|
|
function colorsAfter(sessions: Record<string, string | null>, selections: string[]) {
|
|
const { app } = loadLineageApp(sessions);
|
|
const out: Record<string, string> = {};
|
|
for (const active of selections) {
|
|
for (const family of draw(app, active)) {
|
|
for (const path of family.paths) {
|
|
// The path and its end dot carry the child id; they must agree on the colour.
|
|
const child = path.attrs['data-child-tab'];
|
|
const dot = family.dots.find((d) => d.attrs['data-child-tab'] === child)!;
|
|
expect(dot.style['--lineage-color'] ?? '').toBe(path.style['--lineage-color'] ?? '');
|
|
out[child] = path.style['--lineage-color'] ?? '';
|
|
}
|
|
}
|
|
}
|
|
return { app, colors: out };
|
|
}
|
|
|
|
it('paints every line out of one tab the same colour', () => {
|
|
const { colors } = colorsAfter({ w1: null, a: 'w1', b: 'w1', c: 'w1' }, ['w1']);
|
|
expect(Object.keys(colors).sort()).toEqual(['a', 'b', 'c']);
|
|
expect(new Set(Object.values(colors)).size).toBe(1);
|
|
});
|
|
|
|
it('paints two spawning tabs in different colours', () => {
|
|
const { colors } = colorsAfter({ w1: null, w2: null, a: 'w1', b: 'w1', c: 'w2', d: 'w2' }, ['w1', 'w2']);
|
|
expect(colors.a).toBe(colors.b);
|
|
expect(colors.c).toBe(colors.d);
|
|
expect(colors.a).not.toBe(colors.c);
|
|
});
|
|
|
|
it('assigns colours in strip order, not in the order families get selected', () => {
|
|
// Selecting w2's family first must not hand w2 the first (blue) colour.
|
|
const { colors } = colorsAfter({ w1: null, a: 'w1', w2: null, c: 'w2' }, ['c', 'a']);
|
|
expect(colors.a).toBe('');
|
|
expect(colors.c).not.toBe('');
|
|
});
|
|
|
|
it('changes colour at each generation of a chain', () => {
|
|
const { colors } = colorsAfter({ w1: null, w2: 'w1', w3: 'w2' }, ['w2']);
|
|
expect(colors.w2).not.toBe(colors.w3);
|
|
});
|
|
|
|
it('keeps a tab on its colour across re-renders and interleaved siblings', () => {
|
|
const { app } = loadLineageApp({});
|
|
const first = app._lineageColorFor('w1');
|
|
app._lineageColorFor('w2');
|
|
app._lineageColorFor('w3');
|
|
expect(app._lineageColorFor('w1')).toBe(first);
|
|
});
|
|
|
|
it('cycles the palette once every tab in it has spawned', () => {
|
|
const { app } = loadLineageApp({});
|
|
const palette = loadLineageHelper().COLORS;
|
|
const seen = Array.from({ length: palette.length }, (_, i) => app._lineageColorFor(`p${i}`));
|
|
expect(new Set(seen).size).toBe(palette.length);
|
|
expect(app._lineageColorFor(`p${palette.length}`)).toBe(seen[0]);
|
|
});
|
|
});
|