You've already forked torvalds-GuitarPedal
mirror of
https://github.com/torvalds/GuitarPedal.git
synced 2026-08-14 12:44:08 +00:00
'Software' was the directory everything that was not KiCad ended up in, which stopped describing anything a while ago - Validation and the web app are software too. Worse, it put the shared parts inside the firmware, where they read as the firmware's own. They are not. Effects/ has three consumers built from it: the firmware, Validation's bench, and the web app's controls, all generated from the same POT: comments by gen_effects.py. Audio/ has two - the bench compiles the same biquads, the same envelope followers and the same single_sample(), which is the whole reason a measurement on a workstation says anything about the pedal. Neither belongs under Firmware/, so neither is under it any more: Effects/ one file per effect Audio/ the DSP they are built from, and the audio loop Firmware/ the rest of what runs on the pedal, and the submodules WebMIDI/ the web app scripts/ what the build runs Validation/ unchanged Hardware/, Documentation/, Images/ CMakeLists.txt and the wrapper Makefile move to the top with them, because the build now consumes four of those directories and generates into a fifth. board.local and build/ come along; MIDI_CC_MAP.md is generated into Documentation/ rather than into the old Software/ root. scripts/ goes with the build rather than staying under the firmware, because six of the ten had nothing to do with the firmware: gen_effects.py reads Effects/ and writes to three different places, pow2/log2/quarter_sine generate Audio/'s tables, check-readme.py compares Effects/ against the README, and server.py serves the web app. Four of them are invoked from Validation, which was reaching into Firmware/ for tooling - the same burying this commit is undoing. The four that really are about the firmware are ELF checks the top-level build drives anyway, and a second scripts directory would only be a second place to look. C includes say "Audio/foo.h" and the generated map says "Effects/bar.h", with the repository root on the include path for both the firmware and the bench. Spelling the directory out rather than relying on a bare name is what keeps Audio/cycles.h shimmable: a quoted include searches the including file's own directory first. The submodules are renamed as well as moved. git mv updates their paths but leaves the section names, and 'Software/pico-sdk' surviving in .gitmodules would be the word this commit removes, still load-bearing. That meant the nested modules under pico-sdk too - six .git files pointing into .git/modules/Software - which is why 'git submodule update --init --recursive' is worth running once after pulling this. Verified rather than assumed: a clean configure and build, make check (failing only on the missing-eeprom case it already failed on), check-effects, all four analysis pages reproducing every series and drawing every chart, and a flash to the board that still measures a routed reverb where it did before. One latent bug fell out of it. bench/coeff declared only quarter_sine.h of the three generated math tables, and Audio/util.h includes pow2.h and log2.h as well - so building that target with an empty gen/ could never have worked. 'make bench' builds bench/bench first, which generates all three, so it stayed hidden until this rebuilt everything from nothing. Signed-off-by: Linus Torvalds <torvalds@linux-foundation.org>
624 lines
25 KiB
JavaScript
624 lines
25 KiB
JavaScript
//
|
||
// Does the web app still load, and does it still draw the right
|
||
// conclusions?
|
||
//
|
||
// The firmware has a compiler and two post-build checks looking over its
|
||
// shoulder. The app has none of that, and 'node --check' is not a
|
||
// substitute: it parses, and a parser will happily accept an identifier
|
||
// that was never declared. That is exactly the mistake that gets made
|
||
// when a block is deleted and something further down still wanted a name
|
||
// it happened to define - which is a runtime ReferenceError in the
|
||
// browser and silence everywhere else.
|
||
//
|
||
// So load it for real, against enough of a DOM to get through its boot
|
||
// path, and then call the handful of functions whose logic decides what
|
||
// the user is told.
|
||
//
|
||
// getElementById() is deliberately strict: it knows the ids in
|
||
// index.html, plus any the app assigns as it builds cards, and returns
|
||
// null for anything else. So asking for an element that no longer exists
|
||
// in the markup fails here instead of quietly doing nothing in front of
|
||
// somebody.
|
||
//
|
||
// Called as: node test-webmidi.js <generated effects.js>
|
||
//
|
||
'use strict';
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const WEB = path.join(__dirname, '..', 'WebMIDI');
|
||
const effectsJs = process.argv[2] || path.join(WEB, 'effects.js');
|
||
|
||
let failures = 0;
|
||
|
||
const say = (line) => process.stdout.write(line + '\n');
|
||
|
||
function check(what, ok, detail) {
|
||
if (ok) return;
|
||
say(` FAIL: ${what}${detail ? ' - ' + detail : ''}`);
|
||
failures++;
|
||
}
|
||
|
||
//
|
||
// A DOM, to the depth this actually needs.
|
||
//
|
||
const byId = new Map();
|
||
|
||
function element(id) {
|
||
const classes = new Set();
|
||
const el = {
|
||
children: [],
|
||
parentElement: null,
|
||
style: {},
|
||
dataset: {},
|
||
textContent: '',
|
||
value: '',
|
||
title: '',
|
||
checked: false,
|
||
classes,
|
||
classList: {
|
||
add: (c) => classes.add(c),
|
||
remove: (c) => classes.delete(c),
|
||
contains: (c) => classes.has(c),
|
||
toggle: (c, on) => (on === undefined
|
||
? (classes.has(c) ? classes.delete(c) : classes.add(c))
|
||
: (on ? classes.add(c) : classes.delete(c))),
|
||
},
|
||
get id() { return this._id; },
|
||
set id(v) { this._id = v; byId.set(v, this); },
|
||
get innerHTML() { return ''; },
|
||
set innerHTML(v) { if (v === '') el.children.length = 0; },
|
||
// Parentage is tracked because the app walks upwards as well as
|
||
// down - a pot's value display is found from the input via its
|
||
// parent, so an element that does not know its parent makes
|
||
// resetUnroutedEffects() throw rather than do nothing.
|
||
appendChild(c) { el.children.push(c); c.parentElement = el; return c; },
|
||
insertBefore(c) { el.children.push(c); c.parentElement = el; return c; },
|
||
removeChild(c) { return c; },
|
||
remove() {},
|
||
addEventListener() {},
|
||
removeEventListener() {},
|
||
dispatchEvent() { return true; },
|
||
setAttribute() {},
|
||
getAttribute() { return null; },
|
||
// The same selector has to come back as the same node, or a
|
||
// canvas fetched twice is two different canvases and nothing
|
||
// drawn on it can be observed.
|
||
querySelector(sel) {
|
||
if (!el._q) el._q = new Map();
|
||
if (!el._q.has(sel)) el._q.set(sel, element('?'));
|
||
return el._q.get(sel);
|
||
},
|
||
querySelectorAll() { return []; },
|
||
closest() { return null; },
|
||
// Nothing here is laid out, so everything has no size - which is
|
||
// also what a real parked card reports, and is the case the
|
||
// drawing code has a fallback for.
|
||
getBoundingClientRect() {
|
||
return { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 };
|
||
},
|
||
scrollIntoView() {},
|
||
focus() {},
|
||
// A canvas context that remembers what it was asked to do,
|
||
// so a test can tell "drew the wrong thing" from "drew nothing"
|
||
getContext() {
|
||
if (!el._ctx) {
|
||
const calls = [];
|
||
el._ctx = new Proxy({}, {
|
||
get: (t, k) => (k === 'calls' ? calls : () => calls.push(k)),
|
||
set: () => true,
|
||
});
|
||
}
|
||
return el._ctx;
|
||
},
|
||
};
|
||
el._id = id;
|
||
return el;
|
||
}
|
||
|
||
// Everything index.html declares is real; everything else is not.
|
||
for (const m of fs.readFileSync(path.join(WEB, 'index.html'), 'utf8')
|
||
.matchAll(/\bid="([^"]+)"/g))
|
||
byId.set(m[1], element(m[1]));
|
||
|
||
global.document = {
|
||
getElementById: (id) => byId.get(id) || null,
|
||
createElement: () => element(undefined),
|
||
// Enough of a node to be appended and to carry its text
|
||
createTextNode: (text) => ({ textContent: text, parentElement: null }),
|
||
querySelector: () => element('?'),
|
||
querySelectorAll: () => [],
|
||
addEventListener() {},
|
||
body: element('body'),
|
||
head: element('head'),
|
||
documentElement: element('html'),
|
||
};
|
||
global.window = {
|
||
addEventListener() {},
|
||
matchMedia: () => ({ matches: false, addEventListener() {} }),
|
||
location: { hostname: 'localhost', href: 'http://localhost/' },
|
||
};
|
||
global.location = global.window.location;
|
||
// Node has a real navigator of its own, and it is read-only, so this has
|
||
// to be defined over the top rather than assigned.
|
||
//
|
||
// MIDI access succeeds with nothing plugged in, which is the state the
|
||
// app starts in anyway and exercises its device enumeration rather than
|
||
// its failure path.
|
||
Object.defineProperty(global, 'navigator', {
|
||
value: {
|
||
requestMIDIAccess: () => Promise.resolve({
|
||
inputs: new Map(), outputs: new Map(),
|
||
onstatechange: null, addEventListener() {},
|
||
}),
|
||
},
|
||
configurable: true,
|
||
writable: true,
|
||
});
|
||
global.Event = class { constructor(type) { this.type = type; } };
|
||
global.alert = () => {};
|
||
global.confirm = () => true;
|
||
global.localStorage = { getItem: () => null, setItem() {}, removeItem() {} };
|
||
|
||
//
|
||
// Load it. The app is a plain script, so its declarations are scoped to
|
||
// the function we build here - the trailing export is how the tests below
|
||
// get at the ones they need.
|
||
//
|
||
const WANT = ['handleIdentity', 'populateScenePicker', 'updateSceneLabels',
|
||
'boardFault', 'earlyNote', 'clipFault', 'dropFault',
|
||
'renderAttention', 'handleTelemetry', 'handleSysex',
|
||
'routeEffect', 'unrouteEffect',
|
||
'potToValue', 'valueToPot', 'clampToNeighbours', 'pileAt',
|
||
'uiPref', 'setUiPref'];
|
||
|
||
//
|
||
// The chain is a list held in a variable rather than anything the dom
|
||
// can be asked about, so reading it takes an accessor evaluated in the
|
||
// app's own scope. A snapshot would not do: currentRouting is replaced
|
||
// on every routing change, not mutated.
|
||
//
|
||
const src = fs.readFileSync(effectsJs, 'utf8') + '\n'
|
||
+ fs.readFileSync(path.join(WEB, 'app.js'), 'utf8') + '\n'
|
||
+ `;globalThis.__app = { ${WANT.join(', ')} };`
|
||
+ `;globalThis.__app.routing = () => currentRouting;`;
|
||
|
||
//
|
||
// The app logs as it goes, including from promises that settle after this
|
||
// script has finished, so console stays silenced for the whole run and
|
||
// anything worth saying goes out directly.
|
||
//
|
||
for (const k of ['log', 'debug', 'info', 'warn', 'error'])
|
||
console[k] = () => {};
|
||
|
||
try {
|
||
new Function(src)();
|
||
} catch (e) {
|
||
say(` FAIL: app.js did not load - ${e.name}: ${e.message}`);
|
||
const at = (e.stack || '').split('\n')[1];
|
||
if (at) say(` ${at.trim()}`);
|
||
process.exit(1);
|
||
}
|
||
|
||
const app = globalThis.__app;
|
||
for (const name of WANT)
|
||
check(`${name} is defined`, app[name] !== undefined);
|
||
|
||
//
|
||
// The identity reply decides whether the user gets told their board is
|
||
// wrong, and it is the thing most likely to be quietly inverted.
|
||
//
|
||
const identity = (over) => Object.assign({
|
||
build: 'Jan 1 2026 00:00:00', scenes: 32, midi_hw: true,
|
||
found: { eeprom: true, legacy_codec: false, legacy_screen: false },
|
||
}, over);
|
||
|
||
app.handleIdentity(identity());
|
||
check('a current board raises no warning', app.boardFault.on === false);
|
||
|
||
app.handleIdentity(identity({ scenes: 1, found: { eeprom: true, legacy_codec: true } }));
|
||
check('an early board is noted, not faulted', app.earlyNote.on === true
|
||
&& app.boardFault.on === false);
|
||
check('and says why', /TAC5112/.test(document.getElementById('status-early').title),
|
||
document.getElementById('status-early').title);
|
||
|
||
app.handleIdentity(identity({ found: { eeprom: false } }));
|
||
check('a missing eeprom is flagged', app.boardFault.on === true);
|
||
|
||
app.handleIdentity(identity());
|
||
check('and both clear again when the next pedal is fine',
|
||
app.boardFault.on === false && app.earlyNote.on === false);
|
||
|
||
//
|
||
// How many scenes there are comes from the pedal, so the picker has to be
|
||
// built from the reply rather than guessed at.
|
||
//
|
||
const picker = document.getElementById('global-scene-select');
|
||
|
||
app.populateScenePicker(32);
|
||
check('32 scenes gives 32 options', picker.children.length === 32,
|
||
`got ${picker.children.length}`);
|
||
|
||
app.populateScenePicker(1);
|
||
check('one scene gives one option', picker.children.length === 1,
|
||
`got ${picker.children.length}`);
|
||
|
||
app.handleIdentity(identity({ scenes: 1 }));
|
||
check('and the identity reply drives it', picker.children.length === 1,
|
||
`got ${picker.children.length}`);
|
||
|
||
// Nothing routed and nothing asking: this must not throw on a fresh page
|
||
app.renderAttention();
|
||
|
||
//
|
||
// Feed it the pedal's actual schema, which is the only way to get the
|
||
// effect cards built - and building them is worth exercising, since it is
|
||
// where most of the app's DOM work happens. midi_schema.h is generated
|
||
// next to effects.js, so it is wherever that was found.
|
||
//
|
||
const schemaPath = path.join(path.dirname(path.resolve(effectsJs)), 'midi_schema.h');
|
||
const fallback = path.join(__dirname, '..', 'build', 'midi_schema.h');
|
||
const schemaFile = fs.existsSync(schemaPath) ? schemaPath
|
||
: (fs.existsSync(fallback) ? fallback : null);
|
||
|
||
if (!schemaFile) {
|
||
say(' FAIL: no midi_schema.h found, so no cards can be built');
|
||
process.exit(1);
|
||
}
|
||
|
||
const literal = fs.readFileSync(schemaFile, 'utf8').match(/"((?:[^"\\]|\\.)*)"/);
|
||
const schemaJson = literal[1].replace(/\\"/g, '"');
|
||
const asSysex = (cmd, text) => [0xF0, 0x7D, cmd,
|
||
...[...text].map((c) => c.charCodeAt(0)), 0xF7];
|
||
|
||
app.handleSysex(asSysex(0x02, schemaJson));
|
||
check('the real schema builds the cards',
|
||
document.getElementById('signal-chain-meters') !== null);
|
||
|
||
//
|
||
// Routing. The chain is an ordered list of ids and everything else is a
|
||
// chip in the pool, so the two questions worth asking are whether the
|
||
// pool is exactly the complement of the chain, and whether the two anchor
|
||
// effects stay out of both - they are the ends of the chain and cannot be
|
||
// routed, unrouted or reordered.
|
||
//
|
||
//
|
||
// The schema is an object with the effects under a key now, and used to
|
||
// be a bare array of them. Accepted both ways here for the same reason
|
||
// the app accepts both: this test is run against whatever midi_schema.h
|
||
// happens to be lying about, including one built before the change.
|
||
//
|
||
const parsedSchema = JSON.parse(schemaJson);
|
||
const schema = Array.isArray(parsedSchema) ? parsedSchema : parsedSchema.effects;
|
||
const steering = Array.isArray(parsedSchema) ? null : parsedSchema.steering;
|
||
|
||
check('the schema declares channel steering once, not per effect',
|
||
steering !== null && steering.pots.length === 3 &&
|
||
steering.pots.map((p) => p.index).join(',') === '11,12,13');
|
||
|
||
//
|
||
// Steering means nothing for an effect that is never handed to
|
||
// do_effect_step(), and those are exactly the two that anchor the chain.
|
||
//
|
||
check('only the routable effects are steerable',
|
||
schema.filter((e) => e.steerable).length === schema.length - 2 &&
|
||
!schema[0].steerable && !schema[schema.length - 1].steerable);
|
||
|
||
const routable = schema.slice(1, -1);
|
||
const pool = document.getElementById('effect-pool');
|
||
|
||
// [label, chip grid], or hidden with nothing in it at all
|
||
const chipNames = () => (pool.classes.has('hidden') ? []
|
||
: pool.children[pool.children.length - 1]
|
||
.children.map((c) => c.textContent));
|
||
|
||
const cardOf = (effect) =>
|
||
document.getElementById(`effect-${schema.indexOf(effect)}`);
|
||
|
||
check('nothing is routed until the pedal says so',
|
||
chipNames().join() === routable.map((e) => e.name).join(),
|
||
chipNames().join());
|
||
check('and the cards for those are parked',
|
||
routable.every((e) => cardOf(e).classes.has('parked')));
|
||
check('while the anchors are not chips and not parked',
|
||
!chipNames().includes(schema[0].name)
|
||
&& !cardOf(schema[0]).classes.has('parked')
|
||
&& !cardOf(schema[schema.length - 1]).classes.has('parked'));
|
||
|
||
// Two of them routed, in an order that is not the schema's
|
||
const routed = [routable[2], routable[0]];
|
||
app.handleSysex([0xF0, 0x7D, 0x08, ...routed.map((e) => e.id), 0xF7]);
|
||
|
||
check('a routed effect leaves the pool',
|
||
chipNames().join() ===
|
||
routable.filter((e) => !routed.includes(e)).map((e) => e.name).join(),
|
||
chipNames().join());
|
||
check('and its card comes back',
|
||
routed.every((e) => !cardOf(e).classes.has('parked')));
|
||
|
||
// And back out again, which is the same path the eject button takes
|
||
app.handleSysex([0xF0, 0x7D, 0x08, 0xF7]);
|
||
check('an empty routing order empties the chain',
|
||
chipNames().join() === routable.map((e) => e.name).join(),
|
||
chipNames().join());
|
||
check('and parks the cards again',
|
||
routable.every((e) => cardOf(e).classes.has('parked')));
|
||
|
||
//
|
||
// Tapping a chip adds to the end, and that is the contract the pool
|
||
// makes: order is yours to set by dragging afterwards, so nothing may
|
||
// quietly sort the chain back into schema order.
|
||
//
|
||
app.routeEffect(routable[5].id);
|
||
app.routeEffect(routable[1].id);
|
||
check('chips route to the end, in the order they were tapped',
|
||
app.routing().join() === [routable[5].id, routable[1].id].join(),
|
||
app.routing().join());
|
||
|
||
// Which is what the eject button and the flick both come down to
|
||
app.unrouteEffect(routable[5].id);
|
||
check('unrouting one leaves the others alone',
|
||
app.routing().join() === String(routable[1].id), app.routing().join());
|
||
check('and puts just that one back in the pool',
|
||
chipNames().includes(routable[5].name)
|
||
&& !chipNames().includes(routable[1].name));
|
||
|
||
//
|
||
// Telemetry. The layout is append-only, so the interesting cases are the
|
||
// short frame from older firmware and the long one from newer - neither is
|
||
// an error, and a missing field is absent rather than zero.
|
||
//
|
||
// The load is the field that grew: layout 2 puts a 14-bit value across
|
||
// bytes 5 and 6, where layout 1 had seven bits in byte 5 alone. Both
|
||
// have to read, and which one arrived is decided by whether byte 6 is
|
||
// there rather than by the version, because that is how every other
|
||
// field here is handled.
|
||
//
|
||
const meters = document.getElementById('signal-chain-meters');
|
||
const frame = (...body) => [0xF0, 0x7D, 0x0B, ...body, 0xF7];
|
||
|
||
app.handleTelemetry(frame(1, 38, 61, 18, 127, 42));
|
||
check('a full frame reads out', /in −38 dB/.test(meters.textContent)
|
||
&& /floor −61 dB/.test(meters.textContent)
|
||
&& /out −18 dB/.test(meters.textContent), meters.textContent);
|
||
check('an open gate says so', /gate open/.test(meters.textContent),
|
||
meters.textContent);
|
||
check('cpu is a percentage', /cpu 33\.1%/.test(meters.textContent),
|
||
meters.textContent);
|
||
|
||
app.handleTelemetry(frame(2, 40, 60, 20, 127, 42, 65));
|
||
check('a 14-bit load reads finer than a whole percent',
|
||
/cpu 33\.2%/.test(meters.textContent), meters.textContent);
|
||
|
||
app.handleTelemetry(frame(1, 0, 127, 0, 0, 127));
|
||
check('silence is not a number', /floor −∞ dB/.test(meters.textContent),
|
||
meters.textContent);
|
||
check('a closed gate says so', /gate closed/.test(meters.textContent),
|
||
meters.textContent);
|
||
check('full scale reads zero', /in −0 dB/.test(meters.textContent),
|
||
meters.textContent);
|
||
|
||
app.handleTelemetry(frame(1, 30, 55, 20, 32, 10));
|
||
check('a gating gate reads in dB', /gate −12 dB/.test(meters.textContent),
|
||
meters.textContent);
|
||
|
||
// Older firmware: fields this app knows about are simply not there
|
||
app.handleTelemetry(frame(1, 40, 60));
|
||
check('a short frame keeps what it has', /in −40 dB/.test(meters.textContent)
|
||
&& /floor −60 dB/.test(meters.textContent), meters.textContent);
|
||
check('and does not invent the rest', !/out/.test(meters.textContent)
|
||
&& !/cpu/.test(meters.textContent), meters.textContent);
|
||
|
||
// Newer firmware: extra fields at the end, which must simply be ignored.
|
||
// Byte 6 is the load's LSB and is understood; 98 and 97 are not.
|
||
app.handleTelemetry(frame(2, 12, 34, 56, 127, 64, 99, 98, 97));
|
||
check('a long frame is read as far as we understand it',
|
||
/in −12 dB/.test(meters.textContent)
|
||
&& /cpu 50\.6%/.test(meters.textContent),
|
||
meters.textContent);
|
||
|
||
// A frame with nothing in it at all must not throw or print rubbish
|
||
app.handleTelemetry(frame());
|
||
|
||
//
|
||
// Pot curves. Every curve has to survive the round trip, because the
|
||
// EQ converts both ways constantly - screen position to pot value on the
|
||
// way in, pot value to a frequency to draw on the way out - and a curve
|
||
// that does not come back to where it started makes a node creep every
|
||
// time it is touched.
|
||
//
|
||
for (const pot of [{ curve: 'LINEAR', min: -20, max: 20 },
|
||
{ curve: 'LINEAR', min: -40, max: 0 },
|
||
{ curve: 'FREQUENCY', min: 20, max: 400 },
|
||
{ curve: 'SQUARED', min: 0, max: 100 },
|
||
{ curve: 'EXPONENTIAL', min: 20, max: 20000 }]) {
|
||
let bad = 0;
|
||
for (let v = 0; v <= 120; v++)
|
||
if (app.valueToPot(pot, app.potToValue(pot, v)) !== v)
|
||
bad++;
|
||
check(`${pot.curve}(${pot.min} ${pot.max}) round-trips`, bad === 0,
|
||
`${bad} of 121 values`);
|
||
}
|
||
|
||
// And the app has to agree with the generator about where a default sits
|
||
let defaultsDiffer = 0;
|
||
for (const eff of schema)
|
||
for (const pot of eff.pots)
|
||
if (pot.defaultPot !== undefined
|
||
&& app.valueToPot(pot, pot.default) !== pot.defaultPot)
|
||
defaultsDiffer++;
|
||
check('defaults agree with gen_effects.py', defaultsDiffer === 0,
|
||
`${defaultsDiffer} pots disagree`);
|
||
|
||
//
|
||
// The EQ's ordering rules. A band may reach its neighbours and no
|
||
// further, and the ends of the chain are bounded by the pot instead.
|
||
//
|
||
const order = [10, 30, 30, 60, 100];
|
||
|
||
check('a band stops at the neighbour above',
|
||
app.clampToNeighbours(order, 0, 90) === 30);
|
||
check('and at the neighbour below',
|
||
app.clampToNeighbours(order, 3, 5) === 30);
|
||
check('an unobstructed band moves where it was asked',
|
||
app.clampToNeighbours(order, 3, 45) === 45);
|
||
check('the first band is bounded by the bottom of the pot',
|
||
app.clampToNeighbours(order, 0, -5) === 0);
|
||
check('the last by the top of it',
|
||
app.clampToNeighbours(order, 4, 999) === 120);
|
||
|
||
// Whatever it is asked for, the result never crosses a neighbour
|
||
let crossed = 0;
|
||
for (let idx = 0; idx < 5; idx++)
|
||
for (let want = -20; want <= 140; want++) {
|
||
const got = app.clampToNeighbours(order, idx, want);
|
||
const next = order.slice();
|
||
next[idx] = got;
|
||
for (let i = 1; i < next.length; i++)
|
||
if (next[i] < next[i - 1]) crossed++;
|
||
}
|
||
check('no request of any size can put the bands out of order', crossed === 0,
|
||
`${crossed} orderings broken`);
|
||
|
||
//
|
||
// Which of a pile you get. Equality is what makes a pile, so bands
|
||
// that merely sit close are not one.
|
||
//
|
||
check('a band on its own is a pile of one',
|
||
app.pileAt(order, 0).join() === '0');
|
||
check('bands at the same value are a pile',
|
||
app.pileAt(order, 1).join() === '1,2'
|
||
&& app.pileAt(order, 2).join() === '1,2');
|
||
check('going left takes the lowest, which is the one free to move',
|
||
app.pileAt(order, 2)[0] === 1);
|
||
check('going right takes the highest',
|
||
app.pileAt(order, 1).slice(-1)[0] === 2);
|
||
|
||
//
|
||
// Every effect that declares a graph has to actually draw one.
|
||
//
|
||
// This is the check that was missing when the curve code carried a
|
||
// hardcoded band count: the five-band EQ went on working and a two-band
|
||
// effect drew nothing at all, silently, because the guard that waits for
|
||
// the pots to arrive was written as "fewer than ten".
|
||
//
|
||
const canvasOf = (idx) => {
|
||
const card = document.getElementById(`effect-${idx}`);
|
||
const controls = card.children.find(
|
||
(c) => (c.className || '').includes('eq-container'));
|
||
const wrapper = controls.children.find(
|
||
(c) => (c.className || '').includes('eq-curve-wrapper'));
|
||
return wrapper.querySelector(`#eq-canvas-${idx}`);
|
||
};
|
||
|
||
let graphed = 0;
|
||
schema.forEach((eff, idx) => {
|
||
if (!eff.graph || !eff.graph.length)
|
||
return;
|
||
graphed++;
|
||
|
||
const ctx = canvasOf(idx).getContext('2d');
|
||
const before = ctx.calls.length;
|
||
|
||
// Any parameter update redraws the curve
|
||
app.handleSysex([0xF0, 0x7D, 0x03, eff.id, 1, 60, 0xF7]);
|
||
const drew = ctx.calls.slice(before);
|
||
|
||
check(`${eff.name} draws its response`, drew.includes('stroke'),
|
||
`${drew.length} drawing calls`);
|
||
check(`${eff.name} draws one node per band`,
|
||
drew.filter((c) => c === 'arc').length === eff.graph.length,
|
||
`${drew.filter((c) => c === 'arc').length} of ${eff.graph.length}`);
|
||
});
|
||
check('the schema has graphed effects to check', graphed > 0);
|
||
|
||
// Q has to come from the declaration, or the picture is not the filter
|
||
let badQ = 0;
|
||
for (const eff of schema)
|
||
for (const band of eff.graph || [])
|
||
if (!(band.q > 0) && band.qPot === undefined) badQ++;
|
||
check('every graphed band declares a usable Q', badQ === 0, `${badQ} without one`);
|
||
|
||
//
|
||
// The pedal filters incoming MIDI by one pot, and the app has to
|
||
// transmit on the same channel or bypass, the tuner and scene changes
|
||
// stop arriving. Which pot that is comes from the schema now, so the
|
||
// schema has to actually say.
|
||
//
|
||
const channels = schema.filter((e) => e.roles && e.roles.CHANNEL !== undefined);
|
||
check('exactly one effect owns the MIDI channel', channels.length === 1,
|
||
`${channels.length} claim it`);
|
||
if (channels.length === 1) {
|
||
const eff = channels[0];
|
||
check('and it points at a pot that exists',
|
||
eff.pots[eff.roles.CHANNEL] !== undefined);
|
||
check('with the sixteen channels and omni to choose from',
|
||
(eff.pots[eff.roles.CHANNEL].enum || []).length === 17,
|
||
`${(eff.pots[eff.roles.CHANNEL].enum || []).length} options`);
|
||
}
|
||
|
||
//
|
||
// A pot with no node on the graph has to be reachable. Graphed effects
|
||
// hide their sliders because the nodes are the controls, and a pot
|
||
// outside the band pairs would otherwise be hidden along with them -
|
||
// present, working, and impossible to touch.
|
||
//
|
||
schema.forEach((eff, idx) => {
|
||
const spare = eff.pots.length - (eff.graph || []).length * 2;
|
||
if (!eff.graph || !eff.graph.length || spare <= 0)
|
||
return;
|
||
|
||
const card = document.getElementById(`effect-${idx}`);
|
||
const controls = card.children.find(
|
||
(c) => (c.className || '').includes('eq-container'));
|
||
const hidden = controls.children.find(
|
||
(c) => (c.className || '').includes('eq-sliders'));
|
||
const footer = controls.children.find(
|
||
(c) => (c.className || '').includes('eq-footer'));
|
||
|
||
check(`${eff.name} hides only the pots the graph draws`,
|
||
hidden.children.length === eff.graph.length * 2,
|
||
`${hidden.children.length} hidden, ${eff.graph.length * 2} banded`);
|
||
check(`${eff.name} puts its ${spare} spare pot(s) where they can be used`,
|
||
footer.children.filter(
|
||
(c) => (c.className || '').includes('pot-control')).length
|
||
=== spare + 1, // and the Mix control
|
||
`${footer.children.length} in the footer`);
|
||
});
|
||
|
||
//
|
||
// UI preferences. The interesting case is storage that refuses to work
|
||
// at all, which is a real browser configuration and must not take the
|
||
// app down with it - these are preferences, and the cost of losing one
|
||
// is that it goes back to its default.
|
||
//
|
||
check('an unset preference is its default',
|
||
app.uiPref('nothing.here', true) === true
|
||
&& app.uiPref('nothing.here', false) === false);
|
||
|
||
const store = new Map();
|
||
global.localStorage = {
|
||
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
||
setItem: (k, v) => store.set(k, v),
|
||
};
|
||
app.setUiPref('eq.keepOrder', false);
|
||
check('a preference survives a write and a read',
|
||
app.uiPref('eq.keepOrder', true) === false);
|
||
app.setUiPref('eq.keepOrder', true);
|
||
check('and back again', app.uiPref('eq.keepOrder', false) === true);
|
||
|
||
global.localStorage = {
|
||
getItem() { throw new Error('denied'); },
|
||
setItem() { throw new Error('denied'); },
|
||
};
|
||
check('storage that refuses to read still yields the default',
|
||
app.uiPref('eq.keepOrder', true) === true);
|
||
app.setUiPref('eq.keepOrder', false); // and refusing to write is not fatal
|
||
|
||
if (failures) {
|
||
say(`test-webmidi: ${failures} failure(s)`);
|
||
process.exit(1);
|
||
}
|
||
say('test-webmidi: app loads; identity, scenes and telemetry behave');
|