Files
learnharness/tests/graph-canvas.test.ts
T
amosblomqvist e8fa9403ca follows glyph: two complete offset arrows — no convergence, no head merge
Owner rejected the first cut: two tails converging on one shared head
reads as two arrows feeding a single point, not as parallel strokes.

- followsEdgeGeometry now returns two COMPLETE regular arrows (own shaft
  + own head each), translated ±half-gap along the edge normal —
  parallel shafts AND parallel heads, nothing converges. Offset shafts
  never clip the source disc; offset tips land a hair outside the target
  disc edge (accepted price of parallel heads).
- Gap floors: screen-anchored 3x edgeWidth (dominates at fit/zoomed-out,
  unchanged look) OR head-anchored 1.2x arrowLen (world) — without the
  head floor, the world-scaled heads merge into one blob at deep zoom
  (measured: one 10px band at 5x). max() keeps >= 0.4 arrowLen of clear
  space between the heads past the ~1.5x crossover.
- Vision doc revised (2026-09-15) to record the rejection.

Live-verified at 5x fit zoom: two shafts (11.5px gap = widened floor),
two separate ~8px-base heads with clear space between. At fit: 3px gap,
two heads, unchanged. Gates: tsc, 435/435 tests, lint, build.
2026-09-15 08:20:04 +03:00

