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:
@@ -12,6 +12,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it, it as test } from "node:test";
|
||||
import {
|
||||
applyClusterLabels,
|
||||
arrowGeometry,
|
||||
boxBlurRound,
|
||||
fitTransform,
|
||||
@@ -314,3 +315,81 @@ describe("wrapRegionLabel (wave 7: session names / file paths)", () => {
|
||||
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