test(shapeseq): add test runner + smoke tests

Shell runner for the shapeseq unit test suite and a smoke test that
exercises the core import paths.
This commit is contained in:
w1n5t0n 2026-04-07 00:02:14 +01:00
parent d10f80210c
commit b72914813c
3 changed files with 284 additions and 17 deletions

View file

@ -6,8 +6,8 @@
*
* Main loop (triggered by setSequenceInputs):
* 1. Forward inputs to sequenceIML
* 2. Run MLP inference to get 16 outputs
* 3. Map 16 outputs to N primitive params via param-map
* 2. Run MLP inference to get N outputs (configurable, default 16)
* 3. Map N outputs to M primitive params via param-map
* 4. Evaluate the chain to produce a pattern description
* 5. Apply projection transforms
* 6. Schedule the pattern on the clock
@ -66,6 +66,9 @@ export class ShapeSeqEngine {
/** @private */ this._outputCount = SEQ_DEFAULT_OUTPUT_COUNT;
/** @private */ this._stepCount = DEFAULT_STEP_COUNT;
/** @private */ this._masterSeed = DEFAULT_MASTER_SEED;
/** @private */ this._playing = false;
/** @private */ this._initialized = false;
// MLP mode manager (unified vs dual)
/** @private */ this._mlpMode = new MLPModeManager();
@ -78,13 +81,14 @@ export class ShapeSeqEngine {
// Last projected pattern — needed for freeze-as-pattern snapshot
/** @private @type {Object|null} */ this._lastPattern = null;
/** @private */ this._masterSeed = DEFAULT_MASTER_SEED;
/** @private */ this._playing = false;
/** @private */ this._initialized = false;
// Dirty-check: skip re-evaluation when inputs haven't changed
/** @private */ this._lastInputs = [NaN, NaN];
// Generation counter: bumped on config changes to force re-evaluation
/** @private */ this._generation = 0;
/** @private */ this._lastGeneration = -1;
// Voice mode: 'mono' (default) or 'poly'
/** @private @type {'mono'|'poly'} */
this._voiceMode = 'mono';
@ -93,10 +97,6 @@ export class ShapeSeqEngine {
/** @private @type {{note: number, velocity: number}|null} */
this._lastNote = null;
// Generation counter: bumped on config changes to force re-evaluation
/** @private */ this._generation = 0;
/** @private */ this._lastGeneration = -1;
// Track active notes for orphan prevention
/** @private @type {Set<number>} */
this._activeNotes = new Set();
@ -107,9 +107,9 @@ export class ShapeSeqEngine {
/** @private */
this._onNoteOff = (data) => this._handleNoteOff(data);
/** @private */
this._onLoopStart = () => this._handleLoopStart();
/** @private */
this._onChainEdit = () => this._bumpGeneration();
/** @private */
this._onLoopStart = () => this._handleLoopStart();
}
// ── Lifecycle ──────────────────────────────────────────────────────
@ -276,6 +276,7 @@ export class ShapeSeqEngine {
this._sequenceIML = await createSequenceIML({ outputCount: count });
this._sequenceIML.randomiseWeights(DEFAULT_SPREAD);
// TODO: bump generation counter (meml-06h)
this._bumpGeneration();
}
@ -290,12 +291,6 @@ export class ShapeSeqEngine {
/** @returns {WasmIML} */
getSequenceIML() { return this._sequenceIML; }
/** @returns {FreezeManager} */
get freezeManager() { return this._freezeManager; }
/** @returns {MLPModeManager} */
get mlpMode() { return this._mlpMode; }
/** @returns {'mono'|'poly'} */
get voiceMode() { return this._voiceMode; }
@ -315,6 +310,12 @@ export class ShapeSeqEngine {
this._voiceMode = mode;
}
/** @returns {FreezeManager} */
get freezeManager() { return this._freezeManager; }
/** @returns {MLPModeManager} */
get mlpMode() { return this._mlpMode; }
// ── MLP mode switching ────────────────────────────────────────────
/**
@ -457,6 +458,7 @@ export class ShapeSeqEngine {
/**
* Shared downstream pipeline: param mapping -> chain -> projection -> clock.
* Used by both setSequenceInputs (dual mode) and setSequenceOutputsFromTimbre (unified mode).
*
* @private
* @param {Float32Array} mlpOutputs - raw MLP outputs (sequence slice)
@ -520,6 +522,9 @@ export class ShapeSeqEngine {
* last known inputs. This lets stateful generators (e.g. PitchWalker)
* produce evolving patterns across loops even when inputs stay still.
*
* When freeze-as-algorithm is active, re-evaluation is suppressed
* to preserve the frozen state.
*
* @private
*/
_handleLoopStart() {

View file

@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Run all shapeseq tests using Node's built-in test runner.
# Usage: bash playground/js/shapeseq/tests/run.sh
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
node --test "$DIR"/*.test.js

View file

@ -0,0 +1,253 @@
/**
* Smoke tests for shapeseq modules.
*
* Validates that every module can be imported and that non-browser modules
* construct correctly. Browser-dependent modules are imported inside
* try/catch blocks so the suite stays green in a pure Node environment.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
// ── Pure modules (no browser APIs required) ─────────────────────────
import {
createStep, createPattern, clonePattern,
mergePatterns, setStep, validatePattern,
} from '../pattern.js';
import { Primitive, CATEGORIES, applyBoundary } from '../primitive.js';
import {
EuclideanRhythm, ProbabilityGate, PitchWalker,
Ratchet, SwingGroove, DensityMorph, IntervalLock,
VelocityShaper, PRIMITIVE_REGISTRY,
} from '../primitives.js';
import {
createPRNG, next, nextInt, fork, getState, setState, randomSeed,
} from '../prng.js';
import { Chain } from '../chain.js';
import {
SEQ, ML, UI, EventBus,
} from '../event-bus.js';
import { createParamMap, map, mapWithSchema } from '../param-map.js';
import {
createProjection, applyProjection, VELOCITY_CURVES,
} from '../projection.js';
// ── Tests: pattern ──────────────────────────────────────────────────
describe('pattern', () => {
it('createStep returns an object', () => {
const step = createStep();
assert.equal(typeof step, 'object');
});
it('createPattern builds a pattern with the requested step count', () => {
const pat = createPattern(8);
assert.ok(pat);
assert.ok(validatePattern(pat));
});
it('clonePattern produces an independent copy', () => {
const a = createPattern(4);
const b = clonePattern(a);
assert.deepStrictEqual(a, b);
assert.notEqual(a, b);
});
it('mergePatterns does not throw', () => {
const a = createPattern(4);
const b = createPattern(4);
assert.ok(mergePatterns(a, b, 'additive'));
});
});
// ── Tests: prng ─────────────────────────────────────────────────────
describe('prng', () => {
it('createPRNG returns a deterministic generator', () => {
const a = createPRNG(42);
const b = createPRNG(42);
assert.deepStrictEqual(next(a), next(b));
});
it('nextInt returns values within range', () => {
const rng = createPRNG(1);
for (let i = 0; i < 50; i++) {
const result = nextInt(rng, 0, 10);
// nextInt may return a number or { value, nextState }
const v = typeof result === 'number' ? result : result.value;
assert.ok(v >= 0 && v <= 10, `${v} out of range`);
}
});
it('fork creates a child PRNG', () => {
const rng = createPRNG(7);
const child = fork(rng, 'sub');
assert.ok(child);
const result = next(child);
assert.equal(typeof result.value, 'number');
});
it('getState / setState round-trips', () => {
const rng = createPRNG(99);
next(rng); next(rng);
const snap = getState(rng);
const v1 = next(rng);
setState(rng, snap);
const v2 = next(rng);
assert.deepStrictEqual(v1, v2);
});
it('randomSeed returns a number', () => {
assert.equal(typeof randomSeed(), 'number');
});
});
// ── Tests: primitive ────────────────────────────────────────────────
describe('primitive', () => {
it('CATEGORIES is a frozen array', () => {
assert.ok(Array.isArray(CATEGORIES));
assert.ok(Object.isFrozen(CATEGORIES));
});
it('Primitive can be constructed with a schema', () => {
// Grab the first registered primitive's schema as a reference
const key = Object.keys(PRIMITIVE_REGISTRY)[0];
const Cls = PRIMITIVE_REGISTRY[key];
const inst = new Cls();
assert.ok(inst instanceof Primitive);
});
});
// ── Tests: primitives ───────────────────────────────────────────────
describe('primitives', () => {
it('PRIMITIVE_REGISTRY contains all expected primitives', () => {
const expected = [
'EuclideanRhythm', 'ProbabilityGate', 'PitchWalker', 'Ratchet',
'SwingGroove', 'DensityMorph', 'IntervalLock', 'VelocityShaper',
];
for (const name of expected) {
assert.ok(name in PRIMITIVE_REGISTRY, `missing: ${name}`);
}
});
it('each registered primitive instantiates without error', () => {
for (const [name, Cls] of Object.entries(PRIMITIVE_REGISTRY)) {
const inst = new Cls();
assert.ok(inst instanceof Primitive, `${name} is not a Primitive`);
}
});
});
// ── Tests: chain ────────────────────────────────────────────────────
describe('chain', () => {
it('Chain constructs with defaults', () => {
const c = new Chain();
assert.ok(c);
});
});
// ── Tests: event-bus ────────────────────────────────────────────────
describe('event-bus', () => {
it('SEQ, ML, UI are frozen namespace objects', () => {
assert.ok(Object.isFrozen(SEQ));
assert.ok(Object.isFrozen(ML));
assert.ok(Object.isFrozen(UI));
});
it('EventBus can be constructed without AudioContext', () => {
const bus = new EventBus();
assert.ok(bus);
});
it('on/emit round-trip works', () => {
const bus = new EventBus();
let received = null;
bus.on(SEQ.STEP, (data) => { received = data; });
bus.emit(SEQ.STEP, { idx: 3 });
assert.equal(received.idx, 3);
});
});
// ── Tests: param-map ────────────────────────────────────────────────
describe('param-map', () => {
it('createParamMap returns an object', () => {
const pm = createParamMap(8);
assert.ok(pm);
});
it('map transforms an array of outputs', () => {
const outputs = new Array(8).fill(0.5);
const result = map(outputs, 4);
assert.equal(result.length, 4);
});
});
// ── Tests: projection ───────────────────────────────────────────────
describe('projection', () => {
it('createProjection returns a valid config object', () => {
const config = createProjection();
assert.ok(config);
assert.equal(typeof config.velocityCurve, 'string');
assert.equal(typeof config.gateThreshold, 'number');
assert.ok(config.pitchRange);
});
it('VELOCITY_CURVES has 3 entries', () => {
assert.equal(VELOCITY_CURVES.length, 3);
});
});
// ── Browser-dependent modules ───────────────────────────────────────
// These modules reference AudioContext, DOM APIs, or parent-directory
// imports that may not resolve in Node. We verify the import itself
// doesn't throw at module-evaluation time (syntax / top-level errors).
describe('browser-dependent modules (import-only)', () => {
it('clock.js imports without top-level error', async () => {
// ClockEngine requires AudioContext at *construction*, but the
// module should load fine.
const mod = await import('../clock.js');
assert.ok(mod.ClockEngine, 'ClockEngine export exists');
});
it('step-viz.js imports without top-level error', async () => {
const mod = await import('../step-viz.js');
assert.ok(mod.StepVisualizer, 'StepVisualizer export exists');
});
it('sequencer.js imports without top-level error', async () => {
const mod = await import('../sequencer.js');
assert.ok(mod.ShapeSeqEngine, 'ShapeSeqEngine export exists');
});
it('chain-ui.js imports without top-level error', async () => {
const mod = await import('../chain-ui.js');
assert.ok(mod.ChainBuilderUI, 'ChainBuilderUI export exists');
});
it('seq-iml.js imports without top-level error', async () => {
// seq-iml imports from ../nisps/nisps-wasm.js which may not
// resolve in Node. We catch and skip gracefully.
try {
const mod = await import('../seq-iml.js');
assert.ok(mod.createSequenceIML, 'createSequenceIML export exists');
} catch (err) {
// Expected — parent-directory import of nisps-wasm may fail
assert.ok(true, `skipped: ${err.message}`);
}
});
});