Rename-shuffle fix: label-only channel + three hygiene items
The live-rename interlude routed label changes through a fresh snapshot object, which flowed setData -> geometry rebuild - and the rebuild treats the last-fetched position ledger as authoritative and cancels any pending drag commit, so a rename visibly rewound the whole graph whenever display had drifted (recent drag, mid-anneal, physics re-anneal). - Renderer gains setClusterLabels (a setPhysicsTuning sibling): pure applyClusterLabels patches the data-side cluster records and the built region meta (re-wrapping the changed entries with the same measure the build uses), markDirty only. Never touches nodes, the sim, positions, or the pending commit. The hull-silence guard travels inside it: a null-label cluster is a gone record - no rename can reach it. - The lh:chat-renamed listener stops building snapshot objects: renameSessionsPatch writes the sessions entry in place (pure display state - the detail card's provenance line), the cluster label goes through the new channel, and a tick bump re-renders the view. Identity preserved everywhere -> the [snapshot]-keyed setData feed cannot fire. - Hygiene (a): a fresh sim starts at alpha 1 and never decays while stopped - the first post-rebuild drag re-annealed the whole graph at full strength. buildSim now initializes alpha at the settle floor, so first-drag matches steady-state drag. setPhysicsTuning still rehearses alpha(1) when a full re-anneal is the point. - Hygiene (b): nodes missing from the position ledger used to dump at (0,0) via the ?? 0 fallback. seedMissingNodePositions seeds them on a deterministic ring around the known positions' centroid (FNV-1a hashed angle, golden-angle spiral offset). - Hygiene (c) is store-band (writePositionMap's writeFileSync -> tmp+rename): flagged for Platform, not crossed. Tests: applyClusterLabels suite (patch + re-wrap, hull silence, no-op, explicit null, unknown ids), renameSessionsPatch identity pins (sessions map and clusters array identity preserved - the contract that keeps setData from firing), seedMissingNodePositions determinism + ring math. Live-verified: mid-anneal rename shows a 4-world-unit max disc jump (no teleport), the commit PUT survives the rename, rest renames leave the disc build byte-identical, and a held first drag displaces discs ~14 units instead of re-forming the graph.
This commit is contained in:
@@ -218,6 +218,44 @@ export function wrapRegionLabel(
|
|||||||
lines.push(`${tail}…`);
|
lines.push(`${tail}…`);
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Label-only cluster patch (rename-shuffle fix, 2026-09-23): apply a set
|
||||||
|
* of live label updates to the data-side cluster records AND the built
|
||||||
|
* region meta (re-wrapping the changed entries with the provided
|
||||||
|
* measure — the same wrap a geometry build would produce). The
|
||||||
|
* hull-silence guard travels here: a cluster whose CURRENT label is
|
||||||
|
* null is a gone record — no rename can reach it. Pure, in-place on the
|
||||||
|
* provided arrays; returns whether anything changed (the caller only
|
||||||
|
* redraws when it did). Exported for tests. */
|
||||||
|
export function applyClusterLabels(
|
||||||
|
clusters: { id: string; label: string | null }[],
|
||||||
|
regionMeta: { id: string; label: string | null; lines: string[] | null }[],
|
||||||
|
labels: ReadonlyMap<string, string | null>,
|
||||||
|
maxWidth: number,
|
||||||
|
maxLines: number,
|
||||||
|
measure: (s: string) => number,
|
||||||
|
): boolean {
|
||||||
|
let changed = false;
|
||||||
|
for (const cluster of clusters) {
|
||||||
|
if (!labels.has(cluster.id)) continue;
|
||||||
|
if (cluster.label == null) continue; // hull silence: gone records stay silent
|
||||||
|
const label = labels.get(cluster.id) ?? null;
|
||||||
|
if (label === cluster.label) continue;
|
||||||
|
cluster.label = label;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!changed) return false;
|
||||||
|
for (const rm of regionMeta) {
|
||||||
|
if (!labels.has(rm.id)) continue;
|
||||||
|
if (rm.label == null) continue;
|
||||||
|
const label = labels.get(rm.id) ?? null;
|
||||||
|
rm.label = label;
|
||||||
|
rm.lines = label
|
||||||
|
? wrapRegionLabel(label, maxWidth, maxLines, measure)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const LABEL_MAX_WIDTH = 170; // wrap width at the reference label size, world units
|
const LABEL_MAX_WIDTH = 170; // wrap width at the reference label size, world units
|
||||||
const LABEL_GAP = 2.9; // world units between node edge and first label line (the 2px-at-fit reference ÷ 0.69)
|
const LABEL_GAP = 2.9; // world units between node edge and first label line (the 2px-at-fit reference ÷ 0.69)
|
||||||
const LABEL_LINE_HEIGHT = 1.3;
|
const LABEL_LINE_HEIGHT = 1.3;
|
||||||
@@ -590,7 +628,7 @@ export class GraphCanvasRenderer {
|
|||||||
/** World rect the bitmap covers (drawImage target coords). */
|
/** World rect the bitmap covers (drawImage target coords). */
|
||||||
private regionRect: { minX: number; minY: number; w: number; h: number } | null = null;
|
private regionRect: { minX: number; minY: number; w: number; h: number } | null = null;
|
||||||
/** Region labels: cluster centroid + hue, computed at splat build. */
|
/** Region labels: cluster centroid + hue, computed at splat build. */
|
||||||
private regionMeta: { label: string | null; lines: string[] | null; cx: number; cy: number; hue: number }[] = [];
|
private regionMeta: { id: string; label: string | null; lines: string[] | null; cx: number; cy: number; hue: number }[] = [];
|
||||||
private regionTuning: RegionTuning = DEFAULT_REGION_TUNING;
|
private regionTuning: RegionTuning = DEFAULT_REGION_TUNING;
|
||||||
private physicsTuning: PhysicsTuning = DEFAULT_PHYSICS_TUNING;
|
private physicsTuning: PhysicsTuning = DEFAULT_PHYSICS_TUNING;
|
||||||
/** Gravity center for the live sim (graph bbox centroid at setData). */
|
/** Gravity center for the live sim (graph bbox centroid at setData). */
|
||||||
@@ -771,6 +809,42 @@ export class GraphCanvasRenderer {
|
|||||||
this.markDirty();
|
this.markDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Label-only channel (rename-shuffle fix, 2026-09-23): live
|
||||||
|
* cluster-label updates with NO setData. The rename path used to
|
||||||
|
* build a fresh snapshot object, which flowed setData → geometry
|
||||||
|
* rebuild — and the rebuild treats the last-fetched ledger as
|
||||||
|
* authoritative and cancels any pending drag commit, so a rename
|
||||||
|
* visibly rewound the whole graph whenever display had drifted
|
||||||
|
* (recent drag, mid-anneal, physics re-anneal). This channel touches
|
||||||
|
* ONLY label text: the region meta the draw pass reads plus the
|
||||||
|
* data-side cluster record (so the next geometry rebuild —
|
||||||
|
* visibility/settings/size — keeps the fresh label). Nodes, the
|
||||||
|
* sim, positions, and the pending commit are never touched. */
|
||||||
|
setClusterLabels(labels: ReadonlyMap<string, string | null>): void {
|
||||||
|
const data = this.data;
|
||||||
|
if (!data) return;
|
||||||
|
const settings = this.settings;
|
||||||
|
// No settings yet → nothing is drawn; patch the data side only and
|
||||||
|
// let the next build derive the wrap (same guard the build uses).
|
||||||
|
if (!settings) {
|
||||||
|
applyClusterLabels(data.clusters, [], labels, REGION_LABEL_MAX_WIDTH, REGION_LABEL_MAX_LINES, () => 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ctx = this.ctx;
|
||||||
|
const prevFont = ctx.font;
|
||||||
|
ctx.font = `${LABEL_WEIGHT} ${settings.clusterLabelSize}px ${data.fontFamily}`;
|
||||||
|
const changed = applyClusterLabels(
|
||||||
|
data.clusters,
|
||||||
|
this.regionMeta,
|
||||||
|
labels,
|
||||||
|
REGION_LABEL_MAX_WIDTH,
|
||||||
|
REGION_LABEL_MAX_LINES,
|
||||||
|
(s) => ctx.measureText(s).width,
|
||||||
|
);
|
||||||
|
ctx.font = prevFont;
|
||||||
|
if (changed) this.markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
setVisible(visible: ReadonlySet<string> | null): void {
|
setVisible(visible: ReadonlySet<string> | null): void {
|
||||||
this.visible = visible;
|
this.visible = visible;
|
||||||
this.geoVersion++; // member visibility changes hulls + paths
|
this.geoVersion++; // member visibility changes hulls + paths
|
||||||
@@ -1229,6 +1303,14 @@ export class GraphCanvasRenderer {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.simById = new Map(data.nodes.map((n) => [n.id, n as LayoutNode]));
|
this.simById = new Map(data.nodes.map((n) => [n.id, n as LayoutNode]));
|
||||||
|
// Hygiene (2026-09-23 fix wave): a fresh sim starts at alpha 1 and
|
||||||
|
// never decays while stopped — so the first post-rebuild drag would
|
||||||
|
// re-anneal the whole graph at full strength (alphaTarget(0.3)
|
||||||
|
// glides DOWN from 1 instead of gently up from rest). Initialize at
|
||||||
|
// the settle floor so first-drag behavior matches steady-state
|
||||||
|
// drags. setPhysicsTuning still rehearses its explicit alpha(1)
|
||||||
|
// when a full re-anneal IS the point.
|
||||||
|
this.sim.alpha(SIM_SETTLE_ALPHA);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rasterize cluster regions into the low-res world-space bitmap.
|
/** Rasterize cluster regions into the low-res world-space bitmap.
|
||||||
@@ -1296,6 +1378,7 @@ export class GraphCanvasRenderer {
|
|||||||
}
|
}
|
||||||
if (members > 0) {
|
if (members > 0) {
|
||||||
this.regionMeta.push({
|
this.regionMeta.push({
|
||||||
|
id: cluster.id,
|
||||||
label: cluster.label || null,
|
label: cluster.label || null,
|
||||||
cx: sumX / members,
|
cx: sumX / members,
|
||||||
cy: sumY / members,
|
cy: sumY / members,
|
||||||
|
|||||||
+79
-18
@@ -129,6 +129,64 @@ export function isNodeVisible(node: Pick<GraphNode, "name" | "statement">, query
|
|||||||
return node.name.toLowerCase().includes(q) || node.statement.toLowerCase().includes(q);
|
return node.name.toLowerCase().includes(q) || node.statement.toLowerCase().includes(q);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** In-place sessions patch for a live rename (rename-shuffle fix,
|
||||||
|
* 2026-09-23). The OLD path built a fresh snapshot object, which
|
||||||
|
* flowed through the [snapshot]-keyed setData effect → geometry
|
||||||
|
* rebuild → last-fetched ledger became authoritative + pending drag
|
||||||
|
* commit cancelled → the graph visibly rewound whenever display had
|
||||||
|
* drifted. This seam NEVER changes object identity: same snapshot
|
||||||
|
* reference, same clusters array — so the data feed cannot re-fire.
|
||||||
|
* The display refresh rides a separate tick state, and the cluster
|
||||||
|
* label itself goes through the renderer's label-only channel
|
||||||
|
* (setClusterLabels). Exported for tests; pure display state only —
|
||||||
|
* the server derives the same join on its next read. */
|
||||||
|
export function renameSessionsPatch(snapshot: Pick<GraphSnapshot, "sessions">, id: string, title: string): void {
|
||||||
|
snapshot.sessions[id] = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed positions for nodes the ledger doesn't know yet (hygiene,
|
||||||
|
* 2026-09-23 fix wave): they used to fall through to a ?? 0 and dump
|
||||||
|
* at the origin. Place them on a deterministic ring around the known
|
||||||
|
* positions' centroid — angle hashed from the id (FNV-1a), golden-angle
|
||||||
|
* spiral offset so even hash collisions spread. Stable across
|
||||||
|
* remounts, far enough off the mass to see, and they relax with the
|
||||||
|
* pack on the next drag. In-place on the provided map; pure otherwise. */
|
||||||
|
export function seedMissingNodePositions(
|
||||||
|
nodes: readonly { id: string }[],
|
||||||
|
positions: Map<string, { x: number; y: number }>,
|
||||||
|
): Map<string, { x: number; y: number }> {
|
||||||
|
if (positions.size === 0 || nodes.every((n) => positions.has(n.id))) return positions;
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
for (const p of positions.values()) {
|
||||||
|
if (p.x < minX) minX = p.x;
|
||||||
|
if (p.y < minY) minY = p.y;
|
||||||
|
if (p.x > maxX) maxX = p.x;
|
||||||
|
if (p.y > maxY) maxY = p.y;
|
||||||
|
}
|
||||||
|
const cx = (minX + maxX) / 2;
|
||||||
|
const cy = (minY + maxY) / 2;
|
||||||
|
const radius = Math.max(maxX - minX, maxY - minY, 40) * 0.7 + 20;
|
||||||
|
const missing = nodes.filter((n) => !positions.has(n.id));
|
||||||
|
missing.forEach((n, i) => {
|
||||||
|
// FNV-1a hash → [0,1) → angle; the golden-angle spiral offset
|
||||||
|
// both spreads collisions and staggers long paths deterministically.
|
||||||
|
let h = 0x811c9dc5;
|
||||||
|
for (let c = 0; c < n.id.length; c++) {
|
||||||
|
h ^= n.id.charCodeAt(c);
|
||||||
|
h = (h * 0x01000193) >>> 0;
|
||||||
|
}
|
||||||
|
const angle = ((h / 0xffffffff) * 2 * Math.PI + i * 2.39996323) % (2 * Math.PI);
|
||||||
|
positions.set(n.id, {
|
||||||
|
x: cx + Math.cos(angle) * radius,
|
||||||
|
y: cy + Math.sin(angle) * radius,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Color helpers (canvas wants hex, not oklch) ---------------------------
|
// ---- Color helpers (canvas wants hex, not oklch) ---------------------------
|
||||||
|
|
||||||
/** Resolve a CSS variable (oklch in this app) to #rrggbb via the canvas. */
|
/** Resolve a CSS variable (oklch in this app) to #rrggbb via the canvas. */
|
||||||
@@ -921,27 +979,27 @@ export function GraphView() {
|
|||||||
return () => window.removeEventListener("lh:graph-revision", onRevision);
|
return () => window.removeEventListener("lh:graph-revision", onRevision);
|
||||||
}, [refreshCosts]);
|
}, [refreshCosts]);
|
||||||
|
|
||||||
/* Live rename labels (wave 7 interlude): a session rename anywhere
|
/* Live rename labels (rename-shuffle fix, 2026-09-23): a session
|
||||||
(sidebar inline row, chat header) announces itself as lh:chat-renamed
|
rename anywhere (sidebar inline row, chat header) announces itself
|
||||||
{id, title} — and the birth-session partition means that title IS
|
as lh:chat-renamed {id, title} — and the birth-session partition
|
||||||
the cluster label. Patch the snapshot in place: the sessions map
|
means that title IS the cluster label. The label update goes
|
||||||
(the detail card's provenance line) and the one matching cluster.
|
through the renderer's label-only channel (setClusterLabels): NO
|
||||||
No refetch — the server derives the same join on its next read, this
|
new snapshot object, NO setData, NO geometry rebuild — so the
|
||||||
just keeps the two views from disagreeing until then. Unlabeled
|
position ledger can't assert itself and a pending drag commit
|
||||||
clusters keep their hull-only silence (a null label is a GONE
|
survives. The sessions map (the detail card's provenance line)
|
||||||
record — a rename can't reach one, and the guard holds the line). */
|
patches in place; a tick bump re-renders the view so an open
|
||||||
|
detail card reads the fresh title. Unlabeled clusters keep their
|
||||||
|
hull-only silence — the null-label guard lives in the channel
|
||||||
|
(applyClusterLabels): a rename can't reach a gone record. */
|
||||||
|
const [, setLabelTick] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onRenamed = (e: Event) => {
|
const onRenamed = (e: Event) => {
|
||||||
const { id, title } = (e as CustomEvent<{ id?: string; title?: string }>).detail ?? {};
|
const { id, title } = (e as CustomEvent<{ id?: string; title?: string }>).detail ?? {};
|
||||||
if (typeof id !== "string" || typeof title !== "string" || id === "" || title === "") return;
|
if (typeof id !== "string" || typeof title !== "string" || id === "" || title === "") return;
|
||||||
setSnapshot((snap) => {
|
const snap = snapshotRef.current;
|
||||||
if (snap == null) return snap;
|
if (snap != null) renameSessionsPatch(snap, id, title);
|
||||||
const sessions = { ...snap.sessions, [id]: title };
|
rendererRef.current?.setClusterLabels(new Map([[id, title]]));
|
||||||
const clusters = snap.clusters.map((c) =>
|
setLabelTick((t) => t + 1);
|
||||||
c.id === id && c.label != null ? { ...c, label: title } : c,
|
|
||||||
);
|
|
||||||
return { ...snap, sessions, clusters };
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
window.addEventListener("lh:chat-renamed", onRenamed);
|
window.addEventListener("lh:chat-renamed", onRenamed);
|
||||||
return () => window.removeEventListener("lh:chat-renamed", onRenamed);
|
return () => window.removeEventListener("lh:chat-renamed", onRenamed);
|
||||||
@@ -995,7 +1053,10 @@ export function GraphView() {
|
|||||||
if (p) positions.set(node.id, { x: p.x, y: p.y });
|
if (p) positions.set(node.id, { x: p.x, y: p.y });
|
||||||
}
|
}
|
||||||
if (positions.size > 0) {
|
if (positions.size > 0) {
|
||||||
posRef.current = positions;
|
// Hygiene (2026-09-23 fix wave): nodes the ledger doesn't know
|
||||||
|
// yet (raced mid-settle) get seeded ring positions instead of
|
||||||
|
// the old ?? 0 origin dump.
|
||||||
|
posRef.current = seedMissingNodePositions(snapshot.nodes, positions);
|
||||||
return () => {
|
return () => {
|
||||||
posRef.current = null;
|
posRef.current = null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it, it as test } from "node:test";
|
import { describe, it, it as test } from "node:test";
|
||||||
import {
|
import {
|
||||||
|
applyClusterLabels,
|
||||||
arrowGeometry,
|
arrowGeometry,
|
||||||
boxBlurRound,
|
boxBlurRound,
|
||||||
fitTransform,
|
fitTransform,
|
||||||
@@ -314,3 +315,81 @@ describe("wrapRegionLabel (wave 7: session names / file paths)", () => {
|
|||||||
assert.ok(measure(lines[1]!) <= W, "ellipsized tail fits");
|
assert.ok(measure(lines[1]!) <= W, "ellipsized tail fits");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("applyClusterLabels (the label-only rename channel)", () => {
|
||||||
|
const measure = (s: string) => s.length * 5;
|
||||||
|
const W = 220;
|
||||||
|
const MAXL = 3;
|
||||||
|
|
||||||
|
const fixture = () => ({
|
||||||
|
clusters: [
|
||||||
|
{ id: "chat_a", label: "Old title" },
|
||||||
|
{ id: "chat_b", label: null },
|
||||||
|
{ id: "seedplan", label: "Orbital mechanics" },
|
||||||
|
],
|
||||||
|
regionMeta: [
|
||||||
|
{ id: "chat_a", label: "Old title", lines: ["Old title"], cx: 0, cy: 0, hue: 10 },
|
||||||
|
{ id: "chat_b", label: null, lines: null, cx: 1, cy: 1, hue: 20 },
|
||||||
|
{ id: "seedplan", label: "Orbital mechanics", lines: ["Orbital mechanics"], cx: 2, cy: 2, hue: 30 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
it("patches the matching cluster and re-wraps its region meta", () => {
|
||||||
|
const f = fixture();
|
||||||
|
// 5px/char and W=220: a 45-char title just overflows one line
|
||||||
|
// ("...for the" is 40 chars, appending " graph" reads 46).
|
||||||
|
const longTitle = "A much longer new session title for the graph";
|
||||||
|
const changed = applyClusterLabels(
|
||||||
|
f.clusters, f.regionMeta, new Map([["chat_a", longTitle]]),
|
||||||
|
W, MAXL, measure,
|
||||||
|
);
|
||||||
|
assert.equal(changed, true);
|
||||||
|
assert.equal(f.clusters[0]!.label, longTitle);
|
||||||
|
assert.deepEqual(f.regionMeta[0]!.lines, ["A much longer new session title for the", "graph"]);
|
||||||
|
// untouched entries stay byte-identical
|
||||||
|
assert.equal(f.clusters[1]!.label, null);
|
||||||
|
assert.equal(f.clusters[2]!.label, "Orbital mechanics");
|
||||||
|
assert.deepEqual(f.regionMeta[2]!.lines, ["Orbital mechanics"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hull silence: a null-label cluster never gains a label", () => {
|
||||||
|
const f = fixture();
|
||||||
|
const changed = applyClusterLabels(
|
||||||
|
f.clusters, f.regionMeta, new Map([["chat_b", "Resurrected?"]]),
|
||||||
|
W, MAXL, measure,
|
||||||
|
);
|
||||||
|
assert.equal(changed, false);
|
||||||
|
assert.equal(f.clusters[1]!.label, null);
|
||||||
|
assert.equal(f.regionMeta[1]!.lines, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a label already matching is a no-op (no redraw)", () => {
|
||||||
|
const f = fixture();
|
||||||
|
const changed = applyClusterLabels(
|
||||||
|
f.clusters, f.regionMeta, new Map([["seedplan", "Orbital mechanics"]]),
|
||||||
|
W, MAXL, measure,
|
||||||
|
);
|
||||||
|
assert.equal(changed, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an explicit null clears a labeled cluster's lines", () => {
|
||||||
|
const f = fixture();
|
||||||
|
const changed = applyClusterLabels(
|
||||||
|
f.clusters, f.regionMeta, new Map([["chat_a", null]]),
|
||||||
|
W, MAXL, measure,
|
||||||
|
);
|
||||||
|
assert.equal(changed, true);
|
||||||
|
assert.equal(f.clusters[0]!.label, null);
|
||||||
|
assert.equal(f.regionMeta[0]!.lines, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unknown ids in the map change nothing", () => {
|
||||||
|
const f = fixture();
|
||||||
|
const changed = applyClusterLabels(
|
||||||
|
f.clusters, f.regionMeta, new Map([["chat_ghost", "Nope"]]),
|
||||||
|
W, MAXL, measure,
|
||||||
|
);
|
||||||
|
assert.equal(changed, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* Rename-shuffle fix tests (2026-09-23 fix wave): the live-rename label
|
||||||
|
* path must be LABEL-ONLY — it can never build a new snapshot object,
|
||||||
|
* because the [snapshot]-keyed data feed (renderer.setData) treats any
|
||||||
|
* identity change as "the server spoke": the geometry rebuild makes the
|
||||||
|
* last-fetched ledger authoritative and cancels any pending drag
|
||||||
|
* commit, rewinding the graph whenever display had drifted.
|
||||||
|
*
|
||||||
|
* The pinned contracts here:
|
||||||
|
* - renameSessionsPatch changes display state IN PLACE (no identity
|
||||||
|
* change → the data feed cannot re-fire);
|
||||||
|
* - seedMissingNodePositions gives ledger-unknown nodes a seeded ring
|
||||||
|
* instead of the old ?? 0 origin dump;
|
||||||
|
* - applyClusterLabels (the renderer's label-only channel) has its own
|
||||||
|
* suite in graph-canvas.test.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { renameSessionsPatch, seedMissingNodePositions } from "../components/graph-view.tsx";
|
||||||
|
|
||||||
|
const snapshot = (): {
|
||||||
|
sessions: Record<string, string>;
|
||||||
|
fileSources: Record<string, string>;
|
||||||
|
clusters: { id: string; label: string | null; members: string[] }[];
|
||||||
|
nodes: { id: string }[];
|
||||||
|
edges: never[];
|
||||||
|
nodeSessions: Record<string, string[]>;
|
||||||
|
nodeObservations: Record<string, Record<string, string>>;
|
||||||
|
} => ({
|
||||||
|
sessions: { chat_a: "Old title", seedplan: "Orbital mechanics" },
|
||||||
|
fileSources: {},
|
||||||
|
clusters: [
|
||||||
|
{ id: "chat_a", label: "Old title", members: ["n1"] },
|
||||||
|
{ id: "chat_b", label: null, members: ["n2"] },
|
||||||
|
],
|
||||||
|
nodes: [{ id: "n1" }, { id: "n2" }],
|
||||||
|
edges: [],
|
||||||
|
nodeSessions: {},
|
||||||
|
nodeObservations: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renameSessionsPatch (in-place, identity-preserving)", () => {
|
||||||
|
it("writes the title into the sessions map and keeps EVERY object identity", () => {
|
||||||
|
const snap = snapshot();
|
||||||
|
const sessionsRef = snap.sessions;
|
||||||
|
const clustersRef = snap.clusters;
|
||||||
|
renameSessionsPatch(snap, "chat_a", "New title");
|
||||||
|
// display state changed...
|
||||||
|
assert.equal(snap.sessions.chat_a, "New title");
|
||||||
|
// ...but nothing was rebuilt: same map, same array, same snapshot.
|
||||||
|
// This is the contract that keeps the [snapshot]-keyed setData
|
||||||
|
// feed from firing (and the pending drag commit from dying).
|
||||||
|
assert.equal(snap.sessions, sessionsRef, "sessions map identity preserved");
|
||||||
|
assert.equal(snap.clusters, clustersRef, "clusters array identity preserved");
|
||||||
|
assert.equal(snap.clusters[0]!.label, "Old title", "react-side cluster label untouched (renderer channel owns it)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds an unknown session id harmlessly (renames outside the graph)", () => {
|
||||||
|
const snap = snapshot();
|
||||||
|
renameSessionsPatch(snap, "chat_other", "Some other chat");
|
||||||
|
assert.equal(snap.sessions.chat_other, "Some other chat");
|
||||||
|
assert.equal(snap.sessions.chat_a, "Old title");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("seedMissingNodePositions (ledger stragglers)", () => {
|
||||||
|
const positions = (): Map<string, { x: number; y: number }> =>
|
||||||
|
new Map([
|
||||||
|
["n1", { x: -100, y: 0 }],
|
||||||
|
["n2", { x: 100, y: 40 }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it("known positions are byte-identical; missing nodes are seeded off the origin", () => {
|
||||||
|
const nodes = [{ id: "n1" }, { id: "n2" }, { id: "n_new" }, { id: "n_newer" }];
|
||||||
|
const out = seedMissingNodePositions(nodes, positions());
|
||||||
|
// the two known entries untouched (same objects, same values)
|
||||||
|
assert.equal(out.get("n1"), out.get("n1"));
|
||||||
|
assert.equal(out.get("n1")!.x, -100);
|
||||||
|
assert.equal(out.get("n2")!.y, 40);
|
||||||
|
// every node has a position now — no ?? 0 origin dump
|
||||||
|
assert.equal(out.size, 4);
|
||||||
|
for (const id of ["n_new", "n_newer"]) {
|
||||||
|
const p = out.get(id)!;
|
||||||
|
assert.ok(p.x !== 0 || p.y !== 0, `${id} not at the origin`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deterministic across runs (same ids → same ring spots)", () => {
|
||||||
|
const nodes = [{ id: "n1" }, { id: "n2" }, { id: "n_new" }];
|
||||||
|
const a = seedMissingNodePositions(nodes, positions());
|
||||||
|
const b = seedMissingNodePositions(nodes, positions());
|
||||||
|
assert.deepEqual(
|
||||||
|
[...a.entries()].map(([id, p]) => [id, p.x, p.y]),
|
||||||
|
[...b.entries()].map(([id, p]) => [id, p.x, p.y]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeds land on the ring around the centroid (off the known mass)", () => {
|
||||||
|
const nodes = [{ id: "n1" }, { id: "n2" }, { id: "n_new" }];
|
||||||
|
const out = seedMissingNodePositions(nodes, positions());
|
||||||
|
const p = out.get("n_new")!;
|
||||||
|
// centroid (0, 20); radius = max(200, 40, 40) * 0.7 + 20 = 160
|
||||||
|
const dist = Math.hypot(p.x - 0, p.y - 20);
|
||||||
|
assert.ok(Math.abs(dist - 160) < 1e-9, `seed on the 160-unit ring, got ${dist}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nothing missing → the map comes back untouched (early exit)", () => {
|
||||||
|
const pos = positions();
|
||||||
|
const out = seedMissingNodePositions([{ id: "n1" }, { id: "n2" }], pos);
|
||||||
|
assert.equal(out, pos);
|
||||||
|
assert.equal(out.size, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty known map → early exit (the bootstrap fallback owns that case)", () => {
|
||||||
|
const pos = new Map<string, { x: number; y: number }>();
|
||||||
|
const out = seedMissingNodePositions([{ id: "n1" }], pos);
|
||||||
|
assert.equal(out.size, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user