mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
chore: version packages
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Geometry policy for the session lineage lines (tab → tab it spawned).
|
||||
*
|
||||
* The renderer in session-lineage.js measures and appends; every decision about
|
||||
* WHAT to draw (and whether to draw at all) lives in computeLineagePath, so it can
|
||||
* be pinned here without a browser.
|
||||
*/
|
||||
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 LineagePath = { d: string; endX: number; endY: number; sameRow: boolean } | null;
|
||||
|
||||
function loadLineageHelper() {
|
||||
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||
vm.runInContext(source, context, { filename: 'constants.js' });
|
||||
return (
|
||||
context.window as {
|
||||
CodemanLineage: {
|
||||
computePath: (input: { parent: Rect | null; child: Rect | null; strip?: Rect; depth?: number }) => LineagePath;
|
||||
DIP_MIN_PX: number;
|
||||
DIP_MAX_PX: number;
|
||||
SIBLING_STEP_PX: number;
|
||||
};
|
||||
}
|
||||
).CodemanLineage;
|
||||
}
|
||||
|
||||
// A strip wide enough that nothing is clipped unless a test says so.
|
||||
const STRIP: Rect = { left: 0, top: 0, width: 1200, height: 40 };
|
||||
const tab = (left: number, top = 4): Rect => ({ left, top, width: 120, height: 30 });
|
||||
|
||||
/** Pull the control-point Y values out of `M x y C x y, x y, x y`. */
|
||||
function controlYs(d: string): number[] {
|
||||
const nums = d.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [];
|
||||
// M x0 y0 C x1 y1, x2 y2, x3 y3 → indices 3 and 5 are the control Ys
|
||||
return [nums[3], nums[5]];
|
||||
}
|
||||
|
||||
describe('lineage line geometry', () => {
|
||||
it('bridges two same-row tabs with an arc that hangs BELOW the strip', () => {
|
||||
const helper = loadLineageHelper();
|
||||
const geom = helper.computePath({ parent: tab(0), child: tab(400), strip: STRIP });
|
||||
|
||||
expect(geom).not.toBeNull();
|
||||
expect(geom!.sameRow).toBe(true);
|
||||
// Starts at the parent's bottom-center, ends at the child's bottom-center.
|
||||
expect(geom!.d.startsWith('M 60 34')).toBe(true);
|
||||
expect(geom!.endX).toBe(460);
|
||||
expect(geom!.endY).toBe(34);
|
||||
// Both control points dip below the tab bottoms — that is what makes it a
|
||||
// bracket under the strip rather than a line drawn across the tabs.
|
||||
for (const y of controlYs(geom!.d)) expect(y).toBeGreaterThan(34);
|
||||
});
|
||||
|
||||
it('deepens the dip with distance, but keeps it inside the clamp', () => {
|
||||
const helper = loadLineageHelper();
|
||||
const near = helper.computePath({ parent: tab(0), child: tab(140), strip: STRIP })!;
|
||||
const far = helper.computePath({ parent: tab(0), child: tab(1000), strip: STRIP })!;
|
||||
|
||||
const nearDip = controlYs(near.d)[0] - 34;
|
||||
const farDip = controlYs(far.d)[0] - 34;
|
||||
expect(farDip).toBeGreaterThan(nearDip);
|
||||
expect(nearDip).toBeGreaterThanOrEqual(helper.DIP_MIN_PX);
|
||||
expect(farDip).toBeLessThanOrEqual(helper.DIP_MAX_PX);
|
||||
});
|
||||
|
||||
it('nests siblings by depth so two children of one parent do not overprint', () => {
|
||||
const helper = loadLineageHelper();
|
||||
const first = helper.computePath({ parent: tab(0), child: tab(400), strip: STRIP, depth: 0 })!;
|
||||
const second = helper.computePath({ parent: tab(0), child: tab(400), strip: STRIP, depth: 1 })!;
|
||||
|
||||
expect(controlYs(second.d)[0] - controlYs(first.d)[0]).toBe(helper.SIBLING_STEP_PX);
|
||||
expect(first.d).not.toBe(second.d);
|
||||
});
|
||||
|
||||
it('switches to a vertical bezier when the strip has wrapped to two rows', () => {
|
||||
const helper = loadLineageHelper();
|
||||
const strip: Rect = { left: 0, top: 0, width: 1200, height: 90 };
|
||||
const geom = helper.computePath({ parent: tab(0, 4), child: tab(200, 48), strip })!;
|
||||
|
||||
expect(geom.sameRow).toBe(false);
|
||||
// Parent bottom (34) → child top (48): the arc travels between rows.
|
||||
expect(geom.d.startsWith('M 60 34')).toBe(true);
|
||||
expect(geom.endY).toBe(48);
|
||||
});
|
||||
|
||||
it('draws upward when the child sits on the row ABOVE its parent', () => {
|
||||
const helper = loadLineageHelper();
|
||||
const strip: Rect = { left: 0, top: 0, width: 1200, height: 90 };
|
||||
const geom = helper.computePath({ parent: tab(0, 48), child: tab(200, 4), strip })!;
|
||||
|
||||
expect(geom.sameRow).toBe(false);
|
||||
expect(geom.d.startsWith('M 60 48')).toBe(true); // parent TOP edge
|
||||
expect(geom.endY).toBe(34); // child bottom edge
|
||||
});
|
||||
|
||||
it('skips an edge whose tab is scrolled out of the strip', () => {
|
||||
const helper = loadLineageHelper();
|
||||
// `.session-tabs` is overflow-x:auto, 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: 40 };
|
||||
|
||||
expect(helper.computePath({ parent: tab(-300), child: tab(400), strip })).toBeNull();
|
||||
expect(helper.computePath({ parent: tab(400), child: tab(1400), strip })).toBeNull();
|
||||
expect(helper.computePath({ parent: tab(300), child: tab(600), strip })).not.toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a missing or degenerate rect instead of emitting NaN', () => {
|
||||
const helper = loadLineageHelper();
|
||||
|
||||
expect(helper.computePath({ parent: null, child: tab(0), strip: STRIP })).toBeNull();
|
||||
expect(helper.computePath({ parent: tab(0), child: null, strip: STRIP })).toBeNull();
|
||||
expect(
|
||||
helper.computePath({ parent: { left: 0, top: 0, width: 0, height: 0 }, child: tab(0), strip: STRIP })
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('still draws when no strip rect is supplied (clipping is opt-in)', () => {
|
||||
const helper = loadLineageHelper();
|
||||
const geom = helper.computePath({ parent: tab(0), child: tab(9000) });
|
||||
|
||||
expect(geom).not.toBeNull();
|
||||
expect(geom!.d).not.toContain('NaN');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user