290 lines
12 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.
/**
* Pure-helper tests for the hand-rolled canvas renderer
* (components/graph-canvas.ts). The renderer class itself is DOM-bound
* (canvas, rAF, ResizeObserver) and is verified live in the browser; the
* math it runs on every frame must be exact, so it is pinned here.
*
* The camera-ratio convention is the load-bearing contract with the
* persisted settings: sigma's ratio (1 = fit, SMALLER = zoomed in) is kept
* verbatim so stored labelZoom values survive the renderer swap.
*/
import assert from "node:assert/strict";
import { describe, it as test } from "node:test";
import {
arrowGeometry,
followsEdgeGeometry,
boxBlurRound,
fitTransform,
labelAlpha,
pickNearest,
zoomRatioOf,
LABELS_FADE_SPAN,
} from "../components/graph-canvas.ts";
import {
sanitizePhysicsTuning,
sanitizeRegionTuning,
DEFAULT_PHYSICS_TUNING,
DEFAULT_REGION_TUNING,
REGION_BITMAP_OPTIONS,
} from "../src/core/render-settings.ts";
describe("zoomRatioOf (the sigma-compatible camera ratio)", () => {
test("1 at fit zoom, smaller when zoomed in, larger when zoomed out", () => {
assert.equal(zoomRatioOf(2, 2), 1); // at fit
assert.equal(zoomRatioOf(2, 4), 0.5); // zoomed in 2x -> ratio halves
assert.equal(zoomRatioOf(2, 1), 2); // zoomed out 2x -> ratio doubles
});
});
describe("labelAlpha (the single zoom gate)", () => {
test("fully visible at/below the threshold, faded out after the span", () => {
assert.equal(labelAlpha(0.5, 0.5), 1); // at the threshold
assert.equal(labelAlpha(0.25, 0.5), 1); // zoomed further in: stays 1
assert.equal(labelAlpha(0.75, 0.5), 0.5); // mid-fade
assert.equal(labelAlpha(1.0, 0.5), 0); // one span out: gone
assert.equal(labelAlpha(2.0, 0.5), 0); // far out: clamped
});
test("node labels and cluster labels cross-fade over the exact same span", () => {
// The renderer draws cluster labels at 1 - labelAlpha; the two must
// always sum to 1 within the fade and never coexist at full opacity.
for (const ratio of [0, 0.25, 0.5, 0.6, 0.75, 0.9, 1, 2, 4]) {
const node = labelAlpha(ratio, 0.5);
const cluster = 1 - node;
assert.ok(node + cluster > 0.999 && node + cluster < 1.001, `sum at ratio ${ratio}`);
assert.ok(node <= 1 && cluster <= 1, `clamped at ratio ${ratio}`);
}
});
test("the span constant is unchanged from the sigma era (stored settings depend on it)", () => {
assert.equal(LABELS_FADE_SPAN, 0.5);
});
});
describe("fitTransform", () => {
test("centers the bbox and respects padding", () => {
// A 100x50 graph in a 500x300 viewport, 40px padding:
// available 420x220 -> k = min(4.2, 4.4) = 4.2
const t = fitTransform({ minX: -50, maxX: 50, minY: -25, maxY: 25 }, 500, 300, 40);
assert.ok(Math.abs(t.k - 4.2) < 1e-9, `k=${t.k}`);
assert.ok(Math.abs(t.x - 250) < 1e-9, `x=${t.x}`); // centered
assert.ok(Math.abs(t.y - 150) < 1e-9, `y=${t.y}`);
// World center maps to viewport center:
assert.ok(Math.abs(0 * t.k + t.x - 250) < 1e-9);
});
test("never returns a non-positive scale for degenerate bboxes", () => {
const t = fitTransform({ minX: 5, maxX: 5, minY: 5, maxY: 5 }, 100, 100, 10);
assert.ok(t.k > 0 && Number.isFinite(t.k));
});
});
describe("arrowGeometry", () => {
test("the tip lands exactly on the target disc edge", () => {
const g = arrowGeometry({ x: 0, y: 0 }, { x: 100, y: 0 }, 6, 6, 5);
assert.ok(g, "non-degenerate edge");
assert.ok(Math.abs(g!.tip.x - 94) < 1e-9 && Math.abs(g!.tip.y) < 1e-9);
});
test("the shaft starts outside the source disc", () => {
const g = arrowGeometry({ x: 0, y: 0 }, { x: 100, y: 0 }, 6, 6, 5)!;
assert.ok(Math.abs(g.shaftFrom.x - 6) < 1e-9 && Math.abs(g.shaftFrom.y) < 1e-9);
// Shaft ends at the head base; head is drawn from base to tip.
assert.ok(Math.abs(g.shaftTo.x - 89) < 1e-9);
});
test("the head triangle is symmetric around the shaft axis (0.8x wideness)", () => {
const g = arrowGeometry({ x: 0, y: 0 }, { x: 100, y: 0 }, 6, 6, 5)!;
// halfW = 0.4 x headLength = 2; both base corners share x, mirrored y.
assert.ok(Math.abs(Math.abs(g.left.y) - 2) < 1e-9 && Math.abs(Math.abs(g.right.y) - 2) < 1e-9);
assert.ok(Math.abs(g.left.y + g.right.y) < 1e-9); // mirror
assert.ok(Math.abs(g.left.x - g.right.x) < 1e-9);
});
test("works at arbitrary angles (diagonal stays normalized)", () => {
const a = { x: 0, y: 0 };
const b = { x: 60, y: 80 }; // len 100, dir (0.6, 0.8)
const g = arrowGeometry(a, b, 10, 10, 8)!;
assert.ok(Math.abs(g.tip.x - (60 - 0.6 * 10)) < 1e-9 && Math.abs(g.tip.y - (80 - 0.8 * 10)) < 1e-9);
});
test("null for coincident endpoints (never a NaN arrow)", () => {
assert.equal(arrowGeometry({ x: 5, y: 5 }, { x: 5, y: 5 }, 6, 6, 5), null);
});
});
describe("followsEdgeGeometry (the ⟹ double-shaft glyph)", () => {
test("two COMPLETE arrows translated ±half-gap along the edge normal — nothing converges", () => {
// Horizontal edge a→b: normal is (0, 1) — offsets are vertical.
const g = followsEdgeGeometry({ x: 0, y: 0 }, { x: 100, y: 0 }, 6, 6, 5, 4)!;
assert.ok(g);
// Each arrow is the plain regular arrow, shifted whole: own shaft,
// own head. First at +2, second at −2 (half-gap 2).
assert.ok(Math.abs(g.first.shaftFrom.x - 6) < 1e-9 && Math.abs(g.first.shaftFrom.y - 2) < 1e-9);
assert.ok(Math.abs(g.second.shaftFrom.x - 6) < 1e-9 && Math.abs(g.second.shaftFrom.y + 2) < 1e-9);
// Shafts are PARALLEL: same span, both endpoints offset equally.
assert.ok(Math.abs(g.first.shaftTo.y - 2) < 1e-9 && Math.abs(g.second.shaftTo.y + 2) < 1e-9);
assert.ok(Math.abs(g.first.shaftTo.x - g.second.shaftTo.x) < 1e-9);
});
test("each arrow keeps its OWN head, offset with it (no shared head)", () => {
const a = { x: 0, y: 0 };
const b = { x: 100, y: 0 };
const plain = arrowGeometry(a, b, 6, 6, 5)!;
const g = followsEdgeGeometry(a, b, 6, 6, 5, 4)!;
// First arrow's head = plain head + (0, +2); second = −(0, +2).
assert.deepEqual(g.first.tip, { x: plain.tip.x, y: plain.tip.y + 2 });
assert.deepEqual(g.second.tip, { x: plain.tip.x, y: plain.tip.y - 2 });
assert.deepEqual(g.first.left, { x: plain.left.x, y: plain.left.y + 2 });
assert.deepEqual(g.second.right, { x: plain.right.x, y: plain.right.y - 2 });
// The two heads never meet: tips stay a full gap apart.
assert.ok(Math.abs(g.first.tip.y - g.second.tip.y - 4) < 1e-9);
});
test("the offset shafts never clip the source disc (farther than the axis start)", () => {
const a = { x: 0, y: 0 };
const g = followsEdgeGeometry(a, { x: 100, y: 0 }, 6, 6, 5, 4)!;
for (const arrow of [g.first, g.second]) {
assert.ok(Math.hypot(arrow.shaftFrom.x - a.x, arrow.shaftFrom.y - a.y) >= 6 - 1e-9);
}
});
test("works at arbitrary angles (diagonal normal normalized)", () => {
// dir (0.6, 0.8) → normal (−0.8, 0.6); half-gap 1.5.
const g = followsEdgeGeometry({ x: 0, y: 0 }, { x: 60, y: 80 }, 10, 10, 8, 3)!;
assert.ok(g);
const ax = 0.6 * 10; // axis start
const ay = 0.8 * 10;
assert.ok(Math.abs(g.first.shaftFrom.x - (ax - 0.8 * 1.5)) < 1e-9);
assert.ok(Math.abs(g.first.shaftFrom.y - (ay + 0.6 * 1.5)) < 1e-9);
assert.ok(Math.abs(g.second.shaftFrom.x - (ax + 0.8 * 1.5)) < 1e-9);
assert.ok(Math.abs(g.second.shaftFrom.y - (ay - 0.6 * 1.5)) < 1e-9);
});
test("null for coincident endpoints (same guard as the plain arrow)", () => {
assert.equal(followsEdgeGeometry({ x: 5, y: 5 }, { x: 5, y: 5 }, 6, 6, 5, 4), null);
});
test("zero gap degenerates to the plain single arrow twice", () => {
const a = { x: 0, y: 0 };
const b = { x: 100, y: 0 };
const g = followsEdgeGeometry(a, b, 6, 6, 5, 0)!;
assert.deepEqual(g.first.shaftFrom, g.second.shaftFrom);
assert.deepEqual(g.first.tip, g.second.tip);
});
});
describe("pickNearest (distance-check picking)", () => {
const nodes = [
{ id: "a", x: 10, y: 10 },
{ id: "b", x: 50, y: 50 },
{ id: "c", x: 90, y: 10 },
];
test("picks the nearest node within the pad", () => {
assert.equal(pickNearest(nodes, 12, 12, 16)?.id, "a");
assert.equal(pickNearest(nodes, 48, 52, 16)?.id, "b");
assert.equal(pickNearest(nodes, 88, 8, 16)?.id, "c");
});
test("the picking pad extends the hitbox beyond the disc", () => {
// 20px away, only reachable because the pad covers it.
assert.equal(pickNearest(nodes, 28, 18, 25)?.id, "a");
// Beyond the pad: nothing.
assert.equal(pickNearest(nodes, 30, 40, 3), null);
});
test("ties resolve deterministically (first scanned wins)", () => {
const pair = [
{ id: "first", x: 0, y: 0 },
{ id: "second", x: 10, y: 0 },
];
assert.equal(pickNearest(pair, 5, 0, 10)?.id, "first");
});
test("empty node list picks nothing", () => {
assert.equal(pickNearest([], 0, 0, 100), null);
});
});
describe("sanitizeRegionTuning (dev number inputs)", () => {
test("defaults survive untouched; partial input keeps the rest", () => {
const t = sanitizeRegionTuning(undefined);
assert.deepEqual(t, DEFAULT_REGION_TUNING);
const half = sanitizeRegionTuning({ blur: 7.2 });
assert.equal(half.blur, 8); // snaps into the fixed set
assert.equal(half.dilation, DEFAULT_REGION_TUNING.dilation);
});
test("garbage clamps: NaN falls back, out-of-range clamps", () => {
const t = sanitizeRegionTuning({ dilation: Number.NaN, blur: -5, bitmap: 99999, edgeAlpha: 42 });
assert.equal(t.dilation, DEFAULT_REGION_TUNING.dilation); // NaN -> default
assert.equal(t.blur, 2); // snaps into the fixed set
assert.equal(t.bitmap, REGION_BITMAP_OPTIONS[REGION_BITMAP_OPTIONS.length - 1]); // 99999 snaps to the largest option
assert.equal(t.edgeAlpha, 1);
});
test("empty string / null / undefined values fall back per-field", () => {
const t = sanitizeRegionTuning({ edgeAlpha: Number.NaN });
assert.equal(t.edgeAlpha, DEFAULT_REGION_TUNING.edgeAlpha);
});
});
describe("boxBlurRound (premultiplied region blur)", () => {
test("spreads a single opaque px into its neighborhood and conserves sum", () => {
// 5x5 RGBA, one white opaque pixel at (2,2)
const w = 5, h = 5;
const a = new Float32Array(w * h * 4);
const center = (2 * w + 2) * 4;
a[center] = 255; a[center + 1] = 255; a[center + 2] = 255; a[center + 3] = 255;
const tmp = new Float32Array(a.length);
const b = new Float32Array(a.length);
boxBlurRound(a, tmp, b, w, h, 1);
// Alpha conservation: total alpha unchanged.
let sumA = 0;
for (let i = 3; i < b.length; i += 4) sumA += b[i]!;
assert.ok(Math.abs(sumA - 255) < 1e-3, `alpha sum ${sumA}`);
// The center dimmed, the 8 neighbors picked some up.
assert.ok(b[center + 3]! < 255 && b[center + 3]! > 0);
const up = ((1 * w) + 2) * 4 + 3;
assert.ok(b[up]! > 0, "neighbor got alpha");
// Far corner (4,4) untouched.
assert.equal(b[(4 * 5 + 4) * 4 + 3], 0);
});
test("radius 0 is a plain copy", () => {
const a = new Float32Array([1, 2, 3, 4, 5, 6, 7, 8]);
const tmp = new Float32Array(8);
const b = new Float32Array(8);
boxBlurRound(a, tmp, b, 1, 2, 0);
assert.deepEqual([...b], [1, 2, 3, 4, 5, 6, 7, 8]);
});
});
describe("sanitizePhysicsTuning (physics controls)", () => {
test("defaults survive untouched; partial input keeps the rest", () => {
assert.deepEqual(sanitizePhysicsTuning(undefined), DEFAULT_PHYSICS_TUNING);
const half = sanitizePhysicsTuning({ clusterPull: 0.6 });
assert.equal(half.clusterPull, 0.6);
assert.equal(half.chargeStrength, DEFAULT_PHYSICS_TUNING.chargeStrength);
});
test("garbage clamps: NaN falls back, out-of-range clamps", () => {
const t = sanitizePhysicsTuning({
chargeStrength: Number.NaN,
linkStrength: 99,
linkDistance: -5,
gravity: 7,
clusterPull: -1,
velocityDecay: 3,
});
assert.equal(t.chargeStrength, DEFAULT_PHYSICS_TUNING.chargeStrength);
assert.equal(t.linkStrength, 4); // sweep-validated cap (8 explodes, 5 wobbles)
assert.equal(t.linkDistance, 10);
assert.equal(t.gravity, 0.48);
assert.equal(t.clusterPull, 0);
assert.equal(t.velocityDecay, 0.9);
});
});