memlnaut-nisps/playground/js/shapeseq/tests/sequencer-generation.test.js

166 lines
5.5 KiB
JavaScript
Raw Normal View History

/**
* Tests for ShapeSeqEngine generation counter dirty-check.
*
* The full ShapeSeqEngine requires AudioContext, C15Bridge, and WASM,
* so we test the generation counter logic by creating a minimal subclass
* that stubs out the heavy dependencies while preserving the dirty-check
* and config-mutation methods.
*
* Run with Node >= 18: node --test playground/js/shapeseq/tests/sequencer-generation.test.js
*/
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
// ── Minimal stub of EventBus ────────────────────────────────────────
class StubEventBus {
constructor() { this._listeners = new Map(); }
on(event, fn) {
if (!this._listeners.has(event)) this._listeners.set(event, new Set());
this._listeners.get(event).add(fn);
}
off(event, fn) {
this._listeners.get(event)?.delete(fn);
}
emit(event, data) {
for (const fn of this._listeners.get(event) ?? []) fn(data);
}
}
// ── Minimal harness that replicates the generation + dirty-check logic
// from ShapeSeqEngine without needing AudioContext / WASM / C15 ────
class GenerationTestHarness {
constructor(eventBus) {
this._bus = eventBus;
// Dirty-check state (mirrors sequencer.js)
this._lastInputs = [NaN, NaN];
this._generation = 0;
this._lastGeneration = -1;
// Track how many times the pipeline actually ran
this.evaluationCount = 0;
// Bound handler for chain edits
this._onChainEdit = () => this._bumpGeneration();
this._bus.on('ui.chainEdit', this._onChainEdit);
}
// Config mutators (same as sequencer.js)
setTempo(_bpm) { this._bumpGeneration(); }
setStepCount(_count) { this._bumpGeneration(); }
setProjection(_name) { this._bumpGeneration(); }
/** Mirrors ShapeSeqEngine.setSequenceInputs dirty-check exactly. */
setSequenceInputs(values) {
const EPS = 1e-5;
const inputsSame = Math.abs(values[0] - this._lastInputs[0]) < EPS &&
Math.abs(values[1] - this._lastInputs[1]) < EPS;
const generationSame = this._generation === this._lastGeneration;
if (inputsSame && generationSame) {
return;
}
this._lastInputs[0] = values[0];
this._lastInputs[1] = values[1];
this._lastGeneration = this._generation;
// Instead of running MLP pipeline, just count.
this.evaluationCount++;
}
_bumpGeneration() { this._generation++; }
destroy() {
this._bus.off('ui.chainEdit', this._onChainEdit);
}
}
// ── Tests ───────────────────────────────────────────────────────────
describe('ShapeSeqEngine generation counter', () => {
let bus;
let engine;
beforeEach(() => {
bus = new StubEventBus();
engine = new GenerationTestHarness(bus);
});
it('evaluates on first call even with default inputs', () => {
engine.setSequenceInputs([0.5, 0.5]);
assert.equal(engine.evaluationCount, 1);
});
it('skips evaluation when inputs are identical', () => {
engine.setSequenceInputs([0.5, 0.5]);
engine.setSequenceInputs([0.5, 0.5]);
assert.equal(engine.evaluationCount, 1);
});
it('re-evaluates when inputs change', () => {
engine.setSequenceInputs([0.5, 0.5]);
engine.setSequenceInputs([0.6, 0.5]);
assert.equal(engine.evaluationCount, 2);
});
it('re-evaluates on setTempo even with same inputs', () => {
engine.setSequenceInputs([0.5, 0.5]);
assert.equal(engine.evaluationCount, 1);
engine.setTempo(140);
engine.setSequenceInputs([0.5, 0.5]);
assert.equal(engine.evaluationCount, 2);
});
it('re-evaluates on setStepCount even with same inputs', () => {
engine.setSequenceInputs([0.5, 0.5]);
engine.setStepCount(16);
engine.setSequenceInputs([0.5, 0.5]);
assert.equal(engine.evaluationCount, 2);
});
it('re-evaluates on setProjection() even with same inputs', () => {
engine.setSequenceInputs([0.5, 0.5]);
engine.setProjection({ gateThreshold: 0.5 });
engine.setSequenceInputs([0.5, 0.5]);
assert.equal(engine.evaluationCount, 2);
});
it('re-evaluates on ui.chainEdit event even with same inputs', () => {
engine.setSequenceInputs([0.5, 0.5]);
bus.emit('ui.chainEdit', { action: 'add', name: 'EuclideanRhythm' });
engine.setSequenceInputs([0.5, 0.5]);
assert.equal(engine.evaluationCount, 2);
});
it('generation bump is consumed after one evaluation', () => {
engine.setSequenceInputs([0.5, 0.5]);
engine.setTempo(140);
engine.setSequenceInputs([0.5, 0.5]); // consumes the bump
engine.setSequenceInputs([0.5, 0.5]); // should be skipped
assert.equal(engine.evaluationCount, 2);
});
it('multiple config changes before evaluation only cause one extra eval', () => {
engine.setSequenceInputs([0.5, 0.5]);
engine.setTempo(140);
engine.setStepCount(16);
engine.setProjection({ velocityCurve: 'sCurve' });
bus.emit('ui.chainEdit', { action: 'remove', index: 0 });
engine.setSequenceInputs([0.5, 0.5]);
assert.equal(engine.evaluationCount, 2);
});
it('generation counter increments correctly', () => {
assert.equal(engine._generation, 0);
engine.setTempo(100);
assert.equal(engine._generation, 1);
engine.setStepCount(4);
assert.equal(engine._generation, 2);
bus.emit('ui.chainEdit', { action: 'reorder', from: 0, to: 1 });
assert.equal(engine._generation, 3);
});
});