Files
feedBack/plugins/keys_highway_3d/tests/fx_settings.test.js
T
LegionaryLeaderGitHubClaude Opus 4.8coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>byrongamatos
e446b05a99 feat(keys_highway_3d): key layout modes, lane-color opacity & octave lines (#803)
* feat(keys_highway_3d): sharp-layout modes, lane-color opacity, octave lines

Add a Highway layout section to the settings with a rebuilt way to draw
sharps/flats and lanes on the 3D piano highway:

- Sharps & flats layout (keys3d_bg_sharpMode): floating (original raised
  plane), flat (one plane, zero-overlap piano-shaped tiled lanes with the
  naturals evened out), and realistic (one plane, bars sized like the
  physical keys). All geometry lives in pure laneSpanFlat()/laneSpanReal()
  helpers. Default: realistic.
- Lane color opacity (keys3d_bg_laneOpacity, 0-1): fades the pitch-class
  lane tint; at 0 it is a dark floor with guide lines only at the key-block
  boundaries (E-F and each octave), toward 1 full vivid colored lanes. The
  strips, per-lane separators and block lines crossfade with the value.
  Default: 0.
- Octave separators (keys3d_bg_octaveGaps, default on) and Octave line
  contrast (keys3d_bg_octaveContrast, 0-1): the B->C octave line is a dark
  layer scaled by lane opacity plus a bright layer scaled by its inverse,
  so it auto-shifts dark->bright as the lanes fade.

Settings re-read on init() so they apply on the next chart build. All other
behavior (MIDI scoring, palettes, camera, themes, hit feedback) is unchanged.
Unit tests cover the new defaults, the sharp-mode setting, and the lane
geometry (tiling/evening for flat, uniform/overlap for realistic).

Signed-off-by: LegionaryLeader <legionaryleader@gmail.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Update plugins/keys_highway_3d/settings.html

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update plugins/keys_highway_3d/screen.js

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix(keys_highway_3d): don't trim active-range boundary lane when neighbor sharp is out of range

laneSpanFlat() trimmed a white key's edge for its neighboring black
key's lane even when that neighbor midi fell outside
range.activeLow..range.activeHigh — the neighbor's lane is never
drawn (see the activeLow/activeHigh skip around the lane-strip loop),
so the trim left a dark, unfilled sliver at the active-range boundary
with no sharp lane to fill it. Gate the trim on the neighbor being
in-range; callers that don't pass a range (e.g. the raw-tiling unit
tests) keep the prior unconditional-trim behavior.

Also add the CHANGELOG entry for this PR's feature set, following the
existing keys_highway_3d wording convention (no plugin-local
CHANGELOG exists; plugin.json was already bumped 0.1.2 -> 0.2.0 by
the original commits).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: LegionaryLeader <legionaryleader@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-07 23:57:14 +02:00

