Files
learnharness/tests/render-settings.test.ts
T
amosblomqvist 2f4b9c82e4 Cluster labels: topmost layer + user-facing size setting
- Draw order: the cluster-label block moves from layer 5 to THE LAST
  layer of draw() — labels can never be occluded by node labels or
  hover names (user 2026-09-17; chose "topmost" after reassessing that
  discs were never actually on top — the white-out over discs is
  source-over compositing of the 85%-alpha text over near-white node
  fill, an optical contrast flip, not a draw-order bug).
- clusterLabelSize: new persisted setting (default 14 = the old
  REGION_LABEL_SIZE constant, so rendering is pixel-identical). Screen
  px at ANY zoom (drawn at size/k — camera-compensated, UI-like, same
  law as before). Log-slider range [7, 28] puts the default dead-center
  per the house slider law. Sanitized: old persisted files (no field)
  fall back to 14.
- Panel: the Cluster labels checkbox moves from the Labels section
  into Regions (user: cluster settings live with regions), joined by
  the new "Cluster label size" slider. Labels stay decoupled from the
  Background colors compute switch, as designed on 2026-09-16.
2026-09-17 09:35:11 +03:00

247 lines
11 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_LABEL_SIZE_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);
assert.equal(s.clusterLabelSize, DEFAULT_RENDER_SETTINGS.clusterLabelSize); // old files → 14
});
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,
clusterLabelSize: "huge" as unknown as number,
});
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);
assert.equal(s.clusterLabelSize, DEFAULT_RENDER_SETTINGS.clusterLabelSize); // NaN-safe reset
});
test("clusterLabelSize clamps to its own range (slider law)", () => {
// Default 14 is the geometric mean of [7, 28] → dead-center slider.
assert.equal(
Math.sqrt(CLUSTER_LABEL_SIZE_RANGE.min * CLUSTER_LABEL_SIZE_RANGE.max),
DEFAULT_RENDER_SETTINGS.clusterLabelSize,
);
const s = sanitizeRenderSettings({ clusterLabelSize: 999 });
assert.equal(s.clusterLabelSize, CLUSTER_LABEL_SIZE_RANGE.max);
const s2 = sanitizeRenderSettings({ clusterLabelSize: 1 });
assert.equal(s2.clusterLabelSize, CLUSTER_LABEL_SIZE_RANGE.min);
});
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,
clusterLabelSize: 20,
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);
});
});