Files
learnharness/tests/render-settings.test.ts
T
amosblomqvist b42fdbec1e Region blur becomes a fixed-set dropdown (2/4/6/8/10, default 6)
Non-integer blur radii broke the rendering (the log slider mapped to
fractional values), so the control is now integer-only by design:
REGION_BLUR_OPTIONS with snap-to-nearest sanitization for any input,
same law as the bitmap resolution. The old off zone (blur 0) is gone;
stored 0s snap back into the set.
2026-09-14 03:03:04 +03:00

218 lines
9.6 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.
/**
* 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);
});
});