553 lines
27 KiB
JavaScript
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.
// FX-settings scaffold tests (guitar-highway parity controls). Same bare-vm
// harness as data_layer.test.js — no DOM, no localStorage — which doubles as
// a lint that the new module-scope FX code stays side-effect safe.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function load(extraWindow) {
const window = {
console,
location: { protocol: 'http:', host: 'localhost' },
slopsmith: {},
...extraWindow,
};
window.window = window;
window.globalThis = window;
const context = vm.createContext(window);
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
vm.runInContext(src, context, { filename: 'screen.js' });
return window;
}
test('readFxSettings: defaults survive a localStorage-less environment', () => {
const { readFxSettings, FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
assert.deepEqual(readFxSettings(), FX_DEFAULTS);
assert.equal(FX_DEFAULTS.bloom, true); // effects on by default
});
test('readFxSettings: reads keys3d_bg_* overrides and coerces types', () => {
const store = { keys3d_bg_bloom: '0' };
const win = load({
localStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = v; },
},
});
const { readFxSettings } = win.slopsmithViz_keys_highway_3d.__test;
assert.equal(readFxSettings().bloom, false);
store.keys3d_bg_bloom = 'true';
assert.equal(readFxSettings().bloom, true);
store.keys3d_bg_bloom = 'false';
assert.equal(readFxSettings().bloom, false);
// Corrupt/foreign value → keep the default rather than silently
// disabling the effect.
store.keys3d_bg_bloom = 'banana';
assert.equal(readFxSettings().bloom, true);
});
test('keys3dSetFx: persists, coerces, and ignores unknown keys', () => {
const store = {};
const events = [];
const win = load({
localStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = v; },
},
dispatchEvent: (ev) => { events.push(ev); return true; },
CustomEvent: class CustomEvent {
constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; }
},
});
win.keys3dSetFx('bloom', false);
assert.equal(store.keys3d_bg_bloom, '0');
// String forms round-trip like the reader's accepted representations.
win.keys3dSetFx('bloom', 'false');
assert.equal(store.keys3d_bg_bloom, '0');
win.keys3dSetFx('bloom', 'true');
assert.equal(store.keys3d_bg_bloom, '1');
win.keys3dSetFx('bloom', false);
assert.equal(events.length, 4);
assert.equal(events[0].type, 'keys3d:settings');
// Field-wise (the detail object was built inside the vm realm, so a
// deep-strict compare would trip on its foreign Object.prototype).
assert.equal(events[0].detail.fx.bloom, false);
assert.deepEqual(Object.keys(events[0].detail.fx), ['bloom']);
// Unknown key: no write, no event.
win.keys3dSetFx('nonsense', 1);
assert.equal(events.length, 4);
assert.ok(!('keys3d_bg_nonsense' in store));
});
test('_classifyTiming: OK band is 40% of the window, sign maps early/late', () => {
const { _classifyTiming } = load().slopsmithViz_keys_highway_3d.__test;
const tol = 0.10; // keys HIT_TOLERANCE_S
assert.equal(_classifyTiming(0, tol), 'OK');
assert.equal(_classifyTiming(tol * 0.4, tol), 'OK');
assert.equal(_classifyTiming(-tol * 0.4, tol), 'OK');
// delta = note.t - now: positive → struck before the note → EARLY.
assert.equal(_classifyTiming(tol * 0.41, tol), 'EARLY');
assert.equal(_classifyTiming(-tol * 0.41, tol), 'LATE');
assert.equal(_classifyTiming(NaN, tol), 'OK');
});
test('noteKey prefix round-trips the matched note time (timing-delta source)', () => {
const { noteKey } = load().slopsmithViz_keys_highway_3d.__test;
// _checkHit derives the timing delta as parseFloat(judgeHit's key) - t;
// this pins the serialization that makes that recovery valid.
assert.equal(parseFloat(noteKey(12.3456, 60)), 12.346);
assert.equal(parseFloat(noteKey(0, 21)), 0);
});
test('FX defaults: hit-FX + vibrancy controls ship enabled', () => {
const { FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
assert.equal(FX_DEFAULTS.sparks, true);
assert.equal(FX_DEFAULTS.timingFx, true);
assert.equal(FX_DEFAULTS.streakFx, true);
assert.equal(FX_DEFAULTS.hitFx, 0.7);
assert.equal(FX_DEFAULTS.vibrancy, 0.85);
});
test('themes: table ids match the guitar highway, default is the stock palette', () => {
const { BG_THEMES, _bgThemeColors, readThemeSetting } = load().slopsmithViz_keys_highway_3d.__test;
assert.deepEqual(Object.keys(BG_THEMES), [
'default', 'midnight', 'charcoal', 'deeppurple', 'forest', 'warmslate',
'deepfocus', 'deepsea', 'cathode', 'cathodegreen', 'hearth',
]);
// 'default' preserves THIS plugin's original look.
assert.equal(BG_THEMES.default.clear, 0x1a1a2e);
assert.equal(BG_THEMES.default.board, 0x141422);
assert.equal(BG_THEMES.default.laneDim, 0x2a2a3e);
assert.equal(_bgThemeColors('nonsense'), BG_THEMES.default);
assert.equal(readThemeSetting(), 'default'); // no localStorage in the vm
for (const [id, t] of Object.entries(BG_THEMES)) {
assert.equal(t.clear, t.fog, id + ' clear==fog (horizon dissolve)');
assert.ok(t.laneDim != null, id + ' rail color');
}
});
test('FX defaults: theme-PR controls ship enabled at stock-neutral values', () => {
const { FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
assert.equal(FX_DEFAULTS.cinematic, true);
assert.equal(FX_DEFAULTS.glow, 0.5); // 0.5 = 1.0x multiplier (stock)
});
test('bg styles: validated id set, particles default', () => {
const { BG_STYLE_IDS, readBgStyleSetting } = load().slopsmithViz_keys_highway_3d.__test;
// Host-realm copy — the vm array's foreign prototype trips deepEqual.
assert.deepEqual([...BG_STYLE_IDS], ['off', 'particles', 'lights', 'geometric']);
assert.equal(readBgStyleSetting(), 'particles');
});
test('FX defaults: ambience + score FX ship enabled', () => {
const { FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
assert.equal(FX_DEFAULTS.scoreFx, true);
assert.equal(FX_DEFAULTS.bgIntensity, 0.5);
assert.equal(FX_DEFAULTS.bgReactive, true);
});
/* ── Note-colour palettes (feat/keys3d-note-palettes) ────────────────── */
test('note palettes: 12 entries each, classic IS the stock table', () => {
const { NOTE_PALETTES, PITCH_CLASS_COLORS } =
load().slopsmithViz_keys_highway_3d.__test;
assert.deepEqual(Object.keys(NOTE_PALETTES),
['classic', 'emerald', 'vivid', 'pastel', 'ice']);
for (const [id, colors] of Object.entries(NOTE_PALETTES)) {
assert.equal(colors.length, 12, id + ' has one colour per pitch class');
for (const c of colors) {
assert.ok(Number.isInteger(c) && c >= 0 && c <= 0xffffff,
id + ' colours are 24-bit ints');
}
}
// 'classic' preserves the shipped look byte-identically — it is the
// same array, not a copy that could drift.
assert.equal(NOTE_PALETTES.classic, PITCH_CLASS_COLORS);
assert.equal(PITCH_CLASS_COLORS[0], 0xff3030); // C stays red in classic
});
test('note palettes: two-tone tables use darker sharps than naturals', () => {
const { NOTE_PALETTES } = load().slopsmithViz_keys_highway_3d.__test;
const luma = (c) =>
0.2126 * ((c >> 16) & 0xff) + 0.7152 * ((c >> 8) & 0xff) + 0.0722 * (c & 0xff);
for (const id of ['emerald', 'ice']) {
const p = NOTE_PALETTES[id];
for (const sharp of [1, 3, 6, 8, 10]) {
assert.ok(luma(p[sharp]) < luma(p[0]),
id + ' sharp pc ' + sharp + ' darker than naturals');
}
}
});
test('readPaletteSetting: octaves default, validated overrides only', () => {
// No localStorage in the vm → the plug-and-play default.
const bare = load().slopsmithViz_keys_highway_3d.__test;
assert.equal(bare.readPaletteSetting(), 'octaves');
// An explicit non-default value (classic) overrides.
const store = { keys3d_bg_palette: 'classic' };
const win = load({
localStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = v; },
},
});
const { readPaletteSetting } = win.slopsmithViz_keys_highway_3d.__test;
assert.equal(readPaletteSetting(), 'classic');
// Corrupt/foreign value → the default rather than an undefined scheme.
store.keys3d_bg_palette = 'banana';
assert.equal(readPaletteSetting(), 'octaves');
});
test('keys3dSetPalette: persists + dispatches valid ids, ignores unknown', () => {
const store = {};
const events = [];
const win = load({
localStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = v; },
},
dispatchEvent: (ev) => { events.push(ev); return true; },
CustomEvent: class CustomEvent {
constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; }
},
});
win.keys3dSetPalette('emerald');
assert.equal(store.keys3d_bg_palette, 'emerald');
assert.equal(events.length, 1);
assert.equal(events[0].type, 'keys3d:settings');
assert.equal(events[0].detail.palette, 'emerald');
// Unknown id: no write, no event.
win.keys3dSetPalette('banana');
assert.equal(store.keys3d_bg_palette, 'emerald');
assert.equal(events.length, 1);
// 'octaves' (procedural, not a 12-array) is a valid selectable id.
win.keys3dSetPalette('octaves');
assert.equal(store.keys3d_bg_palette, 'octaves');
assert.equal(events.length, 2);
});
test('PALETTE_IDS: the array palettes plus the procedural octaves scheme', () => {
const { PALETTE_IDS, NOTE_PALETTES } = load().slopsmithViz_keys_highway_3d.__test;
assert.deepEqual([...PALETTE_IDS],
[...Object.keys(NOTE_PALETTES), 'octaves']);
assert.ok(PALETTE_IDS.indexOf('octaves') !== -1);
assert.ok(!('octaves' in NOTE_PALETTES)); // it is NOT a 12-entry table
});
test('octaveNoteColor: hue steps per octave, loops, sharps darker, sub-C1 distinct', () => {
const { octaveNoteColor, OCTAVE_HUES } = load().slopsmithViz_keys_highway_3d.__test;
const luma = (c) =>
0.2126 * ((c >> 16) & 0xff) + 0.7152 * ((c >> 8) & 0xff) + 0.0722 * (c & 0xff);
// C1 (midi 24) = first hue; C2 (36) = second; C8 (108) = 8th (index 7).
assert.equal(octaveNoteColor(24), OCTAVE_HUES[0]); // C1 red
assert.equal(octaveNoteColor(35), OCTAVE_HUES[0]); // B1 still octave 1
assert.equal(octaveNoteColor(36), OCTAVE_HUES[1]); // C2 orange
assert.equal(octaveNoteColor(60), OCTAVE_HUES[3]); // C4 (middle C)
assert.equal(octaveNoteColor(108), OCTAVE_HUES[7]); // C8 last hue
// Naturals across one octave (C1..B1 whites) all share the octave hue.
for (const nat of [24, 26, 28, 29, 31, 33, 35]) {
assert.equal(octaveNoteColor(nat), OCTAVE_HUES[0], 'natural ' + nat);
}
// Sharps in an octave are a DARKER shade of that same hue.
for (const sharp of [25, 27, 30, 32, 34]) { // C#1..A#1
assert.ok(luma(octaveNoteColor(sharp)) < luma(OCTAVE_HUES[0]),
'sharp ' + sharp + ' darker than the octave natural');
}
// The three keys below C1 (A0/A#0/B0) share a distinct sub-C1 colour,
// different from the red octave-1 start.
assert.equal(octaveNoteColor(21), octaveNoteColor(23)); // A0 == B0 hue
assert.notEqual(octaveNoteColor(21), OCTAVE_HUES[0]);
// Loop: an octave past the table wraps (safety for out-of-88 midi).
assert.equal(octaveNoteColor(24 + 12 * OCTAVE_HUES.length), OCTAVE_HUES[0]);
});
/* ── Camera presets + fine-tune (feat/keys3d-camera) ─────────────────── */
test('FX defaults: camera height/distance/tilt all neutral (preset carries the tuned aim)', () => {
const { FX_DEFAULTS, FX_RANGES } = load().slopsmithViz_keys_highway_3d.__test;
assert.equal(FX_DEFAULTS.camHeight, 1.0);
assert.equal(FX_DEFAULTS.camDist, 1.0);
// Tilt ships NEUTRAL (0): the tuned plug-and-play aim now lives in
// CAM_PRESETS.overhead.lookY, so the fine-tune only nudges from a preset
// and 'classic' + this default reproduces the exact historical rig.
assert.equal(FX_DEFAULTS.camTilt, 0.0);
assert.ok(FX_DEFAULTS.camTilt >= FX_RANGES.camTilt[0] && FX_DEFAULTS.camTilt <= FX_RANGES.camTilt[1]);
// Height/distance bracket 1 (can go lower AND higher); tilt spans 0.
assert.ok(FX_RANGES.camHeight[0] < 1 && 1 < FX_RANGES.camHeight[1]);
assert.ok(FX_RANGES.camDist[0] < 1 && 1 < FX_RANGES.camDist[1]);
assert.ok(FX_RANGES.camTilt[0] < 0 && 0 < FX_RANGES.camTilt[1]);
});
test('camTilt: negative values survive the clamp (down-tilt must be reachable)', () => {
const store = {};
const win = load({
localStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = v; },
},
dispatchEvent: () => true,
CustomEvent: class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } },
});
const { FX_RANGES } = win.slopsmithViz_keys_highway_3d.__test;
win.keys3dSetFx('camTilt', -0.5);
assert.equal(store.keys3d_bg_camTilt, '-0.5'); // NOT crushed to 0 by a 0-1 clamp
win.keys3dSetFx('camTilt', -99);
assert.equal(parseFloat(store.keys3d_bg_camTilt), FX_RANGES.camTilt[0]);
});
test('FX ranges: reader + setter clamp to the declared range, not 0-1', () => {
const store = { keys3d_bg_camHeight: '5', keys3d_bg_camDist: '0.01' };
const events = [];
const win = load({
localStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = v; },
},
dispatchEvent: (ev) => { events.push(ev); return true; },
CustomEvent: class CustomEvent {
constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; }
},
});
const { readFxSettings, FX_RANGES } = win.slopsmithViz_keys_highway_3d.__test;
// Reader: corrupt/out-of-range writes clamp to the declared bounds.
assert.equal(readFxSettings().camHeight, FX_RANGES.camHeight[1]);
assert.equal(readFxSettings().camDist, FX_RANGES.camDist[0]);
// Setter: same clamp on the way in; a value above 1 must survive
// (the historical 0-1 clamp would have crushed 1.3 to 1).
win.keys3dSetFx('camHeight', 1.3);
assert.equal(store.keys3d_bg_camHeight, '1.3');
win.keys3dSetFx('camDist', 99);
assert.equal(parseFloat(store.keys3d_bg_camDist), FX_RANGES.camDist[1]);
// Un-ranged keys keep the historical 0-1 clamp.
win.keys3dSetFx('vibrancy', 2);
assert.equal(store.keys3d_bg_vibrancy, '1');
});
test('scrollZ: distance-to-hitline scales linearly with the speed argument', () => {
const { scrollZ } = load().slopsmithViz_keys_highway_3d.__test;
const hitZ = 0;
const d1 = scrollZ(2, 0, hitZ, 130) - hitZ; // 2s ahead at stock speed
const d2 = scrollZ(2, 0, hitZ, 260) - hitZ; // same note at 2x speed
assert.equal(d2, d1 * 2);
// At the hit moment the note is at the hit-line regardless of speed.
assert.equal(scrollZ(5, 5, hitZ, 130), hitZ);
assert.equal(scrollZ(5, 5, hitZ, 260), hitZ);
});
test('camera presets: classic preserves the stock rig, overhead is the default', () => {
const { CAM_PRESETS, readCameraSetting } = load().slopsmithViz_keys_highway_3d.__test;
assert.deepEqual(Object.keys(CAM_PRESETS), ['classic', 'elevated', 'overhead']);
// 'classic' preserves the historical constants (pre-K units) even though
// it is no longer the default — anyone who picks it gets the old rig back
// EXACTLY, because camTilt now defaults to 0 (neutral): effective aim =
// classic.lookY + 0*CAM_TILT_UNITS = 8, the historical LOOK_Y.
assert.deepEqual({ ...CAM_PRESETS.classic },
{ fov: 40, y: 46, z: 112, lookY: 8, lookZ: -165 });
for (const [id, p] of Object.entries(CAM_PRESETS)) {
for (const f of ['fov', 'y', 'z', 'lookY', 'lookZ']) {
assert.ok(Number.isFinite(p[f]), id + '.' + f + ' is a number');
}
assert.ok(p.y > 0 && p.z > 0, id + ' sits above and behind the keys');
}
assert.equal(readCameraSetting(), 'overhead'); // no localStorage in the vm → tuned default
});
test('camera default look is unchanged: overhead bakes the old tuned tilt, camTilt is neutral', () => {
const { CAM_PRESETS, FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
const CAM_TILT_UNITS = 55; // full-swing of the camTilt offset at ±1 (screen.js)
// The shipped default look = overhead preset + the default camTilt. Before,
// that was lookY 0 + (0.6 × 55) = 33; the tuned aim now lives in the
// preset (lookY 33) with a neutral camTilt (0), so the effective aim — and
// thus the out-of-the-box framing — is byte-identical.
const effOverhead = CAM_PRESETS.overhead.lookY + FX_DEFAULTS.camTilt * CAM_TILT_UNITS;
assert.equal(effOverhead, -33);
// 'classic' + the neutral default reproduces the historical LOOK_Y (8) —
// the "pick Classic for the original look" promise, now actually true.
const effClassic = CAM_PRESETS.classic.lookY + FX_DEFAULTS.camTilt * CAM_TILT_UNITS;
assert.equal(effClassic, 8);
});
test('keys3dSetCamera: persists + dispatches valid ids, ignores unknown', () => {
const store = {};
const events = [];
const win = load({
localStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = v; },
},
dispatchEvent: (ev) => { events.push(ev); return true; },
CustomEvent: class CustomEvent {
constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; }
},
});
win.keys3dSetCamera('overhead');
assert.equal(store.keys3d_bg_camera, 'overhead');
assert.equal(events.length, 1);
assert.equal(events[0].type, 'keys3d:settings');
assert.equal(events[0].detail.camera, 'overhead');
win.keys3dSetCamera('helicopter');
assert.equal(store.keys3d_bg_camera, 'overhead');
assert.equal(events.length, 1);
const { readCameraSetting } = win.slopsmithViz_keys_highway_3d.__test;
assert.equal(readCameraSetting(), 'overhead');
store.keys3d_bg_camera = 'garbage';
assert.equal(readCameraSetting(), 'overhead');
});
/* ── Flat-sharps / piano-shaped lanes (feat/keys3d-flat-lanes) ───────── */
test('FX defaults: octave separators on, lanes off (minimal default look)', () => {
const { FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
assert.equal(FX_DEFAULTS.octaveGaps, true); // octave separators ship on
assert.equal(FX_DEFAULTS.laneOpacity, 0.0); // dark floor + guide lines by default
assert.equal(FX_DEFAULTS.octaveContrast, 0.5);
// Sharp LAYOUT is a string setting, not an FX bool.
assert.equal('flatSharps' in FX_DEFAULTS, false);
assert.equal('laneColors' in FX_DEFAULTS, false); // superseded by laneOpacity
});
test('keys3dSetFx: highway-layout controls persist (bool + sliders)', () => {
const store = {};
const win = load({
localStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = v; },
},
dispatchEvent: () => true,
CustomEvent: class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } },
});
win.keys3dSetFx('octaveGaps', true);
assert.equal(store.keys3d_bg_octaveGaps, '1');
// laneOpacity / octaveContrast are 0-1 numbers, persisted verbatim + clamped.
win.keys3dSetFx('laneOpacity', 0.35);
assert.equal(store.keys3d_bg_laneOpacity, '0.35');
win.keys3dSetFx('laneOpacity', 5); // clamps to the 0-1 range
assert.equal(store.keys3d_bg_laneOpacity, '1');
win.keys3dSetFx('octaveContrast', 0.8);
assert.equal(store.keys3d_bg_octaveContrast, '0.8');
});
test('sharpMode: realistic default, validated ids, persists + dispatches', () => {
const bare = load().slopsmithViz_keys_highway_3d.__test;
assert.deepEqual([...bare.SHARP_MODES], ['floating', 'flat', 'realistic']);
assert.equal(bare.readSharpModeSetting(), 'realistic'); // no localStorage → default
const store = {};
const events = [];
const win = load({
localStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = v; },
},
dispatchEvent: (ev) => { events.push(ev); return true; },
CustomEvent: class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } },
});
win.keys3dSetSharpMode('flat'); // a non-default id, to exercise persistence
assert.equal(store.keys3d_bg_sharpMode, 'flat');
assert.equal(events[0].detail.sharpMode, 'flat');
assert.equal(win.slopsmithViz_keys_highway_3d.__test.readSharpModeSetting(), 'flat');
// Unknown id ignored (no write, no event).
win.keys3dSetSharpMode('bogus');
assert.equal(store.keys3d_bg_sharpMode, 'flat');
assert.equal(events.length, 1);
});
test('laneSpanFlat (V5): lanes tile with zero overlap and even the naturals', () => {
const { laneSpanFlat, _isBlackPc } = load().slopsmithViz_keys_highway_3d.__test;
const sh = 2.2, shift = 2.2 / 3;
const dims = { whiteW: 12, sharpHalf: sh, shift, octGap: 0.9 }; // mirrors shipped LANE_DIMS_FLAT
// cx for one octave: whites on integer slots, blacks on half-slots — the
// same slot geometry keyLayout/keyX produce (cx = slot * whiteW=12).
const CX = {
60: 0, 61: 6, 62: 12, 63: 18, 64: 24, 65: 36, 66: 42,
67: 48, 68: 54, 69: 60, 70: 66, 71: 72, 72: 84,
};
const midis = [60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72];
const spans = midis.map((m) => laneSpanFlat(m, _isBlackPc(m), CX[m], dims, false));
const wOf = (s) => s.right - s.left;
const w = (m) => wOf(spans[midis.indexOf(m)]);
// Zero-overlap tiling: every lane abuts the previous one (no gap, no overlap).
for (let i = 1; i < spans.length; i++) {
assert.ok(Math.abs(spans[i].left - spans[i - 1].right) < 1e-9, 'lane ' + midis[i] + ' abuts');
}
// Sharps are all the same width.
for (const m of [61, 63, 66, 68, 70]) {
assert.ok(Math.abs(w(m) - 2 * sh) < 1e-9, 'sharp ' + m + ' width');
}
// The lean evens the naturals: C, D, E, F, B all come out equal.
for (const m of [62, 64, 65, 71]) {
assert.ok(Math.abs(w(m) - w(60)) < 1e-9, 'natural ' + m + ' == C (evened)');
}
// G and A are the only slightly-smaller naturals (G# can't lean) — still
// clearly wider than a sharp, and MUCH closer to the rest than plain V2
// (which would leave D at 122·sh, far below C's 12sh).
assert.ok(Math.abs(w(67) - w(69)) < 1e-9, 'G == A');
assert.ok(w(67) < w(60) && w(67) > 2 * sh, 'G/A a touch smaller, still wider than a sharp');
assert.ok(w(60) - w(67) < sh, 'natural spread is under one sharp-width');
});
test('laneSpanReal (V4): naturals uniform, sharps full-width and overlapping', () => {
const { laneSpanReal } = load().slopsmithViz_keys_highway_3d.__test;
const dims = { natHalf: 5.64, sharpHalf: 3.2, octGap: 0.9 }; // mirrors LANE_DIMS_REAL
const wOf = (s) => s.right - s.left;
// Every natural is the same full width, whatever its neighbours.
for (const [midi, slot] of [[60, 0], [62, 1], [64, 2], [67, 4], [71, 6]]) {
assert.ok(Math.abs(wOf(laneSpanReal(midi, false, slot * 12, dims, false)) - 2 * 5.64) < 1e-9,
'natural ' + midi + ' uniform');
}
// Sharps are the full (wider) black-key width and overlap their naturals.
const C = laneSpanReal(60, false, 0, dims, false);
const Cs = laneSpanReal(61, true, 6, dims, false);
assert.ok(Math.abs(wOf(Cs) - 2 * 3.2) < 1e-9, 'sharp full width');
assert.ok(Cs.left < C.right, 'sharp overlaps (tucks over) the natural');
});
test('laneSpanFlat (V5): octaveGaps widens B→C by octGap, sharps unaffected', () => {
const { laneSpanFlat } = load().slopsmithViz_keys_highway_3d.__test;
const dims = { whiteW: 12, sharpHalf: 2.2, shift: 2.2 / 3, octGap: 0.9 };
const gapOff = laneSpanFlat(72, false, 84, dims, false).left - laneSpanFlat(71, false, 72, dims, false).right;
const gapOn = laneSpanFlat(72, false, 84, dims, true).left - laneSpanFlat(71, false, 72, dims, true).right;
assert.ok(Math.abs((gapOn - gapOff) - dims.octGap) < 1e-9, 'B→C divider grows by octGap');
// Sharps are unaffected by the octave-gap option.
const s = laneSpanFlat(61, true, 6, dims, true);
assert.ok(Math.abs((s.right - s.left) - 2 * dims.sharpHalf) < 1e-9, 'sharp width unchanged by gaps');
});
test('laneSpanFlat (V5): active-range boundary key is NOT trimmed by an out-of-range neighbor sharp', () => {
const { laneSpanFlat } = load().slopsmithViz_keys_highway_3d.__test;
const dims = { whiteW: 12, sharpHalf: 2.2, shift: 2.2 / 3, octGap: 0.9 }; // mirrors LANE_DIMS_FLAT
// F (midi 65, cx 36): its upper neighbor F# (66) is a sharp. When F sits
// at range.activeHigh and F# is excluded from the active range, F# never
// gets a lane drawn (see the activeLow/activeHigh skip around the
// lane-strip loop) — trimming F's right edge for it would leave a dark,
// unfilled sliver. The edge should stay full instead.
const highBoundary = { activeLow: 60, activeHigh: 65 };
const fAtBoundary = laneSpanFlat(65, false, 36, dims, false, highBoundary);
assert.ok(Math.abs(fAtBoundary.right - (36 + dims.whiteW / 2)) < 1e-9,
'F right edge stays full when F# is out of the active range');
// Same key, but now F# IS in the active range: normal zero-overlap
// tiling applies — the trim matches the ungated (no-range) call exactly,
// so in-range geometry is unaffected by this fix.
const highIncluded = { activeLow: 60, activeHigh: 66 };
const fWithSharpInRange = laneSpanFlat(65, false, 36, dims, false, highIncluded);
const fUngated = laneSpanFlat(65, false, 36, dims, false);
assert.ok(Math.abs(fWithSharpInRange.right - fUngated.right) < 1e-9,
'F trims normally once F# is back in range');
assert.ok(fWithSharpInRange.right < fAtBoundary.right, 'in-range trim is narrower than the boundary full edge');
// Symmetric case on the low edge: D (midi 62, cx 12), lower neighbor C#
// (61) excluded when D sits at range.activeLow.
const lowBoundary = { activeLow: 62, activeHigh: 72 };
const dAtBoundary = laneSpanFlat(62, false, 12, dims, false, lowBoundary);
assert.ok(Math.abs(dAtBoundary.left - (12 - dims.whiteW / 2)) < 1e-9,
'D left edge stays full when C# is out of the active range');
const lowIncluded = { activeLow: 61, activeHigh: 72 };
const dWithSharpInRange = laneSpanFlat(62, false, 12, dims, false, lowIncluded);
const dUngated = laneSpanFlat(62, false, 12, dims, false);
assert.ok(Math.abs(dWithSharpInRange.left - dUngated.left) < 1e-9,
'D trims normally once C# is back in range');
});