The region bitmap rebuilds only every REGION_EVERY-th frame while the
live sim runs, so during drags the tints stepped at 1/3 the frame
rate. Between rebuilds draw() now linearly blends the previous final
bitmap into the current one ('lighter' + globalAlpha weights = a true
lerp of the premultiplied pixels — no source-over alpha stacking).
Because box blur and the end-alpha scale are linear operators, lerping
the finished bitmaps is equivalent to fading coverage before the blur,
the mathematically exact form of the user's preferred pipeline. Two
tiny GPU blits per frame; splat/blur/readback still only on rebuilds.
New RegionTuning.enabled is a compute switch (not a paint toggle):
off skips the entire build (splat, blur, readback, blit) — the settings
panel exposes it as "Background colors". Cluster labels stay decoupled
and follow clusterLabels. Old persisted files predate the field and
sanitize to enabled: true.
230 lines
10 KiB
TypeScript
230 lines
10 KiB
TypeScript
/**
|
||
* Tests for src/core/render-settings.ts — the shared settings contract
|
||
* (shape, defaults, sanitizer, label-zoom slider mapping). Pure module,
|
||
* pure tests: whatever JSON arrives from disk or the network goes through
|
||
* sanitizeRenderSettings and must come out valid, never throw.
|
||
*/
|
||
|
||
import assert from "node:assert/strict";
|
||
import { describe, it as test } from "node:test";
|
||
import {
|
||
ARROW_LENGTH_RANGE,
|
||
CHARGE_RANGE,
|
||
CLUSTER_PULL_RANGE,
|
||
DEFAULT_RENDER_SETTINGS,
|
||
DEPTH_BASE_RADIUS_RANGE,
|
||
DEPTH_SPACING_RANGE,
|
||
DEPTH_STRENGTH_RANGE,
|
||
EDGE_WIDTH_RANGE,
|
||
GRAVITY_RANGE,
|
||
LABEL_SIZE_RANGE,
|
||
LABEL_ZOOM_MAX,
|
||
LABEL_ZOOM_MIN,
|
||
LINK_DISTANCE_RANGE,
|
||
LINK_STRENGTH_RANGE,
|
||
NODE_SIZE_RANGE,
|
||
REGION_BLUR_OPTIONS,
|
||
sanitizeRegionTuning,
|
||
REGION_DILATION_RANGE,
|
||
REGION_EDGE_ALPHA_RANGE,
|
||
VELOCITY_DECAY_RANGE,
|
||
DEFAULT_PHYSICS_TUNING,
|
||
DEFAULT_REGION_TUNING,
|
||
fromOffSliderPosition,
|
||
fromSliderPosition,
|
||
labelZoomDisplay,
|
||
sanitizeRenderSettings,
|
||
toOffSliderPosition,
|
||
toSliderPosition,
|
||
} from "../src/core/render-settings.ts";
|
||
|
||
describe("sanitizeRenderSettings", () => {
|
||
test("garbage in → full defaults out", () => {
|
||
assert.deepEqual(sanitizeRenderSettings(null), DEFAULT_RENDER_SETTINGS);
|
||
assert.deepEqual(sanitizeRenderSettings(undefined), DEFAULT_RENDER_SETTINGS);
|
||
assert.deepEqual(sanitizeRenderSettings("junk"), DEFAULT_RENDER_SETTINGS);
|
||
assert.deepEqual(sanitizeRenderSettings(42), DEFAULT_RENDER_SETTINGS);
|
||
});
|
||
|
||
test("partial input → defaults fill the gaps", () => {
|
||
const s = sanitizeRenderSettings({ nodeSize: 6 });
|
||
assert.equal(s.nodeSize, 6);
|
||
assert.equal(s.edgeWidth, DEFAULT_RENDER_SETTINGS.edgeWidth);
|
||
assert.equal(s.labelSize, DEFAULT_RENDER_SETTINGS.labelSize);
|
||
assert.equal(s.labelZoom, DEFAULT_RENDER_SETTINGS.labelZoom);
|
||
assert.equal(s.clusterLabels, DEFAULT_RENDER_SETTINGS.clusterLabels);
|
||
});
|
||
|
||
test("out-of-range values clamp; wrong types reset", () => {
|
||
const s = sanitizeRenderSettings({
|
||
nodeSize: 999,
|
||
edgeWidth: 0,
|
||
arrowLength: -5,
|
||
labelZoom: 100,
|
||
labelSize: "big" as unknown as number,
|
||
clusterLabels: "yes" as unknown as boolean,
|
||
});
|
||
assert.equal(s.nodeSize, NODE_SIZE_RANGE.max); // clamped to max
|
||
assert.equal(s.edgeWidth, EDGE_WIDTH_RANGE.min); // clamped to min
|
||
assert.equal(s.arrowLength, ARROW_LENGTH_RANGE.min);
|
||
assert.equal(s.labelZoom, LABEL_ZOOM_MAX);
|
||
assert.equal(s.labelSize, DEFAULT_RENDER_SETTINGS.labelSize); // NaN-safe reset
|
||
assert.equal(s.clusterLabels, DEFAULT_RENDER_SETTINGS.clusterLabels);
|
||
});
|
||
|
||
test("the low ends accept small sizes (log-slider floors)", () => {
|
||
const s = sanitizeRenderSettings({ nodeSize: 0.5, edgeWidth: 0.25, arrowLength: 0.5, labelSize: 0.5 });
|
||
assert.deepEqual(
|
||
{ nodeSize: s.nodeSize, edgeWidth: s.edgeWidth, arrowLength: s.arrowLength, labelSize: s.labelSize },
|
||
{ nodeSize: 0.5, edgeWidth: 0.25, arrowLength: 0.5, labelSize: 0.5 },
|
||
);
|
||
});
|
||
|
||
test("a fully valid object passes through unchanged", () => {
|
||
const valid = {
|
||
nodeSize: 4,
|
||
edgeWidth: 2,
|
||
arrowLength: 3,
|
||
labelZoom: 0.25,
|
||
labelSize: 4,
|
||
clusterLabels: false,
|
||
regions: DEFAULT_REGION_TUNING,
|
||
physics: DEFAULT_PHYSICS_TUNING,
|
||
};
|
||
assert.deepEqual(sanitizeRenderSettings(valid), valid);
|
||
});
|
||
|
||
test("the pre-2026-09-13 stored shape (flat, no regions/physics) still loads", () => {
|
||
// data/render-settings.json on disk predates the nested sections —
|
||
// those loads must get defaults, not crash, not shift anything.
|
||
const s = sanitizeRenderSettings({ nodeSize: 4, edgeWidth: 1, arrowLength: 5, labelZoom: 0.5, clusterLabels: true });
|
||
assert.deepEqual(s.regions, DEFAULT_REGION_TUNING);
|
||
assert.deepEqual(s.physics, DEFAULT_PHYSICS_TUNING);
|
||
assert.equal(s.nodeSize, 4);
|
||
});
|
||
|
||
test("nested sections sanitize: garbage clamps inside them without touching the rest", () => {
|
||
const s = sanitizeRenderSettings({
|
||
nodeSize: 3,
|
||
regions: { blur: -5, bitmap: 999, edgeAlpha: 9 },
|
||
physics: { linkStrength: 99, gravity: 7, depthMode: "sideways" },
|
||
});
|
||
assert.equal(s.nodeSize, 3);
|
||
assert.equal(s.regions.blur, 2); // snaps into the fixed set {2,4,6,8,10}
|
||
assert.equal(s.regions.bitmap, 512); // snaps to the nearest fixed option
|
||
assert.equal(s.regions.edgeAlpha, 1);
|
||
assert.equal(s.regions.dilation, DEFAULT_REGION_TUNING.dilation);
|
||
assert.equal(s.physics.linkStrength, 4); // sweep-validated cap
|
||
assert.equal(s.physics.gravity, 0.48);
|
||
assert.equal(s.physics.depthMode, "off"); // unknown mode → off
|
||
assert.equal(s.physics.chargeStrength, DEFAULT_PHYSICS_TUNING.chargeStrength);
|
||
});
|
||
|
||
test("the pre-wave stored shape (no labelSize) still loads", () => {
|
||
// localStorage-era records lacked labelSize — must not shift anything.
|
||
const s = sanitizeRenderSettings({ nodeSize: 4, edgeWidth: 1, arrowLength: 5, labelZoom: 0.5, clusterLabels: true });
|
||
assert.equal(s.labelSize, DEFAULT_RENDER_SETTINGS.labelSize);
|
||
assert.equal(s.nodeSize, 4);
|
||
});
|
||
});
|
||
|
||
describe("slider mapping (log scale, default-centered)", () => {
|
||
const RANGES: [string, number, number, number][] = [
|
||
["nodeSize", NODE_SIZE_RANGE.min, NODE_SIZE_RANGE.max, DEFAULT_RENDER_SETTINGS.nodeSize],
|
||
["edgeWidth", EDGE_WIDTH_RANGE.min, EDGE_WIDTH_RANGE.max, DEFAULT_RENDER_SETTINGS.edgeWidth],
|
||
["arrowLength", ARROW_LENGTH_RANGE.min, ARROW_LENGTH_RANGE.max, DEFAULT_RENDER_SETTINGS.arrowLength],
|
||
["labelSize", LABEL_SIZE_RANGE.min, LABEL_SIZE_RANGE.max, DEFAULT_RENDER_SETTINGS.labelSize],
|
||
["labelZoom", LABEL_ZOOM_MIN, LABEL_ZOOM_MAX, DEFAULT_RENDER_SETTINGS.labelZoom],
|
||
];
|
||
|
||
test("every default sits dead-center at position 50", () => {
|
||
for (const [name, min, max, dflt] of RANGES)
|
||
assert.equal(Math.round(toSliderPosition(dflt, min, max)), 50, name);
|
||
});
|
||
|
||
test("round-trips both directions across the whole span", () => {
|
||
for (const [name, min, max] of RANGES) {
|
||
for (let p = 0; p <= 100; p += 5) {
|
||
const v = fromSliderPosition(p, min, max);
|
||
assert.ok(Math.abs(toSliderPosition(v, min, max) - p) < 1e-9, `${name} @ ${p}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
test("slider extremes map to the range ends and clamp beyond", () => {
|
||
for (const [name, min, max] of RANGES) {
|
||
assert.equal(fromSliderPosition(0, min, max), min, name);
|
||
assert.ok(Math.abs(fromSliderPosition(100, min, max) - max) < 1e-9, name); // exp/log float noise
|
||
assert.equal(toSliderPosition(min * 0.1, min, max), 0, name); // clamps
|
||
assert.equal(toSliderPosition(max * 10, min, max), 100, name);
|
||
}
|
||
});
|
||
|
||
test("display units normalize the default to 1", () => {
|
||
assert.equal(labelZoomDisplay(0.5), "1×");
|
||
assert.equal(labelZoomDisplay(0.25), "0.5×");
|
||
assert.equal(labelZoomDisplay(4), "8×");
|
||
assert.equal(labelZoomDisplay(0.0625), "0.13×"); // rounded to 2 decimals
|
||
});
|
||
|
||
test("every region/physics default sits dead-center at position 50", () => {
|
||
// The same law for the 2026-09-13 sliders: default = geometric mean
|
||
// of the range ends (repulsion maps |charge|; friction tolerates
|
||
// the sub-0.5° drift).
|
||
const pairs: [number, number, number][] = [
|
||
[REGION_DILATION_RANGE.min, REGION_DILATION_RANGE.max, DEFAULT_REGION_TUNING.dilation],
|
||
[REGION_EDGE_ALPHA_RANGE.min, REGION_EDGE_ALPHA_RANGE.max, DEFAULT_REGION_TUNING.edgeAlpha],
|
||
// |charge| in [30, 480] (the range stores negative ends).
|
||
[-CHARGE_RANGE.max, -CHARGE_RANGE.min, -DEFAULT_PHYSICS_TUNING.chargeStrength],
|
||
[LINK_STRENGTH_RANGE.min, LINK_STRENGTH_RANGE.max, DEFAULT_PHYSICS_TUNING.linkStrength],
|
||
[LINK_DISTANCE_RANGE.min, LINK_DISTANCE_RANGE.max, DEFAULT_PHYSICS_TUNING.linkDistance],
|
||
[GRAVITY_RANGE.min, GRAVITY_RANGE.max, DEFAULT_PHYSICS_TUNING.gravity],
|
||
[VELOCITY_DECAY_RANGE.min, VELOCITY_DECAY_RANGE.max, DEFAULT_PHYSICS_TUNING.velocityDecay],
|
||
[DEPTH_STRENGTH_RANGE.min, DEPTH_STRENGTH_RANGE.max, DEFAULT_PHYSICS_TUNING.depthStrength],
|
||
[DEPTH_SPACING_RANGE.min, DEPTH_SPACING_RANGE.max, DEFAULT_PHYSICS_TUNING.depthSpacing],
|
||
[DEPTH_BASE_RADIUS_RANGE.min, DEPTH_BASE_RADIUS_RANGE.max, DEFAULT_PHYSICS_TUNING.depthBaseRadius],
|
||
];
|
||
for (const [min, max, d] of pairs) {
|
||
assert.ok(Math.abs(toSliderPosition(d, min, max) - 50) < 0.6, `default ${d} in [${min}, ${max}]`);
|
||
}
|
||
// Off-zone fields: the default sits at 50 INSIDE the on-part.
|
||
assert.ok(Math.abs(toOffSliderPosition(DEFAULT_PHYSICS_TUNING.clusterPull, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max) - 50) < 0.6);
|
||
});
|
||
|
||
test("off-slider mapping: position 0 ↔ value 0; the log part round-trips", () => {
|
||
assert.equal(toOffSliderPosition(0, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max), 0);
|
||
assert.equal(fromOffSliderPosition(0, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max), 0);
|
||
const v = fromOffSliderPosition(toOffSliderPosition(0.15, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max), CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max);
|
||
assert.ok(Math.abs(v - 0.15) < 1e-9);
|
||
// One step in (position 5) sits just above the log floor:
|
||
// min * (max/min)^(5/100), NOT the floor itself (that's position 0,
|
||
// the off zone).
|
||
const stepIn = CLUSTER_PULL_RANGE.min * (CLUSTER_PULL_RANGE.max / CLUSTER_PULL_RANGE.min) ** (5 / 100);
|
||
assert.ok(Math.abs(fromOffSliderPosition(5, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max) - stepIn) < 1e-9);
|
||
});
|
||
|
||
test("blur is a fixed set: the default is 6, non-members snap to the nearest", () => {
|
||
// Non-integer radii broke the rendering, so the control became a
|
||
// dropdown — the sanitizer enforces the set for ANY input.
|
||
assert.equal(DEFAULT_REGION_TUNING.blur, 6);
|
||
assert.equal(sanitizeRegionTuning({ blur: 3.7 }).blur, 4);
|
||
assert.equal(sanitizeRegionTuning({ blur: 7.2 }).blur, 8);
|
||
assert.equal(sanitizeRegionTuning({ blur: -5 }).blur, 2);
|
||
assert.equal(sanitizeRegionTuning({ blur: 99 }).blur, 10);
|
||
// Stored blur 0 (the old off zone) snaps back into the set.
|
||
assert.equal(sanitizeRegionTuning({ blur: 0 }).blur, 2);
|
||
});
|
||
|
||
test("enabled is the compute off switch: only an explicit false turns it off", () => {
|
||
// Default on; old persisted files predate the field (missing → on);
|
||
// garbage falls back to on (fail toward the tuned look).
|
||
assert.equal(DEFAULT_REGION_TUNING.enabled, true);
|
||
assert.equal(sanitizeRegionTuning({}).enabled, true);
|
||
assert.equal(sanitizeRegionTuning({ enabled: false }).enabled, false);
|
||
assert.equal(sanitizeRegionTuning({ enabled: true }).enabled, true);
|
||
assert.equal(sanitizeRegionTuning({ enabled: "no" as unknown as boolean }).enabled, true);
|
||
// An explicit false survives even alongside garbage in other fields.
|
||
assert.equal(sanitizeRegionTuning({ enabled: false, blur: 99, bitmap: 99999 }).enabled, false);
|
||
});
|
||
});
|