2026-09-13 05:56:27 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* 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 {
|
2026-09-13 07:49:00 +03:00
|
|
|
|
ARROW_LENGTH_RANGE,
|
2026-09-13 17:06:18 +03:00
|
|
|
|
CHARGE_RANGE,
|
|
|
|
|
|
CLUSTER_PULL_RANGE,
|
2026-09-13 05:56:27 +03:00
|
|
|
|
DEFAULT_RENDER_SETTINGS,
|
2026-09-13 17:06:18 +03:00
|
|
|
|
DEPTH_BASE_RADIUS_RANGE,
|
|
|
|
|
|
DEPTH_SPACING_RANGE,
|
|
|
|
|
|
DEPTH_STRENGTH_RANGE,
|
2026-09-13 07:49:00 +03:00
|
|
|
|
EDGE_WIDTH_RANGE,
|
2026-09-13 17:06:18 +03:00
|
|
|
|
GRAVITY_RANGE,
|
2026-09-13 07:49:00 +03:00
|
|
|
|
LABEL_SIZE_RANGE,
|
2026-09-13 05:56:27 +03:00
|
|
|
|
LABEL_ZOOM_MAX,
|
|
|
|
|
|
LABEL_ZOOM_MIN,
|
2026-09-13 17:06:18 +03:00
|
|
|
|
LINK_DISTANCE_RANGE,
|
|
|
|
|
|
LINK_STRENGTH_RANGE,
|
2026-09-13 07:49:00 +03:00
|
|
|
|
NODE_SIZE_RANGE,
|
2026-09-14 03:03:04 +03:00
|
|
|
|
REGION_BLUR_OPTIONS,
|
|
|
|
|
|
sanitizeRegionTuning,
|
2026-09-13 17:06:18 +03:00
|
|
|
|
REGION_DILATION_RANGE,
|
|
|
|
|
|
REGION_EDGE_ALPHA_RANGE,
|
|
|
|
|
|
VELOCITY_DECAY_RANGE,
|
|
|
|
|
|
DEFAULT_PHYSICS_TUNING,
|
|
|
|
|
|
DEFAULT_REGION_TUNING,
|
|
|
|
|
|
fromOffSliderPosition,
|
2026-09-13 07:49:00 +03:00
|
|
|
|
fromSliderPosition,
|
2026-09-13 05:56:27 +03:00
|
|
|
|
labelZoomDisplay,
|
|
|
|
|
|
sanitizeRenderSettings,
|
2026-09-13 17:06:18 +03:00
|
|
|
|
toOffSliderPosition,
|
2026-09-13 07:49:00 +03:00
|
|
|
|
toSliderPosition,
|
2026-09-13 05:56:27 +03:00
|
|
|
|
} 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", () => {
|
2026-09-13 07:49:00 +03:00
|
|
|
|
const s = sanitizeRenderSettings({ nodeSize: 6 });
|
|
|
|
|
|
assert.equal(s.nodeSize, 6);
|
2026-09-13 05:56:27 +03:00
|
|
|
|
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,
|
|
|
|
|
|
});
|
2026-09-13 07:49:00 +03:00
|
|
|
|
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);
|
2026-09-13 05:56:27 +03:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-13 07:49:00 +03:00
|
|
|
|
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 });
|
2026-09-13 07:26:22 +03:00
|
|
|
|
assert.deepEqual(
|
|
|
|
|
|
{ nodeSize: s.nodeSize, edgeWidth: s.edgeWidth, arrowLength: s.arrowLength, labelSize: s.labelSize },
|
2026-09-13 07:49:00 +03:00
|
|
|
|
{ nodeSize: 0.5, edgeWidth: 0.25, arrowLength: 0.5, labelSize: 0.5 },
|
2026-09-13 07:26:22 +03:00
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-13 05:56:27 +03:00
|
|
|
|
test("a fully valid object passes through unchanged", () => {
|
2026-09-13 17:06:18 +03:00
|
|
|
|
const valid = {
|
|
|
|
|
|
nodeSize: 4,
|
|
|
|
|
|
edgeWidth: 2,
|
|
|
|
|
|
arrowLength: 3,
|
|
|
|
|
|
labelZoom: 0.25,
|
|
|
|
|
|
labelSize: 4,
|
|
|
|
|
|
clusterLabels: false,
|
|
|
|
|
|
regions: DEFAULT_REGION_TUNING,
|
|
|
|
|
|
physics: DEFAULT_PHYSICS_TUNING,
|
|
|
|
|
|
};
|
2026-09-13 05:56:27 +03:00
|
|
|
|
assert.deepEqual(sanitizeRenderSettings(valid), valid);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-13 17:06:18 +03:00
|
|
|
|
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);
|
2026-09-14 03:03:04 +03:00
|
|
|
|
assert.equal(s.regions.blur, 2); // snaps into the fixed set {2,4,6,8,10}
|
2026-09-13 17:06:18 +03:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-13 05:56:27 +03:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-13 07:49:00 +03:00
|
|
|
|
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);
|
2026-09-13 05:56:27 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-13 07:49:00 +03:00
|
|
|
|
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}`);
|
|
|
|
|
|
}
|
2026-09-13 05:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-13 07:49:00 +03:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-09-13 05:56:27 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
});
|
2026-09-13 17:06:18 +03:00
|
|
|
|
|
|
|
|
|
|
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", () => {
|
2026-09-14 03:03:04 +03:00
|
|
|
|
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);
|
2026-09-13 17:06:18 +03:00
|
|
|
|
// 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).
|
2026-09-14 03:03:04 +03:00
|
|
|
|
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);
|
2026-09-13 17:06:18 +03:00
|
|
|
|
});
|
2026-09-13 05:56:27 +03:00
|
|
|
|
});
|