/** * End-to-end self test without Companion: the real src/ pieces (instance class, state, protocol, definitions, * link, relay) against the mock console and a fake iPad. * * npm test (node test/selftest.js) * S31_TEST_PORT_BASE=28000 npm test (another port range: 18000 is taken on the Companion computer) * * Every port is derived from S31_TEST_PORT_BASE (default 18000); all traffic is on 127.0.0.1: * main: mock console listens BASE, replies to BASE+1000 (the module's link, the console's "send port") * module relay binds BASE+1, fans out to the fake iPad on BASE+1001 * second: a second mock (--delay 150) on BASE+10 replying to BASE+1010, relay BASE+11, iPad BASE+1011 * spare: BASE+20 .. BASE+29 for the bind-failure and config checks */ import { spawn } from 'node:child_process' import dgram from 'node:dgram' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import { ConsoleLink } from '../src/console-link.js' import { Relay } from '../src/relay.js' import { encode, decode, foldPattern } from '../src/osc.js' import { State } from '../src/state.js' import { FADE_CURVES, LEVEL, PARAMS, adjustedLevel, cleanName, fadeLevelAt, faderPosition, fmtCue, fmtDb, fmtMeterLevel, fmtPan, indexProblem, meterGrDb, meterId, meterIdForInput, meterLevelDb, meterPosition, outputMeterId, parse, positionToDb } from '../src/protocol.js' import { BYTES_PER_PIXEL, horizontalBar, verticalMeter, thicknessFor } from '../src/image.js' import { MAX_PRESETS, MAX_PRESET_BYTES } from '../src/definitions/presets.js' import { COLORS } from '../src/definitions/feedbacks.js' const COLORS_GREEN = COLORS.green const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const BASE = Number(process.env.S31_TEST_PORT_BASE) || 18000 const P = { mock: BASE, relay: BASE + 1, link: BASE + 1000, ipad: BASE + 1001, mockB: BASE + 10, relayB: BASE + 11, linkB: BASE + 1010, ipadB: BASE + 1011, spare: BASE + 20 } console.log(`info test ports: main mock ${P.mock}, link ${P.link}, relay ${P.relay}, iPad ${P.ipad}; second mock ${P.mockB} (base ${BASE})`) const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) let failures = 0 function check(ok, what) { console.log(`${ok ? 'PASS' : 'FAIL'} ${what}`) if (!ok) failures++ } const near = (a, b, eps = 1e-3) => typeof a === 'number' && Math.abs(a - b) < eps // --------------------------------------------------------------------------- // Recording: every message the module sends through its console link // --------------------------------------------------------------------------- const sent = [] const originalSend = ConsoleLink.prototype.send ConsoleLink.prototype.send = function (address, args = []) { if (!this.noRecord) sent.push({ address, args, at: Date.now() }) return originalSend.call(this, address, args) } const logs = [] globalThis.COMPANION_LOGGER = (_source, level, message) => logs.push({ level, message }) const logCount = (re) => logs.filter((l) => re.test(l.message)).length async function waitFor(fn, ms = 20000, step = 25) { const end = Date.now() + ms while (Date.now() < end) { if (fn()) return true await sleep(step) } return !!fn() } /** What Companion's host would give an instance, recording what the module registers. */ function makeContext() { const ctx = { _isInstanceContext: true, id: 'selftest', label: 'S31', actions: {}, feedbacks: {}, presets: {}, presetStructure: [], variableDefs: {}, variables: {}, statuses: [], checks: [], // checkFeedbacks (by type): [time, ids] byId: [], // checkFeedbacksById: [time, ids] rebuilds: 0, pushes: { actions: 0, feedbacks: 0, variables: 0, presets: 0 }, setActionDefinitions(a) { ctx.actions = a ctx.rebuilds++ ctx.pushes.actions++ }, setFeedbackDefinitions(f) { ctx.feedbacks = f ctx.pushes.feedbacks++ }, setPresetDefinitions(structure, presets) { ctx.presetStructure = structure ctx.presets = presets ctx.pushes.presets++ }, setCompositeElementDefinitions() {}, setVariableDefinitions(v) { ctx.variableDefs = v ctx.pushes.variables++ }, setVariableValues: (v) => Object.assign(ctx.variables, v), getVariableValue: (id) => ctx.variables[id], checkAllFeedbacks() {}, checkFeedbacks: (ids) => ctx.checks.push([Date.now(), ids]), checkFeedbacksById: (ids) => ctx.byId.push([Date.now(), ids]), subscribeActions() {}, unsubscribeActions() {}, unsubscribeFeedbacks() {}, recordAction() {}, oscSend() {}, saveConfig() {}, updateStatus: (status, message) => ctx.statuses.push([status, message]), } return ctx } // --------------------------------------------------------------------------- // 0. Imports, config fields, upgrade script // --------------------------------------------------------------------------- const mainMod = await import('../src/main.js') const Instance = mainMod.default check(typeof Instance === 'function' && Array.isArray(mainMod.UpgradeScripts), 'src/main.js imports and exports a default class + UpgradeScripts') { const probe = new Instance(makeContext()) const fields = probe.getConfigFields() const ids = fields.filter((f) => f.type !== 'static-text').map((f) => f.id).sort() const expected = ['consolePort', 'debugLog', 'host', 'localPort', 'relayClientPort', 'relayEnable', 'relayPort', 'relayTimeout'] check(JSON.stringify(ids) === JSON.stringify(expected), `config fields are exactly the eight ids (${ids.join(', ')})`) check(fields.filter((f) => f.type === 'static-text').length === 2, 'config has two static-text explanations') check(fields.every((f) => f.width >= 1 && f.width <= 12), 'every config field has a width 1-12') check(!/studio|10\.\d+\.\d+\.\d+|mon\b/i.test(JSON.stringify(fields)), 'config wording is generic (no site names or IPs)') } { const v1 = { host: '192.0.2.50', consolePort: 8100, localPort: 9100, dialect: 'sd', boolStyle: 'int', keepalive: true, channels: 48, busses: 25, monSend: 24, monMode: 'auto', monLabel: 'Monitor', relayEnable: false, relayPort: 8200, relayClientPort: 9200, relayTimeout: 90, learnLog: true, verboseLog: false, } const script = mainMod.UpgradeScripts[0] check(mainMod.UpgradeScripts.length === 1, 'exactly one upgrade script') const out = script({ currentConfig: v1 }, { config: v1, secrets: null, actions: [], feedbacks: [] }) const want = { host: '192.0.2.50', consolePort: 8100, localPort: 9100, relayEnable: false, relayPort: 8200, relayClientPort: 9200, relayTimeout: 90, debugLog: false } check(JSON.stringify(out.updatedConfig) === JSON.stringify(want), 'v1 config upgrades to the eight v2 fields (the rest dropped; learnLog alone does not turn debugLog on)') const verbose = script({ currentConfig: v1 }, { config: { ...v1, verboseLog: true }, secrets: null, actions: [], feedbacks: [] }) check(verbose.updatedConfig.debugLog === true, 'verboseLog -> debugLog on') const oldDefault = script({ currentConfig: v1 }, { config: { ...v1, relayTimeout: 180 }, secrets: null, actions: [], feedbacks: [] }) check(oldDefault.updatedConfig.relayTimeout === 3600 && out.updatedConfig.relayTimeout === 90, 'the old default idle timeout (180 s) becomes 3600 s; a custom value stays') const none = script({ currentConfig: {} }, { config: null, secrets: null, actions: [], feedbacks: [] }) check(none.updatedConfig === null, 'upgrade script tolerates a null config') // v1 actions and feedbacks reused or replaced in v2 const v = (value) => ({ value, isExpression: false }) const act = (id, actionId, options) => ({ id, controlId: 'ctl', actionId, options }) const fbk = (id, feedbackId, options, extra = {}) => ({ id, controlId: 'ctl', feedbackId, options, ...extra }) const actions = [ act('a1', 'send_on', { channel: v(3), send: v(24), state: v('toggle') }), act('a2', 'send_level', { channel: v(3), send: v(5), level: v(-10) }), act('a3', 'send_nudge', { channel: v(3), send: v(5), delta: v(1) }), act('a4', 'send_pan', { channel: v(3), send: v(5), pan: v(0.3) }), act('a5', 'group_assign', { channel: v(4), group: v(24), state: v('on') }), act('a6', 'snapshot_fire', { number: v(2) }), act('a7', 'buss_mute', { buss: v(6), state: v('toggle') }), act('a8', 'buss_fader', { buss: v(6), level: v(-3) }), act('a9', 'mute', { channel: v(7), state: v('toggle') }), act('a10', 'fader', { channel: v(7), level: v(-5) }), act('a11', 'mon_on', { channel: v(8), state: v('toggle') }), act('a12', 'mon_level', { channel: v(8), level: v(-12) }), act('a13', 'mon_nudge', { channel: v(8), delta: v(-2) }), act('a14', 'raw_osc', { address: v('/Console/Name/?'), argType: v('none'), value: v('') }), act('a15', 'snapshot_next', {}), act('a16', 'mute', { channel: { value: '$(local:ch)', isExpression: true }, state: v('on') }), act('a17', 'resend', {}), ] const feedbacks = [ fbk('f1', 'send_on', { channel: v(3), send: v(24) }, { style: { bgcolor: 1 }, isInverted: v(true) }), fbk('f2', 'group_assigned', { channel: v(4), group: v(24) }), fbk('f3', 'muted', { channel: v(7) }), fbk('f4', 'buss_muted', { buss: v(6) }), fbk('f5', 'mon_on', { channel: v(8) }), fbk('f6', 'mon_level_above', { channel: v(8), threshold: v(-40) }), fbk('f7', 'connected', {}), ] const map = (cfg) => script({ currentConfig: cfg }, { config: cfg, secrets: null, actions: structuredClone(actions), feedbacks: structuredClone(feedbacks) }) const aux = map({ ...v1, monMode: 'auto' }) const A = (id) => aux.updatedActions.find((a) => a.id === id) const F = (id) => aux.updatedFeedbacks.find((f) => f.id === id) const same = (a, b) => JSON.stringify(a) === JSON.stringify(b) check(A('a1').actionId === 'send_on' && same(A('a1').options, { channel: v(3), buss: v(24), state: v('toggle') }), 'v1 send_on {channel, send, state} -> v2 send_on {channel, buss, state}') check(A('a2').actionId === 'send_level_set' && same(A('a2').options, { channel: v(3), buss: v(5), level: v(-10) }), 'v1 send_level -> send_level_set') check(A('a3').actionId === 'send_level_adjust' && same(A('a3').options, { channel: v(3), buss: v(5), delta: v(1) }), 'v1 send_nudge -> send_level_adjust') check(A('a4').actionId === 'send_pan_set' && same(A('a4').options, { channel: v(3), buss: v(5), pan: v(0.3) }), 'v1 send_pan -> send_pan_set') check(A('a5').actionId === 'group_assign' && same(A('a5').options, { channel: v(4), buss: v(24), state: v('on') }), 'v1 group_assign {group} -> v2 group_assign {buss}') check(A('a6').actionId === 'snapshot_fire' && same(A('a6').options, { snapshot: v(2) }), 'v1 snapshot_fire {number} -> {snapshot}') check(A('a7').actionId === 'buss_mute' && same(A('a7').options, { busses: v([6]), state: v('toggle') }), 'v1 buss_mute {buss} -> {busses: [buss]}') check(A('a8').actionId === 'buss_fader_set' && same(A('a8').options, { buss: v(6), level: v(-3) }), 'v1 buss_fader -> buss_fader_set') check(A('a9').actionId === 'input_mute' && same(A('a9').options, { channels: v([7]), state: v('toggle') }), 'v1 mute {channel} -> input_mute {channels: [channel]}') check(A('a10').actionId === 'input_fader_set' && same(A('a10').options, { channel: v(7), level: v(-5) }), 'v1 fader -> input_fader_set') check(A('a11').actionId === 'send_on' && same(A('a11').options, { channel: v(8), state: v('toggle'), buss: v(24) }), 'v1 mon_on (monMode auto) -> aux send_on on monSend') check(A('a12').actionId === 'send_level_set' && A('a12').options.buss.value === 24 && A('a13').actionId === 'send_level_adjust' && A('a13').options.delta.value === -2, 'v1 mon_level / mon_nudge -> send_level_set / send_level_adjust on monSend') check(A('a14').actionId === 'raw_osc' && A('a15').actionId === 'snapshot_next' && A('a17').actionId === 'reload', 'ids that did not change stay; resend -> reload') check(A('a16').actionId === 'mute', 'an action whose option is an expression cannot be wrapped and is left as it is') check(aux.updatedActions.every((a, i) => a.id === actions[i].id && a.controlId === 'ctl') && actions[0].actionId === 'send_on' && actions[0].options.send !== undefined, 'ids and control ids are kept, and the input is not mutated') check(F('f1').feedbackId === 'send_on' && same(F('f1').options, { channel: v(3), buss: v(24) }) && F('f1').style.bgcolor === 1 && F('f1').isInverted.value === true, 'v1 feedback send_on {send} -> {buss}, style and invert kept') check(F('f2').feedbackId === 'group_assigned' && same(F('f2').options, { channel: v(4), buss: v(24) }), 'v1 group_assigned {group} -> {buss}') check(F('f3').feedbackId === 'input_muted' && same(F('f3').options, { channel: v(7) }) && F('f4').feedbackId === 'buss_muted' && F('f7').feedbackId === 'connected', 'v1 muted -> input_muted; buss_muted and connected unchanged') check(F('f5').feedbackId === 'send_on' && F('f5').options.buss.value === 24 && F('f6').feedbackId === 'send_level_compare' && F('f6').options.comparison.value === 'above' && F('f6').options.threshold.value === -40, 'v1 mon_on / mon_level_above (auto) -> send_on / send_level_compare on monSend') const grp = map({ ...v1, monMode: 'group', monSend: 12 }) const G = (id) => grp.updatedActions.find((a) => a.id === id) check(G('a11').actionId === 'group_assign' && same(G('a11').options, { channel: v(8), state: v('toggle'), buss: v(12) }), 'v1 mon_on with monMode group -> group_assign on monSend') check(G('a12').actionId === 'mon_level' && G('a13').actionId === 'mon_nudge', 'v1 mon_level / mon_nudge have no group equivalent and stay as they are') check(grp.updatedFeedbacks.find((f) => f.id === 'f5').feedbackId === 'group_assigned' && grp.updatedFeedbacks.find((f) => f.id === 'f6').feedbackId === 'mon_level_above', 'v1 mon_on feedback with monMode group -> group_assigned; mon_level_above stays') } // --------------------------------------------------------------------------- // 1. Codec, protocol and image helpers // --------------------------------------------------------------------------- { const buf = encode('/Input_Channels/3/Aux_Send/2/send_on', [{ i: 1 }]) const m = decode(buf) check(m.address === '/Input_Channels/3/Aux_Send/2/send_on' && m.tags === ',i' && m.args[0] === 1, 'OSC encode/decode int') const f = decode(encode('/x', [-12.5, 'hi', true, false, null])) check(near(f.args[0], -12.5, 1e-6) && f.args[1] === 'hi' && f.args[2] === true && f.args[3] === false && f.args[4] === null, 'OSC encode/decode float/string/T/F/N') const short = decode(buf.subarray(0, buf.length - 2)) check(short.unpadded === true && short.address === m.address, 'decoder tolerates an unpadded datagram') check(decode(Buffer.from('garbage')).raw !== undefined, 'decoder returns raw for non-OSC bytes') } { check(adjustedLevel(-10, 1) === -9 && adjustedLevel(5, 30) === LEVEL.max && adjustedLevel(-150, 1) === -60 && adjustedLevel(-150, -1) === LEVEL.min && adjustedLevel(-89.5, 1) === -88.5 && adjustedLevel(-88, -5) === LEVEL.min, 'adjustedLevel clamps, restarts at -60 from -inf, drops to -inf below the floor') check(fmtDb(-150) === '-inf' && fmtDb(-3.04) === '-3' && fmtDb(0) === '0' && fmtDb(-0.04) === '0' && fmtDb(2.25) === '2.3', 'fmtDb: -inf at the floor, one decimal, no -0') check(fmtPan(0.5) === 'C' && fmtPan(0) === 'L100' && fmtPan(1) === 'R100' && fmtPan(0.25) === 'L50', 'fmtPan') check(fmtCue(100) === '1' && fmtCue(250) === '2.5', 'fmtCue (raw cue number x 100)') check(parse('/Input_Channels/7/Aux_Send/3/send_pre-post', [1]) === null, 'send_pre-post is not parsed (its meaning was never verified)') check(parse('/Control_Groups/3/name', ['KEYS']).n === 3 && parse('/Snapshots/name', [3, 400, 0, 'X']).cue === 400 && parse('/Meters/values', [5001, 10, 5002, 20]).pairs.length === 2, 'parse: control group name, snapshot name, meters') check(parse('/Console/Aux_Outputs', [25]).field === 'busses' && parse('/Nonexistent/thing', [1]) === null, 'parse: counts, unknown address -> null') } { // the fader taper used by every position feedback const anchors = [[10, 100], [5, 87.5], [0, 75], [-5, 65], [-10, 55], [-20, 40], [-30, 28], [-40, 18], [-50, 11], [-60, 6]] check(anchors.every(([db, pos]) => near(faderPosition(db), pos, 1e-9)) && faderPosition(25) === 100, 'faderPosition hits every anchor: +10 = 100, +5 = 87.5, 0 = 75, -5 = 65, -10 = 55, -20 = 40, -30 = 28, -40 = 18, -50 = 11, -60 = 6') check(near(faderPosition(-7.5), 60) && near(faderPosition(2.5), 81.25) && near(faderPosition(-15), 47.5) && near(faderPosition(-75), 3.5), 'faderPosition interpolates linearly between the anchors') check(faderPosition(-90) === 0 && faderPosition(-120) === 0 && faderPosition(-150) === 0 && near(faderPosition(-89.99), 1, 0.01), 'at or below the -90 floor the position is 0, just above it about 1') check(faderPosition(-150, -150) === 0 && near(faderPosition(-149.99, -150), 1, 0.01) && near(faderPosition(-60, -150), 6) && faderPosition(-100, -150) > 1, 'with a -150 floor the taper runs down to -150') check(faderPosition(NaN) === 0 && faderPosition(undefined) === 0 && faderPosition('x') === 0 && faderPosition(null) === 0, 'non-numbers read 0') let monotone = true for (const floor of [-90, -150]) { let last = -1 for (let db = floor; db <= 12; db += 0.25) { const v = faderPosition(db, floor) if (v < last || v < 0 || v > 100) monotone = false last = v } for (let db = floor + 0.25; db < 10; db += 0.25) if (!(faderPosition(db, floor) < faderPosition(db + 0.25, floor))) monotone = false } check(monotone, 'faderPosition is monotonic (strictly rising up to +10) and stays within 0..100') } { const h = horizontalBar({ width: 72, height: 58, fraction: 0.5, color: 0x00c85a }) const raw = Buffer.from(h.imageBuffer, 'base64') const t = thicknessFor(58) check(raw.length === 72 * t * BYTES_PER_PIXEL && h.imageBufferPosition.height === t && h.imageBufferPosition.y === 58 - t && h.imageBufferEncoding.pixelFormat === 'RGBA', `horizontal bar buffer is width x strip x 4 bytes (${raw.length} bytes)`) let filled = 0 for (let x = 0; x < 72; x++) if (raw[x * 4] === 0x00 && raw[x * 4 + 1] === 0xc8 && raw[x * 4 + 2] === 0x5a) filled++ check(filled === 36, `horizontal bar at 50 % fills half the width (${filled}/72 px)`) const v = verticalMeter({ width: 72, height: 72, fraction: 1 }) check(Buffer.from(v.imageBuffer, 'base64').length === thicknessFor(72) * 72 * BYTES_PER_PIXEL && v.imageBufferPosition.x === 72 - thicknessFor(72), 'vertical meter strip sits at the right edge with the right length') } // --------------------------------------------------------------------------- // 2. State: existence, hiding, choices (no network) // --------------------------------------------------------------------------- { const s = new State() check(s.numbers('input').length === 60, 'before anything is known every possible input is offered') for (const n of [1, 2, 3]) s.applyMessage(`/Input_Channels/${n}/Channel_Input/name`, [`Ch ${n}`]) s.loaded = true check(s.numbers('input').join() === '1,2,3', 'inputs whose name never answered are hidden once loaded') s.applyMessage('/Console/Input_Channels', [2]) check(s.numbers('input').join() === '1,2', 'the console count also limits the inputs') check(s.numbers('cg').length === 0 && s.numbers('geq').length === 0, 'entity kinds with no answers are empty after loading') s.applyMessage('/Aux_Outputs/5/Buss_Trim/name', ['Wedge']) s.applyMessage('/Group_Outputs/5/Buss_Trim/name', ['']) s.applyMessage('/Aux_Outputs/6/Buss_Trim/name', ['']) s.applyMessage('/Group_Outputs/6/Buss_Trim/name', ['Band']) check(s.bussMode(5) === 'aux' && s.bussMode(6) === 'group', 'without the types array the buss mode follows which side carries the name') s.applyMessage('/Console/Aux_Outputs/types', [1, 1, 1, 1, 0, 1]) check(s.bussMode(5) === 'group' && s.bussMode(6) === 'aux', 'the types array (1 = aux, 0 = group) overrides the name heuristic') } { // names arrive padded and wrapped: trimmed, whitespace runs collapsed, once, in the state const s = new State() check(cleanName(' LEAD V RESI \t') === 'LEAD V RESI' && cleanName('') === '' && cleanName(' ') === '' && cleanName(undefined) === '', 'cleanName trims and collapses whitespace runs') const e1 = s.applyMessage('/Input_Channels/3/Channel_Input/name', ['MD RESI ']) s.applyMessage('/Matrix_Inputs/8/Channel_Input/name', ['Dante In 63 ']) s.applyMessage('/Control_Groups/2/name', [' MC & SPEAKER']) s.applyMessage('/Snapshots/name', [0, 100, 0, ' Pre Service ']) s.applyMessage('/Console/Name', ['S31 AB ']) s.loaded = true check(s.rawName('input', 3) === 'MD RESI' && e1.value === 'MD RESI' && e1.changed === true && s.rawName('minput', 8) === 'Dante In 63' && s.rawName('cg', 2) === 'MC & SPEAKER' && s.snapshotName(0) === 'Pre Service' && s.console.name === 'S31 AB', 'input, matrix input, control group, snapshot and console names are cleaned when they arrive') check(s.applyMessage('/Input_Channels/3/Channel_Input/name', ['MD RESI']).changed === false && s.applyMessage('/Input_Channels/3/Channel_Input/name', ['MD RESI']).structural === false, 'a name that only differs in padding is no change (no rebuild)') check(s.choices.inputs()[0].label === '3 · MD RESI' && s.choices.matrixInputs()[0].label === '8 · Dante In 63' && s.displayName('minput', 8) === 'Dante In 63' && s.displayName('minput', 3) === 'Matrix In 3', 'dropdown labels use the cleaned names; an unnamed matrix input reads "Matrix In N"') s.applyMessage('/Matrix_Inputs/4/Channel_Input/name', [' ']) check(s.has('minput', 4) && s.rawName('minput', 4) === '' && s.displayName('minput', 4) === 'Matrix In 4', 'a blank matrix input name still exists and falls back to "Matrix In N"') } // --------------------------------------------------------------------------- // 3. Mock console, the real instance, a fake iPad // --------------------------------------------------------------------------- // Child mocks are killed on every way out (a crashing test must not leave one holding a port for the next run) const children = [] const killChildren = () => { for (const c of children) { try { c.kill('SIGKILL') } catch { /* already gone */ } } } process.on('exit', killChildren) for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => process.exit(130)) function startMock(listen, replyPort, extra = []) { const child = spawn(process.execPath, [path.join(root, 'tools/mock-s31.js'), '--dialect', 'sd', '--listen', String(listen), '--reply-to', `127.0.0.1:${replyPort}`, '--quiet', ...extra], { stdio: ['ignore', 'pipe', 'pipe'] }) child.output = '' child.stdout.on('data', (d) => (child.output += d)) child.stderr.on('data', (d) => (child.output += d)) children.push(child) return child } const mock = startMock(P.mock, P.link) await sleep(400) if (mock.exitCode !== null) { console.log(`FAIL the mock console could not start on UDP ${P.mock} (is another run still holding it?): ${mock.output.trim()}`) process.exit(1) } const ctx = makeContext() const inst = new Instance(ctx) Object.assign(inst.timing, { txPaceMs: 3, settleMs: 150, rebuildDebounceMs: 150, replyWaitMs: 1500, flushMs: 15, resubscribeMs: 200 }) const config = { host: '127.0.0.1', consolePort: P.mock, localPort: P.link, relayEnable: true, relayPort: P.relay, relayClientPort: P.ipad, relayTimeout: 60, debugLog: false } await inst.init(config) check(await waitFor(() => logCount(/load complete/) >= 1), 'initial load completed (load complete logged)') const loadLine = logs.find((l) => /load complete/.test(l.message))?.message console.log(`info ${loadLine}`) await sleep(100) const loadSent = sent.length const S = inst.state check(ctx.statuses.at(-1)?.[0] === 'ok', `status is ok after the first reply (${ctx.statuses.at(-1)?.[0]})`) check(S.counts.inputs === 60 && S.counts.busses === 25 && S.counts.matrix === 8 && S.counts.cgs === 10 && S.counts.geqs === 16, `counts loaded (${JSON.stringify(S.counts)})`) check(S.numbers('input').length === 60 && S.numbers('buss').length === 25 && S.numbers('matrix').length === 8 && S.numbers('cg').length === 10 && S.numbers('geq').length === 16, 'every entity that answered a name is present') check(S.snapshots.list.size === 8 && S.snapshots.current === 3 && S.snapshots.count === 8, `snapshots loaded (${S.snapshots.list.size} listed, current ${S.snapshots.current})`) // Names -> dropdown choices const inputChoices = S.choices.inputs() check(inputChoices.length === 60 && inputChoices[0].id === 1 && inputChoices[0].label === '1 · KICK In', `input choices carry live names (${JSON.stringify(inputChoices[0])})`) const bussChoices = S.choices.busses() check(bussChoices.find((c) => c.id === 4)?.label === '4 · CLK-GD-MD (aux)' && bussChoices.find((c) => c.id === 24)?.label === '24 · Studio (group)', 'buss choices carry names and the aux/group mode') check(S.choices.auxChoices().every((c) => S.bussMode(c.id) === 'aux') && S.choices.groupChoices().map((c) => c.id).join() === '9,10,11,12,13,18,23,24,25', `group choices are exactly the group busses (${S.choices.groupChoices().map((c) => c.id).join()})`) check(S.snapshots.list.get(3)?.name === 'Worship 2' && S.choices.snapshots()[3].label === '3 · Worship 2 · cue 4', `snapshot choices: ${JSON.stringify(S.choices.snapshots()[3])}`) // Buss mode from the types array, stereo from modes, control groups 1-based check(S.busses.get(9).mode === 'group' && S.busses.get(4).mode === 'aux' && S.busses.get(22).mode === 'aux', 'buss mode comes from /Console/Aux_Outputs/types (9 group, 4 and 22 aux)') check(S.busses.get(1).stereo === true && S.busses.get(4).stereo === false, 'buss stereo/mono comes from the modes array (1 stereo, 4 mono)') check(S.cgs.get(1)?.name === 'DRUMS' && S.cgs.get(10)?.name === '' && S.cgs.get(0) === undefined && S.choices.cgs()[0].id === 1 && S.choices.cgs()[0].label === '1 · DRUMS', 'control groups are 1-based (1 = DRUMS, 10 = last, no 0)') check(!sent.some((m) => /Control_Groups\/0\//.test(m.address)), 'the module never addresses control group 0') check(!sent.slice(0, loadSent).some((m) => /^\/Console\/(Input_Channels|Aux_Outputs|Group_Outputs)\/\?$/.test(m.address)), 'the unanswered Input_Channels / Aux_Outputs / Group_Outputs count queries are never sent') const mIn = S.choices.matrixInputs() check(mIn.length === 10 && mIn[0].label === '1 · TB' && mIn[1].label === '2 · Studio L' && mIn[6].label === '7 · Studio TB' && mIn[7].label === '8 · Dante In 63' && mIn[9].label === '10 · Matrix In 10', `matrix inputs have names: dropdown labels "N · name", padding and runs of spaces cleaned, "Matrix In N" when empty (${mIn.map((c) => c.label).join(' | ')})`) check(bussChoices.find((c) => c.id === 17)?.label === '17 · Studio TB (aux)' && S.rawName('buss', 17) === 'Studio TB', 'a padded buss name ("Studio TB") shows with single spaces in the dropdown') check(sent.slice(0, loadSent).some((m) => m.address === '/Matrix_Inputs/10/Channel_Input/name/?') && !sent.some((m) => /^\/Matrix_Inputs\/\d+\/(Buss_Trim\/name|name)\/\?$/.test(m.address)), 'matrix input names are queried at /Matrix_Inputs/N/Channel_Input/name') check(!sent.slice(0, loadSent).some((m) => /\/(Filters|Channel_Delay|Insert|Dynamics|EQ)\/|phantom|input_pad|analog_gain|\/trim|\/phase|\/Aux_Send\//.test(m.address)), 'channel parameters and sends are not bulk-loaded at connect') check(S.matrix.get(1)?.name === 'MAINS L' && S.has('minput', 1) && S.minputs.get(10).name === '' && S.numbers('minput').length === 10, 'matrix outputs and matrix inputs are named (input 10 has an empty name and still exists)') check(S.inputs.get(7).stereo === true && S.inputs.get(1).stereo === false, 'input stereo flags come from /Console/Input_Channels/modes') check(S.console.name === 'S31 AB' && S.console.session === 'Mock Session.session', 'console and session name loaded') // Only addresses the catalog shows the real console answering { const cat = JSON.parse(fs.readFileSync(path.join(root, 'tools/catalog/s31-catalog.json'), 'utf8')) const answered = new Set(cat.catalog.filter((e) => e.replyTypes).map((e) => foldPattern(e.address))) const extra = [ /^\/Console\/Aux_Outputs\/types$/, // design: seen in a live iPad session, not probed in the catalog /^\/Snapshots\/(Fire_Snapshot_number|Fire_Next_Snapshot|Fire_Prev_Snapshot)$/, // write-only trigger nodes /^\/Input_Channels\/N\/(Channel_Input\/post_meter|Dynamics\/GR_meter)$/, // meter subscriptions captured from an iPad session /^\/(Aux_Outputs|Group_Outputs|Matrix_Outputs)\/N\/Output\/meter$/, // output meter subscriptions (iPad app, confirmed live) /^\/Matrix_Inputs\/N\/Channel_Input\/(name|trim|phase)$/, // matrix input names and params, verified live after the catalog was written ] const offenders = new Set() const check1 = (a) => { const base = foldPattern(a.replace(/\/\?$/, '')) if (!answered.has(base) && !extra.some((re) => re.test(base))) offenders.add(base) } for (const m of sent.slice(0, loadSent)) check1(m.address) check(offenders.size === 0, `every address sent during the load is one the console answers (${offenders.size ? [...offenders].join(', ') : `${new Set(sent.slice(0, loadSent).map((m) => foldPattern(m.address))).size} distinct patterns`})`) globalThis.__catalogCheck = check1 globalThis.__offenders = offenders } // Variables await sleep(80) const V = ctx.variables check(V.matrix_in_1_name === 'TB' && V.matrix_in_7_name === 'Studio TB' && V.matrix_in_10_name === 'Matrix In 10' && 'matrix_in_10_name' in ctx.variableDefs, 'matrix input name variables matrix_in_N_name: cleaned, with the Matrix In N fallback') check(V.input_1_name === 'KICK In' && V.buss_24_name === 'Studio' && V.buss_24_mode === 'Group' && V.buss_1_stereo === 'Stereo' && V.cg_1_name === 'DRUMS' && V.matrix_1_name === 'MAINS L', 'variables carry live names and modes') check(V.snapshot_name === 'Worship 2' && V.snapshot_index === 3 && V.snapshot_cue === '4' && V.snapshot_count === 8 && V.console_name === 'S31 AB' && V.session_name === 'Mock Session.session' && V.connected === 'Yes', 'snapshot / console / connection variables') check(typeof V.input_1_fader === 'string' && V.input_1_fader !== '' && ['Muted', 'Unmuted'].includes(V.input_1_mute) && V.input_1_pan === 'C', 'input fader / mute / pan variables are filled') check('input_60_pan' in ctx.variableDefs && 'cg_10_mute' in ctx.variableDefs && 'last_change' in ctx.variableDefs && !('input_61_name' in ctx.variableDefs), 'variable definitions exist for entities that exist') // Definitions: every entity option is a searchable dropdown with numeric ids; levels show -inf { const ENTITY_OPTION_IDS = new Set(['channel', 'channels', 'buss', 'busses', 'matrix', 'matrixIn', 'matrixOut', 'cg', 'geq', 'snapshot']) const all = [...Object.values(ctx.actions), ...Object.values(ctx.feedbacks)] const entityOptions = all.flatMap((d) => d.options).filter((o) => ENTITY_OPTION_IDS.has(o.id)) check(entityOptions.length > 40 && entityOptions.every((o) => (o.type === 'dropdown' || o.type === 'multidropdown') && o.minChoicesForSearch === 0 && o.choices.every((c) => typeof c.id === 'number')), `all ${entityOptions.length} entity options are searchable dropdowns with numeric ids`) const levels = all.flatMap((d) => d.options).filter((o) => o.id === 'level') const thresholds = all.flatMap((d) => d.options).filter((o) => o.id === 'threshold' && o.min === -150) check(levels.length === 8 && levels.every((o) => o.type === 'number' && o.showMinAsNegativeInfinity === true && (o.min === -150 || o.min === -90)) && thresholds.every((o) => o.showMinAsNegativeInfinity === true), `level options use showMinAsNegativeInfinity (${levels.length} level + ${thresholds.length} threshold options)`) check(ctx.actions.matrix_send_level_set.options[2].min === -90 && ctx.actions.matrix_send_level_set.options[2].showMinAsNegativeInfinity === true && ctx.actions.send_level_set.options[2].min === -150 && ctx.actions.input_fader_set.options[1].min === -150 && ctx.actions.cg_fader_set.options[1].min === -150, 'per-kind floor: a matrix send level goes down to -90, aux sends and faders to -150') const adjusts = Object.entries(ctx.actions).filter(([id]) => /(fader|send_level)_adjust$/.test(id)) check(adjusts.length === 8 && adjusts.every(([, a]) => a.description.includes('From \u2212\u221e the first step up jumps to \u221260 dB')), `all ${adjusts.length} level adjust actions say that the first step up from -inf jumps to -60 dB`) check(ctx.actions.input_mute.options[0].choices[0].label === '1 · KICK In' && ctx.actions.send_on.options[1].choices.every((c) => S.bussMode(c.id) === 'aux'), 'action dropdowns show the live labels; send actions list aux busses only') const idsExpected = ['input_mute', 'input_solo', 'input_fader_set', 'input_fader_adjust', 'input_pan_set', 'input_pan_adjust', 'input_name_set', 'send_on', 'send_level_set', 'send_level_adjust', 'send_pan_set', 'send_pan_adjust', 'group_assign', 'buss_mute', 'buss_solo', 'buss_fader_set', 'buss_fader_adjust', 'matrix_mute', 'matrix_solo', 'matrix_fader_set', 'matrix_fader_adjust', 'matrix_send_on', 'matrix_send_level_set', 'matrix_send_level_adjust', 'cg_mute', 'cg_solo', 'cg_fader_set', 'cg_fader_adjust', 'geq_band_set', 'geq_band_adjust', 'geq_on', 'snapshot_fire', 'snapshot_next', 'snapshot_prev', 'reload', 'raw_osc', 'relay_forget', 'fade_stop', 'group_mute', 'group_solo', 'group_fader_set', 'group_fader_adjust', 'aux_mute', 'aux_solo', 'aux_fader_set', 'aux_fader_adjust'] // per-channel switches and values, generated from the PARAMS table for (const [param, p] of Object.entries(PARAMS)) { for (const ent of Object.keys(p.paths)) idsExpected.push(...(p.type === 'switch' ? [`${ent}_${param}`] : [`${ent}_${param}_set`, `${ent}_${param}_adjust`])) } check(idsExpected.every((id) => ctx.actions[id]) && Object.keys(ctx.actions).length === idsExpected.length, `all ${idsExpected.length} designed actions exist (${Object.keys(ctx.actions).length} registered)`) check(!('macro_press' in ctx.actions), 'no first-class macro action (the console never answered a macro query)') const fbExpected = ['input_muted', 'input_solo', 'send_on', 'group_assigned', 'buss_muted', 'buss_solo', 'matrix_muted', 'cg_muted', 'snapshot_current', 'level_compare', 'send_level_compare', 'connected', 'relay_clients', 'fade_running', 'input_signal', 'input_peak', 'input_fader_value', 'input_pan_value', 'send_level_value', 'send_on_value', 'buss_fader_value', 'matrix_fader_value', 'cg_fader_value', 'snapshot_name_value', 'input_meter_value', 'input_fader_bar', 'send_level_bar', 'buss_fader_bar', 'input_meter_bar'] for (const k of ['group', 'aux']) fbExpected.push(`${k}_muted`, `${k}_solo`, `${k}_fader_value`, `${k}_fader_pos`, `${k}_meter_value`, `${k}_meter_pos`, `${k}_signal`, `${k}_peak`) fbExpected.push('buss_signal', 'buss_peak', 'buss_meter_value', 'buss_meter_pos', 'matrix_signal', 'matrix_peak', 'matrix_meter_value', 'matrix_meter_pos') fbExpected.push('input_fader_pos', 'buss_fader_pos', 'matrix_fader_pos', 'cg_fader_pos', 'send_level_pos', 'matrix_send_level_pos', 'input_pan_num', 'send_pan_num', 'geq_band_db', 'input_meter_pos') fbExpected.push('matrix_solo', 'cg_solo', 'matrix_send_on', 'matrix_send_off', 'geq_on', 'send_pan_value', 'matrix_send_level_value', 'geq_band_value', 'matrix_fader_bar', 'cg_fader_bar', 'matrix_send_level_bar', 'geq_band_bar') for (const [param, p] of Object.entries(PARAMS)) for (const ent of Object.keys(p.paths)) fbExpected.push(`${ent}_${param}_${p.type === 'switch' ? 'state' : 'value'}`) check(fbExpected.every((id) => ctx.feedbacks[id]) && Object.keys(ctx.feedbacks).length === fbExpected.length, `all ${fbExpected.length} designed feedbacks exist (${Object.keys(ctx.feedbacks).length} registered)`) // Feedback parity: every action that changes readable state has a feedback for that state const PARITY = { input_mute: ['input_muted'], input_solo: ['input_solo'], input_fader_set: ['input_fader_value', 'input_fader_bar'], input_fader_adjust: ['input_fader_value', 'input_fader_bar'], input_pan_set: ['input_pan_value'], input_pan_adjust: ['input_pan_value'], send_on: ['send_on', 'send_on_value'], send_level_set: ['send_level_value', 'send_level_bar'], send_level_adjust: ['send_level_value', 'send_level_bar'], send_pan_set: ['send_pan_value'], send_pan_adjust: ['send_pan_value'], group_assign: ['group_assigned'], buss_mute: ['buss_muted'], buss_solo: ['buss_solo'], buss_fader_set: ['buss_fader_value', 'buss_fader_bar'], buss_fader_adjust: ['buss_fader_value', 'buss_fader_bar'], matrix_mute: ['matrix_muted'], matrix_solo: ['matrix_solo'], matrix_fader_set: ['matrix_fader_value', 'matrix_fader_bar'], matrix_fader_adjust: ['matrix_fader_value', 'matrix_fader_bar'], matrix_send_on: ['matrix_send_on', 'matrix_send_off'], matrix_send_level_set: ['matrix_send_level_value', 'matrix_send_level_bar'], matrix_send_level_adjust: ['matrix_send_level_value', 'matrix_send_level_bar'], cg_mute: ['cg_muted'], cg_solo: ['cg_solo'], cg_fader_set: ['cg_fader_value', 'cg_fader_bar'], cg_fader_adjust: ['cg_fader_value', 'cg_fader_bar'], geq_on: ['geq_on'], geq_band_set: ['geq_band_value', 'geq_band_bar'], geq_band_adjust: ['geq_band_value', 'geq_band_bar'], snapshot_fire: ['snapshot_current', 'snapshot_name_value'], snapshot_next: ['snapshot_current', 'snapshot_name_value'], snapshot_prev: ['snapshot_current', 'snapshot_name_value'], reload: ['connected'], relay_forget: ['relay_clients'], fade_stop: ['fade_running'], group_mute: ['group_muted'], group_solo: ['group_solo'], group_fader_set: ['group_fader_value'], group_fader_adjust: ['group_fader_value'], aux_mute: ['aux_muted'], aux_solo: ['aux_solo'], aux_fader_set: ['aux_fader_value'], aux_fader_adjust: ['aux_fader_value'], input_name_set: [], // the name is the variable input_N_name raw_osc: [], // free-form } for (const [param, p] of Object.entries(PARAMS)) { for (const ent of Object.keys(p.paths)) { if (p.type === 'switch') PARITY[`${ent}_${param}`] = [`${ent}_${param}_state`] else PARITY[`${ent}_${param}_set`] = PARITY[`${ent}_${param}_adjust`] = [`${ent}_${param}_value`] } } const GAUGE = { input_fader: 'input_fader_pos', send_level: 'send_level_pos', buss_fader: 'buss_fader_pos', group_fader: 'group_fader_pos', aux_fader: 'aux_fader_pos', matrix_fader: 'matrix_fader_pos', matrix_send_level: 'matrix_send_level_pos', cg_fader: 'cg_fader_pos', input_pan: 'input_pan_num', send_pan: 'send_pan_num', geq_band: 'geq_band_db' } for (const id of Object.keys(PARITY)) { const g = GAUGE[id.replace(/_(set|adjust)$/, '')] if (g && /_(set|adjust)$/.test(id)) PARITY[id] = [...PARITY[id], g] } const noGauge = Object.keys(ctx.actions).filter((id) => /^(input_fader|send_level|buss_fader|group_fader|aux_fader|matrix_fader|matrix_send_level|cg_fader|input_pan|send_pan|geq_band)_(set|adjust)$/.test(id) && !(PARITY[id] || []).some((f) => /_(pos|num|db)$/.test(f))) check(noGauge.length === 0 && Object.values(GAUGE).every((f) => ctx.feedbacks[f]?.type === 'value'), `every level and pan action has a numeric value feedback for gauges (${Object.values(GAUGE).join(', ')})`) const noFeedback = Object.keys(ctx.actions).filter((id) => !(id in PARITY)) const missing = Object.entries(PARITY).filter(([id, fbs]) => id in ctx.actions && fbs.some((f) => !ctx.feedbacks[f])).map(([id]) => id) const levelActions = Object.keys(ctx.actions).filter((id) => /_(set|adjust)$/.test(id) && /(fader|level|pan|band|trim|gain|freq)/.test(id)) const lacksValue = levelActions.filter((id) => !(PARITY[id] || []).some((f) => /_value$/.test(f))) const lacksBar = levelActions.filter((id) => /(fader|send_level)/.test(id) && !/^(group|aux)_/.test(id) && !(PARITY[id] || []).some((f) => /_bar$/.test(f))) check(noFeedback.length === 0 && missing.length === 0 && lacksValue.length === 0 && lacksBar.length === 0, `every action has its feedback (${Object.keys(ctx.actions).length} actions; no feedback listed: ${noFeedback.join(', ') || 'none'}; unknown feedback: ${missing.join(', ') || 'none'}; level without value: ${lacksValue.join(', ') || 'none'}; fader/send level without bar: ${lacksBar.join(', ') || 'none'})`) check(Object.values(ctx.feedbacks).filter((f) => f.type === 'advanced').every((f) => Array.isArray(f.affectedProperties)), 'advanced feedbacks declare affectedProperties') } // --------------------------------------------------------------------------- // 4. Presets: structure validity, size // --------------------------------------------------------------------------- { const P = ctx.presets const ids = Object.keys(P) const refs = ctx.presetStructure.flatMap((sec) => sec.definitions.flatMap((d) => d.presets)) check(refs.every((id) => P[id]) && new Set(refs).size === refs.length, `every preset id referenced by the structure exists, none twice (${refs.length} references)`) check(ids.every((id) => refs.includes(id)), 'every preset is listed in some group') let problems = [] const optionKeys = (def) => def.options.map((o) => o.id).sort().join() const checkAction = (id, a) => { const def = ctx.actions[a.actionId] if (!def) return problems.push(`${id}: unknown action ${a.actionId}`) if (optionKeys(def) !== Object.keys(a.options).sort().join()) problems.push(`${id}: ${a.actionId} options ${Object.keys(a.options)} vs ${optionKeys(def)}`) } for (const [id, p] of Object.entries(P)) { if (p.type !== 'simple' && p.type !== 'layered') problems.push(`${id}: type ${p.type}`) for (const step of p.steps) { for (const key of ['down', 'up', 'rotate_left', 'rotate_right']) for (const a of step[key] || []) checkAction(id, a) if ((step.rotate_left && !step.rotate_right) || (!step.rotate_left && step.rotate_right)) problems.push(`${id}: only one rotate array`) } for (const f of p.feedbacks) { const def = ctx.feedbacks[f.feedbackId] if (!def) problems.push(`${id}: unknown feedback ${f.feedbackId}`) else { if (optionKeys(def) !== Object.keys(f.options).sort().join()) problems.push(`${id}: ${f.feedbackId} options ${Object.keys(f.options)} vs ${optionKeys(def)}`) if (p.type === 'simple' && def.type === 'boolean' && !f.style) problems.push(`${id}: boolean feedback ${f.feedbackId} without style`) if (p.type === 'simple' && def.type === 'advanced' && f.style) problems.push(`${id}: advanced feedback ${f.feedbackId} with style`) if (p.type === 'layered' && (def.type !== 'boolean' || f.style || !Array.isArray(f.styleOverrides) || !f.styleOverrides.length)) problems.push(`${id}: layered feedback ${f.feedbackId} must be boolean with styleOverrides`) if (def.type === 'value') problems.push(`${id}: value feedback ${f.feedbackId} used as a button feedback`) } } const locals = new Set() for (const lv of p.localVariables || []) { locals.add(lv.variableName) const def = ctx.feedbacks[lv.feedbackId] if (lv.variableType !== 'feedback' || !def || def.type !== 'value') problems.push(`${id}: local variable ${lv.variableName} is not backed by a value feedback`) else if (optionKeys(def) !== Object.keys(lv.options).sort().join()) problems.push(`${id}: local ${lv.feedbackId} options`) } const used = p.type === 'layered' ? JSON.stringify(p.elements) : p.style.text for (const m of used.matchAll(/\$\(local:(\w+)\)/g)) if (!locals.has(m[1])) problems.push(`${id}: uses undeclared local ${m[1]}`) for (const m of used.matchAll(/\$\(S31:(\w+)\)/g)) if (!(m[1] in ctx.variableDefs)) problems.push(`${id}: uses undefined variable ${m[1]}`) } check(problems.length === 0, `preset actions, feedbacks, options and local variables are valid (${problems.length ? problems.slice(0, 5).join('; ') : 'no problems'})`) const knobs = ids.filter((id) => /_knob_/.test(id)) check(knobs.length > 100 && knobs.every((id) => P[id].steps[0].rotate_left?.length && P[id].steps[0].rotate_right?.length), `all ${knobs.length} knob presets have rotate_left and rotate_right`) const sections = ctx.presetStructure.map((s) => s.id) check(['inputs', 'groups', 'aux_masters', 'cgs', 'matrix', 'snapshots', 'console', 'meters', 'geqs'].every((s) => sections.includes(s)) && sections.some((s) => s.startsWith('sends_')) && sections.some((s) => s.startsWith('group_')) && sections.some((s) => s.startsWith('msends_')), `preset sections cover inputs, busses, sends, groups, matrix, matrix sends, graphic EQs, control groups, snapshots, meters, console (${sections.length} sections)`) // matrix sends: one section per matrix output, per matrix input an on/off button and a knob const msendSections = ctx.presetStructure.filter((x) => x.id.startsWith('msends_')) const msendOn = ids.filter((id) => id.startsWith('msend_on_')) const msendKnob = ids.filter((id) => id.startsWith('msend_knob_')) check(msendSections.length === 8 && msendSections[7].name === `Matrix sends to ${S.displayName('matrix', 8)}` && msendOn.length === 80 && msendKnob.length === 80, `a "Matrix sends to " section per matrix output (${msendSections.length}: "${msendSections[7]?.name}") with ${msendOn.length} on/off and ${msendKnob.length} knob presets`) const mOn = P.msend_on_1_8 const mKnob = P.msend_knob_1_8 check(mOn.style.text === '$(S31:matrix_in_1_name)\\n$(local:level) dB' && mOn.name === 'Matrix send on/off: TB → 8 · Studio' && mOn.localVariables[0].feedbackId === 'matrix_send_level_value' && JSON.stringify(mOn.localVariables[0].options) === JSON.stringify({ matrixIn: 1, matrixOut: 8 }) && mOn.steps[0].down[0].actionId === 'matrix_send_on' && mOn.steps[0].down[0].options.state === 'toggle' && mOn.style.bgcolor === 0x000000 && mOn.feedbacks.length === 1 && mOn.feedbacks[0].feedbackId === 'matrix_send_off' && mOn.feedbacks[0].style.bgcolor === 0xb01010 && mOn.feedbacks[0].style.color === 0xffffff && JSON.stringify(mOn.feedbacks[0].options) === JSON.stringify({ matrixIn: 1, matrixOut: 8 }), 'matrix send on/off preset: toggle matrix_send_on, black when the send is on, red with white text when it is off (like a mute), text from the level value feedback') check(!JSON.stringify(mOn.feedbacks).includes('matrix_send_on') && !JSON.stringify(P.msend_knob_1_8.feedbacks).includes('matrix_send_on') && !ids.filter((id) => id.startsWith('msend_')).some((id) => P[id].feedbacks.some((f) => f.style?.bgcolor === COLORS_GREEN || f.styleOverrides?.some((o) => o.override === 0x00a050))), 'no matrix send preset carries a green (send on) feedback any more') check(mKnob.steps[0].rotate_left[0].actionId === 'matrix_send_level_adjust' && mKnob.steps[0].rotate_left[0].options.delta === -1 && mKnob.steps[0].rotate_right[0].options.delta === 1 && mKnob.steps[0].down[0].actionId === 'matrix_send_on' && mKnob.type === 'layered' && mKnob.feedbacks.length === 1 && mKnob.feedbacks[0].feedbackId === 'matrix_send_off' && mKnob.feedbacks[0].styleOverrides[0].elementId === 'bg' && mKnob.feedbacks[0].styleOverrides[0].elementProperty === 'color' && mKnob.feedbacks[0].styleOverrides[0].override === 0xb01010 && mKnob.localVariables.map((v) => `${v.variableName}=${v.feedbackId}`).join() === 'level=matrix_send_level_value,pos=matrix_send_level_pos,meter=matrix_meter_pos', 'matrix send knob preset (layered): rotate = matrix_send_level_adjust -1/+1, press toggles on/off, off tints the background red, level, gauge position and meter local variables') { const meter = mKnob.elements.find((e) => e.id === 'meter') const title = mKnob.elements.find((e) => e.id === 'title') const mv = mKnob.localVariables.find((v) => v.variableName === 'meter') check(meter && meter.type === 'gauge' && meter.orientation === 'vertical' && meter.x + meter.width <= 100 && meter.x >= title.width && meter.height === 100 && meter.multiColour === true && meter.stops.length === 3 && meter.value.value === '$(local:meter)' && JSON.stringify(mv.options) === JSON.stringify({ matrix: 8 }), `matrix send knob has a vertical meter column on the right fed by the destination output's meter (x ${meter?.x}, width ${meter?.width}, title width ${title?.width})`) const iKnob = P.input_knob_3 || Object.values(P).find((p) => p.type === 'layered' && p.localVariables?.some((v) => v.feedbackId === 'input_fader_pos')) check(iKnob && !iKnob.elements.some((e) => e.id === 'meter') && iKnob.elements.find((e) => e.id === 'title').width === 100, 'other knob presets keep the full-width layout without a meter column') } check(!!P.geq_in_1 && P.geq_in_1.steps[0].down[0].actionId === 'geq_on' && P.geq_in_1.feedbacks[0].feedbackId === 'geq_on' && ids.filter((id) => id.startsWith('geq_in_')).length === 16, 'a Graphic EQs section with an in/out toggle and a geq_on feedback per graphic EQ') // layered presets: element types and required fields from the Companion graphics definitions const layered = ids.filter((id) => P[id].type === 'layered') const knobsLayered = layered.filter((id) => /_knob_/.test(id)) const meterPresets = layered.filter((id) => /^input_(meter|gate_gr|comp_gr)_/.test(id)) check(knobsLayered.length === ids.filter((id) => /_knob_/.test(id)).length && meterPresets.length === 180 && layered.filter((id) => /^(group|aux|matrix)_meter_/.test(id)).length === 32 && layered.length === knobsLayered.length + 180 + 32 && !ids.some((id) => /_(mute|solo|on)_/.test(id) && P[id].type === 'layered'), `every knob (${knobsLayered.length}) and meter (${meterPresets.length} input + 32 buss / matrix output) preset is layered; plain toggles stay simple`) const ELEMENT_TYPES = new Set(['group', 'composite', 'text', 'image', 'box', 'line', 'circle', 'gauge']) const LAYERED_PROPS = { box: ['id', 'type', 'x', 'y', 'width', 'height', 'color'], text: ['id', 'type', 'x', 'y', 'width', 'height', 'text', 'fontsize', 'fontsizeAllowShrink', 'color', 'halign', 'valign'], gauge: ['id', 'type', 'x', 'y', 'width', 'height', 'value', 'min', 'max', 'orientation', 'trackStyle', 'fillEnabled', 'multiColour', 'stops', 'trackAmount'] } const isExpr = (v) => !!v && typeof v === 'object' && v.isExpression === true && typeof v.value === 'string' const lp = [] for (const id of layered) { const p = P[id] if (!Array.isArray(p.elements) || !p.elements.length || typeof p.name !== 'string' || !Array.isArray(p.steps) || !Array.isArray(p.feedbacks) || p.style !== undefined) lp.push(`${id}: shape`) const elementIds = new Set() for (const e of p.elements) { if (!ELEMENT_TYPES.has(e.type) || typeof e.id !== 'string' || elementIds.has(e.id)) lp.push(`${id}: element ${e.type}/${e.id}`) elementIds.add(e.id) if (!LAYERED_PROPS[e.type]) lp.push(`${id}: unexpected element type ${e.type}`) else for (const k of Object.keys(e)) if (!LAYERED_PROPS[e.type].includes(k)) lp.push(`${id}: ${e.type} ${e.id} has unknown property ${k}`) for (const k of ['x', 'y', 'width', 'height']) if (typeof e[k] !== 'number' || e[k] < 0 || e[k] > 100) lp.push(`${id}: ${e.id}.${k}`) if (e.type === 'text' && (typeof e.text !== 'string' || typeof e.fontsize !== 'number')) lp.push(`${id}: text ${e.id}`) if (e.type === 'box' && typeof e.color !== 'number') lp.push(`${id}: box color`) if (e.type === 'gauge') { const okStops = Array.isArray(e.stops) && e.stops.length > 0 && e.stops.every((st) => typeof st.value === 'number' && typeof st.color === 'number' && typeof st.gradient === 'boolean') if (!isExpr(e.value) || e.value.value !== `$(local:${e.id === 'meter' ? 'meter' : 'pos'})` || e.min !== 0 || e.max !== 100 || !['horizontal', 'vertical', 'ring'].includes(e.orientation) || !['transparent', 'dimmed'].includes(e.trackStyle) || !okStops) lp.push(`${id}: gauge`) } } for (const f of p.feedbacks) for (const o of f.styleOverrides || []) { const el = p.elements.find((e) => e.id === o.elementId) if (!el || !(o.elementProperty in el) || typeof o.override !== 'number') lp.push(`${id}: styleOverride ${o.elementId}.${o.elementProperty}`) } const posVar = (p.localVariables || []).find((v) => v.variableName === 'pos') if (!posVar || !/_(pos)$/.test(posVar.feedbackId) || ctx.feedbacks[posVar.feedbackId]?.type !== 'value') lp.push(`${id}: pos local variable`) } check(lp.length === 0, `layered presets are valid: element types and fields, unique element ids, style overrides name existing elements, every $(local:x) is declared and backed by a value feedback (${lp.length ? lp.slice(0, 4).join('; ') : `${layered.length} presets, no problems`})`) const iK = P.input_knob_3 check(iK.elements.map((e) => `${e.type}:${e.id}`).join() === 'box:bg,text:title,text:level,gauge:gauge' && iK.elements[1].text === `$(S31:input_3_name)` && iK.elements[2].text === '$(local:level) dB' && iK.elements[3].orientation === 'horizontal' && iK.elements[3].y === 84 && iK.elements[3].height === 16 && iK.elements[3].stops.length === 1 && iK.elements[3].stops[0].color === 0xffffff && iK.elements[1].fontsize === 100 && iK.elements[1].fontsizeAllowShrink === true && iK.elements[3].trackStyle === 'dimmed' && iK.feedbacks[0].feedbackId === 'input_muted' && iK.feedbacks[0].styleOverrides[0].override === 0xb01010 && iK.localVariables.map((v) => `${v.variableName}=${v.feedbackId}`).join() === 'level=input_fader_value,pos=input_fader_pos', 'input knob: black box, name text, "$(local:level) dB", a white horizontal bottom gauge fed by $(local:pos), text size 100 with shrink, muted tints the background red') const sK = P.send_knob_3_24 || P[ids.find((id) => id.startsWith('send_knob_'))] check(sK.feedbacks[0].styleOverrides[0].override === 0x00a050 && sK.localVariables[1].feedbackId === 'send_level_pos', 'aux send knob: send on tints the background green, gauge from send_level_pos') const mt = P.input_meter_7 const g = mt.elements.find((e) => e.id === 'gauge') check(g.orientation === 'vertical' && g.x === 86 && g.width === 14 && g.multiColour === true && g.stops.map((st) => `${st.value}:${st.color.toString(16)}`).join() === '0:b050,70:f0a000,90:e00000' && mt.localVariables[0].feedbackId === 'input_meter_pos' && mt.feedbacks.map((f) => f.feedbackId).join() === 'input_signal,input_peak', 'meter preset: vertical gauge on the right edge, green below 70, amber 70-90, red above 90, signal and peak tint the background') check(Object.entries(ctx.feedbacks).filter(([id]) => /_bar$/.test(id)).every(([, f]) => /\(legacy image\)$/.test(f.name)) && Object.keys(ctx.feedbacks).filter((id) => /_bar$/.test(id)).length === 8, 'the 8 advanced image bar feedbacks keep their ids and are named "(legacy image)"') const bytes = JSON.stringify(P).length console.log(`info presets: ${ids.length} presets, ${bytes} bytes serialized, ${inst.presetStats?.dropped?.length ? `dropped ${inst.presetStats.dropped.join(' and ')}` : 'nothing dropped'}`) check(ids.length <= MAX_PRESETS && bytes <= MAX_PRESET_BYTES, `preset count ${ids.length} <= ${MAX_PRESETS} and size ${bytes} <= ${MAX_PRESET_BYTES} bytes`) // over the limit: the gate / compressor reduction meter presets go first, then the per-input send knobs const { getPresetDefinitions } = await import('../src/definitions/presets.js') const tier1 = getPresetDefinitions(inst, { count: 2900, bytes: MAX_PRESET_BYTES }) const tier2 = getPresetDefinitions(inst, { count: 2500, bytes: MAX_PRESET_BYTES }) check(tier1.stats.dropped.length === 1 && /reduction meter/.test(tier1.stats.dropped[0]) && tier1.stats.count === ids.length - 120 && !Object.keys(tier1.presets).some((id) => /_gr_/.test(id)) && Object.keys(tier1.presets).some((id) => id.startsWith('send_knob_')), `over the limit the 120 gate / compressor reduction meter presets are dropped first (${tier1.stats.count} left)`) check(tier2.stats.dropped.length === 2 && !Object.keys(tier2.presets).some((id) => id.startsWith('send_knob_')) && tier2.stats.count === ids.length - 120 - 900, `still over: the 900 per-input send level knobs go next (${tier2.stats.count} left)`) check(!/studio mon/i.test(JSON.stringify([P, ctx.presetStructure, ctx.variableDefs])) && !JSON.stringify(Object.values(ctx.actions).map((a) => a.name)).match(/studio mon/i), 'no site-specific wording in the generated definitions') } // --------------------------------------------------------------------------- // 5. Action callbacks produce the right OSC // --------------------------------------------------------------------------- async function run(actionId, options) { const mark = sent.length await ctx.actions[actionId].callback({ id: 'a1', controlId: 'c1', actionId, options, surfaceId: undefined }, {}) await sleep(25) return sent.slice(mark).map((m) => ({ address: m.address, ...decodeArgs(m) })) } function decodeArgs(m) { const d = decode(encode(m.address, m.args)) return { tags: d.tags, args: d.args } } const only = (list, re) => list.filter((m) => re.test(m.address) && !m.address.endsWith('/?')) const writes = (list) => list.filter((m) => !m.address.endsWith('/?')) const queries = (list) => list.filter((m) => m.address.endsWith('/?')) function expectWrite(list, address, tags, value, label) { const w = writes(list).find((m) => m.address === address) const ok = !!w && w.tags === tags && (typeof value === 'number' && tags === ',f' ? near(w.args[0], value) : w.args[0] === value) check(ok, `${label}: ${address} ${tags} ${JSON.stringify(w?.args)}`) } { let r = await run('input_fader_set', { channel: 5, level: -12.5 }) expectWrite(r, '/Input_Channels/5/fader', ',f', -12.5, 'input_fader_set') check(queries(r).some((m) => m.address === '/Input_Channels/5/fader/?') || true, 'a confirm query follows every write (checked below)') const cur = S.getFader('input', 7) r = await run('input_fader_adjust', { channel: 7, delta: 2 }) expectWrite(r, '/Input_Channels/7/fader', ',f', adjustedLevel(cur, 2), 'input_fader_adjust (+2 dB from the loaded level)') r = await run('input_fader_set', { channel: 8, level: -150 }) r = await run('input_fader_adjust', { channel: 8, delta: 1 }) expectWrite(r, '/Input_Channels/8/fader', ',f', -60, 'input_fader_adjust from -inf lands at -60 dB') const mute9 = S.getMute('input', 9) r = await run('input_mute', { channels: [9], state: 'toggle' }) expectWrite(r, '/Input_Channels/9/mute', ',f', mute9 ? 0 : 1, 'input_mute toggle (known state)') r = await run('input_mute', { channels: [10, 11], state: 'on' }) expectWrite(r, '/Input_Channels/10/mute', ',f', 1, 'input_mute on, first of two inputs') expectWrite(r, '/Input_Channels/11/mute', ',f', 1, 'input_mute on, second of two inputs') r = await run('input_solo', { channels: [12], state: 'on' }) expectWrite(r, '/Input_Channels/12/solo', ',f', 1, 'input_solo on') r = await run('input_pan_set', { channel: 13, pan: 0.25 }) expectWrite(r, '/Input_Channels/13/Panner/pan', ',f', 0.25, 'input_pan_set') r = await run('input_pan_adjust', { channel: 13, pdelta: 0.5 }) expectWrite(r, '/Input_Channels/13/Panner/pan', ',f', 0.75, 'input_pan_adjust') r = await run('input_pan_adjust', { channel: 13, pdelta: 0.5 }) expectWrite(r, '/Input_Channels/13/Panner/pan', ',f', 1, 'input_pan_adjust clamps at 1') // sends r = await run('send_on', { channel: 2, buss: 3, state: 'on' }) expectWrite(r, '/Input_Channels/2/Aux_Send/3/send_on', ',f', 1, 'send_on on') r = await run('send_level_set', { channel: 2, buss: 3, level: -10 }) expectWrite(r, '/Input_Channels/2/Aux_Send/3/send_level', ',f', -10, 'send_level_set') r = await run('send_level_adjust', { channel: 2, buss: 3, delta: 3 }) expectWrite(r, '/Input_Channels/2/Aux_Send/3/send_level', ',f', -7, 'send_level_adjust (from the value just set)') r = await run('send_pan_set', { channel: 2, buss: 3, pan: 0.2 }) expectWrite(r, '/Input_Channels/2/Aux_Send/3/send_pan', ',f', 0.2, 'send_pan_set') r = await run('send_pan_adjust', { channel: 2, buss: 3, pdelta: -0.1 }) expectWrite(r, '/Input_Channels/2/Aux_Send/3/send_pan', ',f', 0.1, 'send_pan_adjust') // toggle with unknown state queries first, then writes the opposite of the reply const unknown = S.getSend(4, 5) check(unknown.on === undefined, 'send 4 -> 5 state is not loaded yet') r = await run('send_on', { channel: 4, buss: 5, state: 'toggle' }) const q = r.findIndex((m) => m.address === '/Input_Channels/4/Aux_Send/5/send_on/?') const w = r.findIndex((m) => m.address === '/Input_Channels/4/Aux_Send/5/send_on') const replied = r[w]?.args?.[0] check(q === 0 && w > q && (replied === 0 || replied === 1) && r[w].tags === ',f', `toggle on unknown state queries first (query #${q}), then writes (${JSON.stringify(r[w]?.args)})`) await sleep(60) check(S.getSend(4, 5).on === (replied === 1), 'state follows the toggle (echo or optimistic)') // groups check(S.getGroup(1, 24) === undefined, 'group assign is not preloaded') r = await run('group_assign', { channel: 3, buss: 24, state: 'off' }) expectWrite(r, '/Input_Channels/3/Group_Send/24/group', ',f', 0, 'group_assign off') r = await run('group_assign', { channel: 3, buss: 24, state: 'on' }) expectWrite(r, '/Input_Channels/3/Group_Send/24/group', ',f', 1, 'group_assign on') // busses: aux side vs group side r = await run('buss_mute', { busses: [3], state: 'on' }) expectWrite(r, '/Aux_Outputs/3/mute', ',f', 1, 'buss_mute on an aux buss uses Aux_Outputs') r = await run('buss_mute', { busses: [9, 24], state: 'on' }) expectWrite(r, '/Group_Outputs/9/mute', ',f', 1, 'buss_mute on a group buss uses Group_Outputs') expectWrite(r, '/Group_Outputs/24/mute', ',f', 1, 'buss_mute second group buss') r = await run('buss_solo', { busses: [24], state: 'on' }) expectWrite(r, '/Group_Outputs/24/solo', ',f', 1, 'buss_solo') r = await run('buss_fader_set', { buss: 24, level: -3 }) expectWrite(r, '/Group_Outputs/24/fader', ',f', -3, 'buss_fader_set on a group buss') const auxFader = S.getFader('buss', 1) r = await run('buss_fader_adjust', { buss: 1, delta: -1.5 }) expectWrite(r, '/Aux_Outputs/1/fader', ',f', adjustedLevel(auxFader, -1.5), 'buss_fader_adjust on an aux buss') // matrix r = await run('matrix_mute', { matrix: 2, state: 'toggle' }) check(writes(r).some((m) => m.address === '/Matrix_Outputs/2/mute' && m.tags === ',f'), 'matrix_mute toggle') r = await run('matrix_solo', { matrix: 2, state: 'on' }) check(writes(r).some((m) => m.address === '/Matrix_Outputs/2/solo' && m.tags === ',f' && m.args[0] === 1), 'matrix_solo on (float 1)') r = await run('matrix_solo', { matrix: 2, state: 'off' }) check(writes(r).some((m) => m.address === '/Matrix_Outputs/2/solo' && m.tags === ',f' && m.args[0] === 0), 'matrix_solo off (float 0)') r = await run('matrix_fader_set', { matrix: 2, level: -20 }) expectWrite(r, '/Matrix_Outputs/2/fader', ',f', -20, 'matrix_fader_set') r = await run('matrix_fader_adjust', { matrix: 2, delta: 4 }) expectWrite(r, '/Matrix_Outputs/2/fader', ',f', -16, 'matrix_fader_adjust') r = await run('matrix_send_on', { matrixIn: 2, matrixOut: 3, state: 'on' }) expectWrite(r, '/Matrix_Inputs/2/Matrix_Send/3/send_on', ',f', 1, 'matrix_send_on') r = await run('matrix_send_level_set', { matrixIn: 2, matrixOut: 3, level: -30 }) expectWrite(r, '/Matrix_Inputs/2/Matrix_Send/3/send_level', ',f', -30, 'matrix_send_level_set') r = await run('matrix_send_level_adjust', { matrixIn: 3, matrixOut: 2, delta: 1 }) check(r[0]?.address === '/Matrix_Inputs/3/Matrix_Send/2/send_level/?', 'matrix_send_level_adjust on an unknown level queries first') expectWrite(r, '/Matrix_Inputs/3/Matrix_Send/2/send_level', ',f', -60, 'matrix_send_level_adjust from the -90 floor lands at -60') // a matrix send floors at -90: stepping down from -inf stays there, a set below it is clamped, -150 is never written r = await run('matrix_send_level_adjust', { matrixIn: 4, matrixOut: 2, delta: -1 }) check(r[0]?.address === '/Matrix_Inputs/4/Matrix_Send/2/send_level/?', 'matrix_send_level_adjust -1 on an unknown level queries first') expectWrite(r, '/Matrix_Inputs/4/Matrix_Send/2/send_level', ',f', -90, 'matrix_send_level_adjust -1 from -90 stays at -90 (not -150)') r = await run('matrix_send_level_set', { matrixIn: 4, matrixOut: 2, level: -150 }) expectWrite(r, '/Matrix_Inputs/4/Matrix_Send/2/send_level', ',f', -90, 'matrix_send_level_set -150 is clamped to -90') r = await run('matrix_send_level_adjust', { matrixIn: 4, matrixOut: 2, delta: 1 }) expectWrite(r, '/Matrix_Inputs/4/Matrix_Send/2/send_level', ',f', -60, 'matrix_send_level_adjust +1 from -90 jumps to -60') r = await run('matrix_send_level_adjust', { matrixIn: 4, matrixOut: 2, delta: -35 }) expectWrite(r, '/Matrix_Inputs/4/Matrix_Send/2/send_level', ',f', -90, 'matrix_send_level_adjust below -90 lands on -90') check(!sent.some((m) => /^\/Matrix_Inputs\/\d+\/Matrix_Send\/\d+\/send_level$/.test(m.address) && m.args[0] === -150), 'no matrix send write ever carries -150') r = await run('send_level_set', { channel: 6, buss: 3, level: -150 }) expectWrite(r, '/Input_Channels/6/Aux_Send/3/send_level', ',f', -150, 'an aux send still goes down to -150') r = await run('send_level_adjust', { channel: 6, buss: 3, delta: -1 }) expectWrite(r, '/Input_Channels/6/Aux_Send/3/send_level', ',f', -150, 'aux send adjust -1 from -inf stays at -150') r = await run('send_level_adjust', { channel: 6, buss: 3, delta: 1 }) expectWrite(r, '/Input_Channels/6/Aux_Send/3/send_level', ',f', -60, 'aux send adjust +1 from -inf jumps to -60') // control groups (1-based) r = await run('cg_mute', { cg: 1, state: 'on' }) expectWrite(r, '/Control_Groups/1/mute', ',f', 1, 'cg_mute') r = await run('cg_solo', { cg: 10, state: 'on' }) expectWrite(r, '/Control_Groups/10/solo', ',f', 1, 'cg_solo') r = await run('cg_fader_set', { cg: 2, level: -6 }) expectWrite(r, '/Control_Groups/2/fader', ',f', -6, 'cg_fader_set') r = await run('cg_fader_adjust', { cg: 2, delta: -2 }) expectWrite(r, '/Control_Groups/2/fader', ',f', -8, 'cg_fader_adjust') // graphic EQ r = await run('geq_on', { geq: 1, state: 'off' }) expectWrite(r, '/Graphic_EQ/1/geq_in', ',f', 0, 'geq_on off') r = await run('geq_band_set', { geq: 1, band: 5, gain: 3 }) expectWrite(r, '/Graphic_EQ/1/geq_gain_5', ',f', 3, 'geq_band_set') r = await run('geq_band_adjust', { geq: 1, band: 5, delta: 1 }) expectWrite(r, '/Graphic_EQ/1/geq_gain_5', ',f', 4, 'geq_band_adjust') r = await run('geq_band_adjust', { geq: 2, band: 32, delta: -1 }) check(r[0]?.address === '/Graphic_EQ/2/geq_gain_32/?', 'geq_band_adjust on an unknown gain queries first') expectWrite(r, '/Graphic_EQ/2/geq_gain_32', ',f', -1, 'geq_band_adjust from the replied 0') // per-channel switches and values, loaded lazily on first use check(S.getParam('input', 14, 'phantom') === undefined, 'input 14 phantom is not preloaded') r = await run('input_phantom', { channel: 14, state: 'toggle' }) const pq = r.findIndex((m) => m.address === '/Input_Channels/14/Channel_Input/phantom/?') const pw = r.findIndex((m) => m.address === '/Input_Channels/14/Channel_Input/phantom') check(pq === 0 && pw > pq && r[pw].tags === ',f' && r[pw].args[0] === 1, `input_phantom toggle on an unloaded switch queries first, then writes the opposite of the replied 0 (${JSON.stringify(r[pw]?.args)})`) r = await run('input_lpf_in', { channel: 14, state: 'on' }) expectWrite(r, '/Input_Channels/14/Filters/hi_filter_in', ',f', 1, 'input_lpf_in on (hi_filter_in is the low-pass filter)') r = await run('input_hpf_in', { channel: 14, state: 'on' }) expectWrite(r, '/Input_Channels/14/Filters/lo_filter_in', ',f', 1, 'input_hpf_in on (lo_filter_in is the high-pass filter)') r = await run('input_pad', { channel: 14, state: 'off' }) expectWrite(r, '/Input_Channels/14/Channel_Input/input_pad', ',f', 0, 'input_pad off') r = await run('input_phase', { channel: 14, state: 'on' }) expectWrite(r, '/Input_Channels/14/Channel_Input/phase', ',f', 1, 'input_phase on') for (const [id, path] of [['eq_in', 'EQ/eq_in'], ['comp_in', 'Dynamics/comp_in'], ['gate_in', 'Dynamics/gate_in'], ['delay_on', 'Channel_Delay/delay_on'], ['insert_a', 'Insert/insert_A_in'], ['insert_b', 'Insert/insert_B_in']]) { r = await run(`input_${id}`, { channel: 14, state: 'on' }) expectWrite(r, `/Input_Channels/14/${path}`, ',f', 1, `input_${id} on`) } r = await run('input_hpf_freq_set', { channel: 14, value: 80 }) expectWrite(r, '/Input_Channels/14/Filters/lo_filter_freq', ',f', 80, 'input_hpf_freq_set') r = await run('input_hpf_freq_adjust', { channel: 14, delta: 10 }) expectWrite(r, '/Input_Channels/14/Filters/lo_filter_freq', ',f', 88, 'input_hpf_freq_adjust (+10 % per step)') r = await run('input_lpf_freq_adjust', { channel: 15, delta: -50 }) check(r[0]?.address === '/Input_Channels/15/Filters/hi_filter_freq/?', 'input_lpf_freq_adjust on an unloaded value queries first') expectWrite(r, '/Input_Channels/15/Filters/hi_filter_freq', ',f', 6000, 'input_lpf_freq_adjust halves the replied 12000 Hz') r = await run('input_trim_set', { channel: 14, value: -3 }) expectWrite(r, '/Input_Channels/14/Channel_Input/trim', ',f', -3, 'input_trim_set') r = await run('input_gain_set', { channel: 14, value: 32 }) expectWrite(r, '/Input_Channels/14/Channel_Input/analog_gain', ',f', 32, 'input_gain_set') r = await run('input_gain_adjust', { channel: 14, delta: 2.5 }) expectWrite(r, '/Input_Channels/14/Channel_Input/analog_gain', ',f', 34.5, 'input_gain_adjust') r = await run('buss_trim_set', { buss: 1, value: -2 }) expectWrite(r, '/Aux_Outputs/1/Buss_Trim/trim', ',f', -2, 'buss_trim_set on an aux buss') r = await run('buss_trim_adjust', { buss: 24, delta: 1 }) check(r[0]?.address === '/Group_Outputs/24/Buss_Trim/trim/?', 'buss_trim_adjust on a group buss queries the Group_Outputs tree') expectWrite(r, '/Group_Outputs/24/Buss_Trim/trim', ',f', 1, 'buss_trim_adjust from the replied 0') r = await run('matrix_trim_set', { matrix: 2, value: 1.5 }) expectWrite(r, '/Matrix_Outputs/2/Buss_Trim/trim', ',f', 1.5, 'matrix_trim_set') r = await run('minput_trim_set', { matrixIn: 3, value: -2 }) expectWrite(r, '/Matrix_Inputs/3/Channel_Input/trim', ',f', -2, 'minput_trim_set (matrix input trim, dB)') r = await run('minput_trim_adjust', { matrixIn: 3, delta: 0.5 }) expectWrite(r, '/Matrix_Inputs/3/Channel_Input/trim', ',f', -1.5, 'minput_trim_adjust') r = await run('minput_phase', { matrixIn: 3, state: 'toggle' }) check(r[0]?.address === '/Matrix_Inputs/3/Channel_Input/phase/?', 'minput_phase toggle on an unloaded switch queries first') expectWrite(r, '/Matrix_Inputs/3/Channel_Input/phase', ',f', 1, 'minput_phase toggle writes a float 1 (the replied state was 0)') r = await run('buss_eq_in', { buss: 3, state: 'toggle' }) check(r[0]?.address === '/Aux_Outputs/3/EQ/eq_in/?' && writes(r).some((m) => m.address === '/Aux_Outputs/3/EQ/eq_in' && m.tags === ',f'), 'buss_eq_in toggle on an unloaded aux buss queries first') r = await run('buss_insert_a', { buss: 9, state: 'on' }) expectWrite(r, '/Group_Outputs/9/Insert/insert_A_in', ',f', 1, 'buss_insert_a on a group buss') r = await run('buss_phase', { buss: 3, state: 'on' }) expectWrite(r, '/Aux_Outputs/3/Buss_Trim/phase', ',f', 1, 'buss_phase on') // names, snapshots, raw, relay r = await run('snapshot_fire', { snapshot: 5 }) expectWrite(r, '/Snapshots/Fire_Snapshot_number', ',i', 5, 'snapshot_fire') check(await waitFor(() => S.snapshots.current === 5, 3000), 'the console echoed Current_Snapshot 5 and the state followed') await sleep(80) check(ctx.variables.snapshot_name === 'Offering' && ctx.variables.snapshot_index === 5, 'snapshot variables follow the recall') r = await run('snapshot_next', {}) expectWrite(r, '/Snapshots/Fire_Next_Snapshot', ',i', 0, 'snapshot_next') r = await run('snapshot_prev', {}) expectWrite(r, '/Snapshots/Fire_Prev_Snapshot', ',i', 0, 'snapshot_prev') r = await run('raw_osc', { address: '/Console/Name/?', argType: 'none', value: '' }) check(r.some((m) => m.address === '/Console/Name/?' && m.tags === ','), 'raw_osc sends the address as given') r = await run('raw_osc', { address: '/Input_Channels/1/Aux_Send/1/send_level', argType: 'float', value: '-5' }) expectWrite(r, '/Input_Channels/1/Aux_Send/1/send_level', ',f', -5, 'raw_osc float') } // A write is confirmed by one query once its guard window ends (not immediately) { const mark = sent.length await run('cg_fader_set', { cg: 3, level: -4 }) await sleep(100) check(!sent.slice(mark).some((m) => m.address === '/Control_Groups/3/fader/?'), 'no immediate confirm query after a write') await sleep(inst.timing.writeGuardMs + 200) const seq = sent.slice(mark).map((m) => m.address) check(seq.filter((a) => a === '/Control_Groups/3/fader/?').length === 1 && seq.indexOf('/Control_Groups/3/fader/?') > seq.indexOf('/Control_Groups/3/fader'), 'one confirm query of the same address follows when the write guard ends') } // A 0/1 switch takes a FLOAT write only (verified on a real S31): the mock ignores int and T/F writes like the console does { const address = '/Input_Channels/30/mute' const before = S.getMute('input', 30) const want = before ? 0 : 1 const seen = [] const original = inst.onConsoleMessage.bind(inst) inst.onConsoleMessage = (msg, rinfo) => { if (msg.address === address) seen.push(msg) return original(msg, rinfo) } const raw = dgram.createSocket('udp4') const sendRaw = (args) => new Promise((resolve) => raw.send(encode(address, args), P.mock, '127.0.0.1', resolve)) await sendRaw([{ i: want }]) await sendRaw([true]) await sendRaw([false]) await sendRaw([{ i: before ? 0 : 1 }]) await sleep(500) check(seen.length === 0 && S.getMute('input', 30) === before, 'int and T/F writes to a switch are ignored by the mock: no echo and no state change') inst.queryNow(address) check(await waitFor(() => seen.length === 1, 2000) && seen[0].tags === ',f' && seen[0].args[0] === (before ? 1 : 0), 'reading it back still gives the old value, as a float') await sendRaw([want]) check(await waitFor(() => seen.length === 2, 2000) && seen[1].tags === ',f' && seen[1].args[0] === want && S.getMute('input', 30) === !before, 'a float write is accepted and echoed as a float') await sendRaw([before ? 1 : 0]) // restore await sleep(300) inst.onConsoleMessage = original raw.close() // and the module's own switch writes are floats, so the console keeps them (the confirm query after the guard agrees) const mark = sent.length const was31 = S.getMute('input', 31) await run('input_mute', { channels: [31], state: 'toggle' }) await sleep(inst.timing.writeGuardMs + 500) const w = sent.slice(mark).find((m) => m.address === '/Input_Channels/31/mute' && !m.address.endsWith('/?')) check(!!w && decodeArgs(w).tags === ',f' && S.getMute('input', 31) === !was31, 'a module switch write goes out as a float and the console accepted it (the confirm query returns the written value)') } // Names: renames rebuild the definitions once (debounced) and the dropdowns pick them up { const before = ctx.rebuilds await run('input_name_set', { channel: 4, name: 'Renamed four' }) await run('input_name_set', { channel: 5, name: 'Renamed five' }) await run('input_name_set', { channel: 6, name: 'Renamed six' }) await sleep(500) check(ctx.rebuilds === before + 1, `three quick renames caused exactly one definitions rebuild (${ctx.rebuilds - before})`) check(ctx.actions.input_mute.options[0].choices.find((c) => c.id === 4)?.label === '4 · Renamed four' && ctx.variables.input_6_name === 'Renamed six', 'renamed inputs show up in the dropdown choices and variables') check(ctx.presets.input_mute_5.name.includes('Renamed five'), 'presets are rebuilt with the new names') } // --------------------------------------------------------------------------- // 6. Feedbacks // --------------------------------------------------------------------------- const fb = (feedbackId, options, image) => ({ id: `fb-${feedbackId}-${JSON.stringify(options)}`, controlId: 'c1', feedbackId, type: ctx.feedbacks[feedbackId].type, options, previousOptions: null, image }) const cb = (feedbackId, options, image) => ctx.feedbacks[feedbackId].callback(fb(feedbackId, options, image), { signal: new AbortController().signal }) { const mutedInput = [...S.inputs].find(([, v]) => v.mute === true)?.[0] check(cb('input_muted', { channel: 9 }) === !S.getMute('input', 9) === false || true, 'input_muted evaluates') check(cb('input_muted', { channel: 10 }) === true && cb('input_muted', { channel: 1 }) === (S.getMute('input', 1) === true), 'input_muted follows the state (10 was muted by an action)') check(cb('input_solo', { channel: 12 }) === true && cb('input_solo', { channel: 1 }) === false, 'input_solo') check(cb('send_on', { channel: 2, buss: 3 }) === true && cb('send_level_value', { channel: 2, buss: 3 }) === '-7' && cb('send_on_value', { channel: 2, buss: 3 }) === 'On', 'send_on / send_level_value / send_on_value') check(cb('group_assigned', { channel: 3, buss: 24 }) === true, 'group_assigned') check(cb('buss_muted', { buss: 24 }) === true && cb('buss_solo', { buss: 24 }) === true && cb('buss_muted', { buss: 3 }) === true, 'buss_muted / buss_solo') check(cb('matrix_muted', { matrix: 2 }) === S.getMute('matrix', 2) && cb('cg_muted', { cg: 1 }) === true, 'matrix_muted / cg_muted') check(cb('snapshot_current', { snapshot: 5 }) === (S.snapshots.current === 5) && cb('snapshot_current', { snapshot: 0 }) === false, 'snapshot_current') check(cb('level_compare', { kind: 'buss', buss: 24, comparison: 'above', threshold: -10 }) === true && cb('level_compare', { kind: 'cg', cg: 2, comparison: 'below', threshold: -7 }) === true && cb('level_compare', { kind: 'input', channel: 7, comparison: 'above', threshold: 20 }) === false, 'level_compare for buss / control group / input') check(cb('send_level_compare', { channel: 2, buss: 3, comparison: 'above', threshold: -10 }) === true && cb('send_level_compare', { channel: 2, buss: 3, comparison: 'below', threshold: -10 }) === false, 'send_level_compare') check(cb('connected', {}) === true, 'connected feedback') check(cb('input_phantom_state', { channel: 14 }) === true && cb('input_hpf_in_state', { channel: 14 }) === true && cb('input_pad_state', { channel: 14 }) === false && cb('buss_insert_a_state', { buss: 9 }) === true && cb('buss_eq_in_state', { buss: 3 }) !== undefined, 'switch feedbacks follow the loaded / written state') check(cb('input_comp_in_state', { channel: 33 }) === false && (await waitFor(() => sent.some((m) => m.address === '/Input_Channels/33/Dynamics/comp_in/?'), 2000)), 'an unloaded switch feedback reads false and queries the console once') await sleep(120) check(S.getParam('input', 33, 'comp_in') === true && cb('input_comp_in_state', { channel: 33 }) === true, 'the replied switch value now drives the feedback (mock default: comp in)') check(cb('input_fader_value', { channel: 5 }) === '-12.5' && cb('input_pan_value', { channel: 13 }) === 'R100' && cb('buss_fader_value', { buss: 24 }) === '-3' && cb('matrix_fader_value', { matrix: 2 }) === '-16' && cb('cg_fader_value', { cg: 2 }) === '-8', 'value feedbacks return dB / pan text') check(cb('snapshot_name_value', {}) === 'Offering' || cb('snapshot_name_value', {}) === S.snapshotName(S.snapshots.current), 'snapshot_name_value follows the current snapshot') // matrix sends, solo, graphic EQs, channel-strip values and send pan check(cb('matrix_send_on', { matrixIn: 2, matrixOut: 3 }) === true && cb('matrix_send_level_value', { matrixIn: 2, matrixOut: 3 }) === '-30' && cb('matrix_send_level_value', { matrixIn: 3, matrixOut: 2 }) === '-60', 'matrix_send_on and matrix_send_level_value follow the written matrix sends') check(cb('matrix_send_level_value', { matrixIn: 5, matrixOut: 5 }) === '--' && cb('matrix_send_on', { matrixIn: 5, matrixOut: 5 }) === false, 'an unloaded matrix send reads "--" / false and is queued for loading') check(await waitFor(() => sent.some((m) => m.address === '/Matrix_Inputs/5/Matrix_Send/5/send_level/?') && S.getMatrixSend(5, 5).level !== undefined, 2500), 'the unloaded matrix send is queried once and its state arrives') check(cb('matrix_send_level_value', { matrixIn: 5, matrixOut: 5 }) === '-inf' && cb('matrix_send_on', { matrixIn: 5, matrixOut: 5 }) === false, 'a matrix send at its -90 floor reads "-inf"') // matrix_send_off: true only when the console says the send is off (like a mute); unknown is not red, and starts the lazy load check(cb('matrix_send_on', { matrixIn: 2, matrixOut: 3 }) === true && cb('matrix_send_off', { matrixIn: 2, matrixOut: 3 }) === false, 'matrix_send_off is false for a send that is on') check(cb('matrix_send_off', { matrixIn: 6, matrixOut: 6 }) === false && cb('matrix_send_on', { matrixIn: 6, matrixOut: 6 }) === false, 'matrix_send_off is false for an unknown send (not red until the console says off) and the send is queued for loading') check(await waitFor(() => sent.some((m) => m.address === '/Matrix_Inputs/6/Matrix_Send/6/send_on/?') && S.getMatrixSend(6, 6).on !== undefined, 2500), 'the unknown send is queried lazily and its on/off arrives') { const sendOn = (v) => inst.onConsoleMessage(decode(encode('/Matrix_Inputs/6/Matrix_Send/6/send_on', [v])), { address: '127.0.0.1', port: P.mock }) sendOn(0.0) check(S.getMatrixSend(6, 6).on === false && cb('matrix_send_off', { matrixIn: 6, matrixOut: 6 }) === true && cb('matrix_send_on', { matrixIn: 6, matrixOut: 6 }) === false, 'matrix_send_off is true for a send the console reports off') sendOn(1.0) check(cb('matrix_send_off', { matrixIn: 6, matrixOut: 6 }) === false && cb('matrix_send_on', { matrixIn: 6, matrixOut: 6 }) === true, 'and false again when it is switched on') } check(cb('matrix_solo', { matrix: 2 }) === false && cb('cg_solo', { cg: 10 }) === true && cb('cg_solo', { cg: 1 }) === false, 'matrix_solo / cg_solo follow the state') inst.onConsoleMessage(decode(encode('/Matrix_Outputs/2/solo', [1.0])), { address: '127.0.0.1', port: P.mock }) check(cb('matrix_solo', { matrix: 2 }) === true, 'matrix_solo goes true when the console reports solo') inst.onConsoleMessage(decode(encode('/Matrix_Outputs/2/solo', [0.0])), { address: '127.0.0.1', port: P.mock }) check(cb('geq_on', { geq: 1 }) === false && cb('geq_band_value', { geq: 1, band: 5 }) === '4', 'geq_on (switched off earlier) and geq_band_value (band 5 at +4 dB)') check(cb('geq_on', { geq: 3 }) === false && cb('geq_band_value', { geq: 3, band: 7 }) === '--', 'an unloaded graphic EQ reads false / "--"') check(await waitFor(() => S.getGeq(3).on !== undefined && S.getGeq(3).gains?.get(7) !== undefined, 2500) && cb('geq_on', { geq: 3 }) === true && cb('geq_band_value', { geq: 3, band: 7 }) === '0', 'the graphic EQ state loads lazily (mock default: in, flat)') check(cb('send_pan_value', { channel: 2, buss: 3 }) === 'L80', 'send_pan_value (pan 0.1 -> L80)') // numeric position feedbacks for native gauges: known state gives the taper position, unknown gives 0 (and starts the lazy load) check(near(cb('input_fader_pos', { channel: 5 }), 51.25) && near(cb('buss_fader_pos', { buss: 24 }), 69) && near(cb('matrix_fader_pos', { matrix: 2 }), 46) && near(cb('cg_fader_pos', { cg: 2 }), 59), 'input / buss / matrix / control group fader position follow the fader taper (-12.5 dB -> 51.25, -3 -> 69, -16 -> 46, -8 -> 59)') check(near(cb('send_level_pos', { channel: 2, buss: 3 }), 61) && near(cb('matrix_send_level_pos', { matrixIn: 2, matrixOut: 3 }), 28) && near(cb('matrix_send_level_pos', { matrixIn: 3, matrixOut: 2 }), 6), 'aux send and matrix send position (-7 dB -> 61, -30 -> 28, -60 -> 6)') check(cb('input_pan_num', { channel: 13 }) === 1 && near(cb('send_pan_num', { channel: 2, buss: 3 }), 0.1) && cb('geq_band_db', { geq: 1, band: 5 }) === 4, 'pan is the raw 0..1 number and the graphic EQ band is the dB number') check(cb('input_fader_pos', { channel: 99 }) === 0 && cb('buss_fader_pos', { buss: 99 }) === 0 && cb('matrix_fader_pos', { matrix: 99 }) === 0 && cb('cg_fader_pos', { cg: 99 }) === 0 && cb('input_pan_num', { channel: 99 }) === 0.5, 'unknown fader state reads 0 (pan reads the 0.5 centre)') const lazy = [ ['send_level_pos', { channel: 45, buss: 9 }, '/Input_Channels/45/Aux_Send/9/send_level/?', 0], ['matrix_send_level_pos', { matrixIn: 7, matrixOut: 6 }, '/Matrix_Inputs/7/Matrix_Send/6/send_level/?', 0], ['send_pan_num', { channel: 45, buss: 9 }, '/Input_Channels/45/Aux_Send/9/send_pan/?', 0.5], ['geq_band_db', { geq: 4, band: 9 }, '/Graphic_EQ/4/geq_gain_9/?', 0], ] const firsts = lazy.map(([id, options]) => cb(id, options)) check(lazy.every(([, , , want], i) => firsts[i] === want), `unloaded send / matrix send / send pan / GEQ band read ${firsts.join(', ')} on first sight`) check(await waitFor(() => lazy.every(([, , address]) => sent.some((m) => m.address === address)), 2500), 'and each starts its lazy load') await sleep(150) check(typeof cb('send_level_pos', { channel: 45, buss: 9 }) === 'number' && S.getSend(45, 9).level !== undefined && S.getMatrixSend(7, 6).level === -90 && cb('matrix_send_level_pos', { matrixIn: 7, matrixOut: 6 }) === 0 && typeof cb('send_pan_num', { channel: 45, buss: 9 }) === 'number' && cb('geq_band_db', { geq: 4, band: 9 }) === 0, 'once the state arrives the position feedbacks return the real values (a matrix send at -90 stays 0)') check(cb('input_trim_value', { channel: 14 }) === '-3' && cb('input_gain_value', { channel: 14 }) === '34.5' && cb('input_hpf_freq_value', { channel: 14 }) === '88' && cb('buss_trim_value', { buss: 1 }) === '-2' && cb('matrix_trim_value', { matrix: 2 }) === '1.5', 'channel-strip value feedbacks: trim, gain, filter frequency (whole Hz), buss and matrix trim') check(cb('minput_trim_value', { matrixIn: 3 }) === '-1.5' && cb('minput_phase_state', { matrixIn: 3 }) === true && cb('minput_phase_state', { matrixIn: 4 }) === false, 'matrix input trim text and phase feedback follow the writes') check(cb('input_lpf_freq_value', { channel: 60 }) === '--' && (await waitFor(() => S.getParam('input', 60, 'lpf_freq') !== undefined, 2500)) && cb('input_lpf_freq_value', { channel: 60 }) === '12000', 'an unloaded channel-strip value reads "--" and loads lazily') check(cb('send_level_value', { channel: 40, buss: 8 }) === '--' && cb('send_on', { channel: 40, buss: 8 }) === false, 'an unloaded send pair reads as unknown ("--" / false) and is queued for loading') check(await waitFor(() => sent.some((m) => m.address === '/Input_Channels/40/Aux_Send/8/send_level/?'), 2000), 'the unloaded pair is queried once its feedback runs') await sleep(120) check(typeof S.getSend(40, 8).level === 'number' && cb('send_level_value', { channel: 40, buss: 8 }) !== '--', 'the pair state arrives and the value feedback now returns a number') const count40 = sent.filter((m) => m.address === '/Input_Channels/40/Aux_Send/8/send_level/?').length cb('send_level_value', { channel: 40, buss: 8 }) check(sent.filter((m) => m.address === '/Input_Channels/40/Aux_Send/8/send_level/?').length === count40, 'pair loading is de-duplicated') // bars const image = { width: 72, height: 58 } const t = thicknessFor(58) const barOf = (id, options) => cb(id, { ...options, color: 0x00c85a }, image) for (const [id, options, fraction] of [ ['input_fader_bar', { channel: 5 }, (-12.5 + 60) / 70], ['buss_fader_bar', { buss: 24 }, (-3 + 60) / 70], ['send_level_bar', { channel: 2, buss: 3 }, (-7 + 60) / 70], ['matrix_send_level_bar', { matrixIn: 2, matrixOut: 3 }, (-30 + 60) / 70], ['matrix_fader_bar', { matrix: 2 }, (-16 + 60) / 70], ['cg_fader_bar', { cg: 2 }, (-8 + 60) / 70], ['geq_band_bar', { geq: 1, band: 5 }, (4 + 12) / 24], ]) { const res = barOf(id, options) const raw = Buffer.from(res.imageBuffer || '', 'base64') let filled = 0 for (let x = 0; x < 72; x++) if (raw[x * 4] === 0x00 && raw[x * 4 + 1] === 0xc8 && raw[x * 4 + 2] === 0x5a) filled++ check(raw.length === 72 * t * 4 && res.imageBufferEncoding?.pixelFormat === 'RGBA' && res.imageBufferPosition?.y === 58 - t && res.imageBufferPosition?.width === 72 && Math.abs(filled - Math.round(fraction * 72)) <= 1, `${id}: base64 RGBA strip of ${raw.length} bytes, ${filled}/72 px filled`) } check(Object.keys(cb('input_fader_bar', { channel: 5, color: 0 }, undefined)).length === 0, 'bar feedbacks return nothing when the button gives no image size') } // --------------------------------------------------------------------------- // 7. Meters (scale: a level value is about the dB below full scale, 127 = silence; gain reduction is dB, 127 = inactive) // --------------------------------------------------------------------------- { const id7 = meterIdForInput(7) check(id7 >= 5000 && meterIdForInput(60) === 5060 && outputMeterId('buss', 3) === 5403 && outputMeterId('matrix', 2) === 5452, `meter ids start at 5000 (input 7 -> ${id7}); output meters use 5400 + buss and 5450 + matrix output`) // the scale helpers check(meterLevelDb(42) === -42 && meterLevelDb(0) === 0 && meterLevelDb(127) === -Infinity && meterLevelDb(undefined) === -Infinity && meterGrDb(3) === 3 && meterGrDb(127) === 0 && meterGrDb(undefined) === 0, 'meterLevelDb: 127 = -inf, v = -v dBFS; meterGrDb: 127 = no reduction, v = v dB') check(fmtMeterLevel(42) === '-42' && fmtMeterLevel(127) === '-inf' && fmtMeterLevel(0) === '0' && meterPosition(127) === 0 && meterPosition(60) === 0 && near(meterPosition(42), 30) && meterPosition(0) === 100 && near(meterPosition(6, 'gr'), 30) && meterPosition(127, 'gr') === 0 && meterPosition(30, 'gr') === 100, 'meter text and positions: -42 -> "-42" and 30 %, 0 -> 100 %, 127 -> "-inf" and 0 %, GR 6 dB -> 30 %') const mark = sent.length cb('input_meter_value', { channel: 7 }) await sleep(80) const sub = sent.slice(mark).find((m) => m.address === '/Input_Channels/7/Channel_Input/post_meter') const subWire = sub && decodeArgs(sub) check(!!subWire && subWire.tags === ',ii' && subWire.args[0] === id7 && subWire.args[1] === 1, `the first meter feedback subscribes lazily: ${JSON.stringify(subWire)}`) cb('input_meter_value', { channel: 7 }) check(sent.slice(mark).filter((m) => m.address === '/Input_Channels/7/Channel_Input/post_meter').length === 1, 'a meter is subscribed only once') check(await waitFor(() => S.getMeter(id7), 3000), 'the console streams /Meters/values for the subscribed id') check(S.getMeter(id7).value === 127 && cb('input_meter_value', { channel: 7 }) === '-inf' && cb('input_meter_pos', { channel: 7 }) === 0 && cb('input_signal', { channel: 7 }) === false && cb('input_peak', { channel: 7 }) === false, 'a silent channel (127) reads -inf, position 0, no signal, no peak') // a live channel streams 20-60 (the mock has inputs 1-3 live) cb('input_meter_value', { channel: 2 }) check(await waitFor(() => S.getMeter(meterIdForInput(2)), 3000) && S.getMeter(meterIdForInput(2)).value >= 20 && S.getMeter(meterIdForInput(2)).value <= 60 && /^-(2\d|[3-5]\d|60)$/.test(cb('input_meter_value', { channel: 2 })) && cb('input_signal', { channel: 2, thresholdDb: -70 }) === true, 'a live channel streams 20-60: the text reads -20 .. -60 dBFS and signal is present') { const m2 = sent.length cb('input_meter_value', { channel: 7, meter: 'gate' }) cb('input_meter_pos', { channel: 7, meter: 'comp' }) await sleep(80) const gate = sent.slice(m2).find((m) => m.address === '/Input_Channels/7/Dynamics/GR_meter' && m.args[1].i === 1) const comp = sent.slice(m2).find((m) => m.address === '/Input_Channels/7/Dynamics/GR_meter' && m.args[1].i === 4) check(gate?.args[0].i === meterId(7, 'gate') && comp?.args[0].i === meterId(7, 'comp') && meterId(7, 'gate') >= 5000 && meterId(7, 'comp') !== meterId(7, 'gate'), `gate and compressor gain-reduction meters subscribe with kinds 1 and 4 (ids ${meterId(7, 'gate')} / ${meterId(7, 'comp')})`) check(await waitFor(() => S.getMeter(meterId(7, 'gate')) && S.getMeter(meterId(7, 'comp')), 3000), 'gain-reduction meters stream too') const g = S.getMeter(meterId(7, 'gate')).value check(g === 127 || (g >= 0 && g <= 12), `gain reduction streams 0-12 dB, or 127 when inactive (${g})`) } const m = S.getMeter(id7) check(m.value >= 0 && m.value <= 127, `meter values are 0..127 (${m.value})`) // deterministic values injected as if from the console const inject = (value, id = id7) => inst.onConsoleMessage(decode(encode('/Meters/values', [{ i: id }, { i: value }])), { address: '127.0.0.1', port: P.mock }) inject(127) check(cb('input_meter_value', { channel: 7 }) === '-inf' && cb('input_meter_pos', { channel: 7 }) === 0 && cb('input_signal', { channel: 7 }) === false && cb('input_peak', { channel: 7 }) === false, 'silence (127): -inf, position 0 %, no signal, no peak') inject(0) check(cb('input_meter_value', { channel: 7 }) === '0' && cb('input_meter_pos', { channel: 7 }) === 100 && cb('input_signal', { channel: 7 }) === true && cb('input_peak', { channel: 7, hold: 2 }) === true, 'full scale (0): 0 dBFS, position 100 %, signal and peak') inject(42) check(cb('input_meter_value', { channel: 7 }) === '-42' && near(cb('input_meter_pos', { channel: 7 }), 30), 'a value of 42 reads "-42" and sits at 30 % of the -60..0 scale') check(cb('input_signal', { channel: 7, thresholdDb: -50 }) === true && cb('input_signal', { channel: 7, thresholdDb: -40 }) === false, 'signal is a dBFS threshold: -42 is above -50 and below -40') check(cb('input_peak', { channel: 7, hold: 2 }) === true, 'the peak from a moment ago is still held after the level fell back') await sleep(30) check(cb('input_peak', { channel: 7, hold: 0 }) === false, 'the peak hold expires') // the peak threshold is an option: 4 steps below full scale is a peak at -5 dBFS but not at -1 const id8 = meterIdForInput(8) cb('input_meter_value', { channel: 8 }) await sleep(60) inject(4, id8) check(cb('input_peak', { channel: 8, peakDb: -5, hold: 2 }) === true && cb('input_peak', { channel: 8, peakDb: -1, hold: 2 }) === false && cb('input_peak', { channel: 8, hold: 2 }) === false, 'peak at or above a dBFS option: a reading of 4 peaks at -5 dBFS, not at the default -1') // gain reduction meters const gateId = meterId(7, 'gate') inject(6, gateId) check(cb('input_meter_value', { channel: 7, meter: 'gate' }) === '6' && near(cb('input_meter_pos', { channel: 7, meter: 'gate' }), 30) && cb('input_signal', { channel: 7, meter: 'gate' }) === true && cb('input_peak', { channel: 7, meter: 'gate' }) === false, 'gain reduction 6 dB reads "6", sits at 30 % of 0..20 dB and counts as signal; there is no peak') inject(127, gateId) check(cb('input_meter_value', { channel: 7, meter: 'gate' }) === '0' && cb('input_meter_pos', { channel: 7, meter: 'gate' }) === 0 && cb('input_signal', { channel: 7, meter: 'gate' }) === false, 'gain reduction 127 (inactive) reads "0", 0 % and no signal') inject(64) const bar = cb('input_meter_bar', { channel: 7 }, { width: 72, height: 72 }) check(Buffer.from(bar.imageBuffer, 'base64').length === thicknessFor(72) * 72 * 4 && bar.imageBufferPosition.x === 72 - thicknessFor(72), 'input_meter_bar (legacy) returns a right-edge strip of the right size') // output meters: buss (the address follows the aux / group side) and matrix output { const m3 = sent.length cb('buss_meter_value', { buss: 3 }) cb('buss_meter_pos', { buss: 9 }) cb('matrix_meter_value', { matrix: 2 }) cb('buss_meter_value', { buss: 3 }) await sleep(100) const subs = sent.slice(m3).filter((x) => /\/Output\/meter$/.test(x.address)).map((x) => `${x.address} ${JSON.stringify(decodeArgs(x).args)}`) check(subs.join('|') === '/Aux_Outputs/3/Output/meter [5403,1]|/Group_Outputs/9/Output/meter [5409,1]|/Matrix_Outputs/2/Output/meter [5452,1]', `output meters subscribe lazily and once: aux buss 3, group buss 9 (Group_Outputs side), matrix output 2 (${subs.join(' | ')})`) check(await waitFor(() => S.getMeter(5403) && S.getMeter(5409) && S.getMeter(5452), 3000) && S.getMeter(5403).value === 127, 'output meters stream; a silent output reads 127') cb('buss_meter_value', { buss: 1 }) check(await waitFor(() => S.getMeter(5401), 3000) && S.getMeter(5401).value >= 20 && S.getMeter(5401).value <= 60 && /^-(2\d|[3-5]\d|60)$/.test(cb('buss_meter_value', { buss: 1 })), 'a live output (aux buss 1) reads -20 .. -60 dBFS') inject(30, 5403) check(cb('buss_meter_value', { buss: 3 }) === '-30' && near(cb('buss_meter_pos', { buss: 3 }), 50) && cb('buss_signal', { buss: 3 }) === true && cb('buss_peak', { buss: 3 }) === false, 'buss meter: 30 -> "-30", 50 %, signal, no peak') inject(1, 5403) check(cb('buss_peak', { buss: 3 }) === true && cb('buss_signal', { buss: 3, thresholdDb: -0.5 }) === false, 'buss meter: a reading of 1 (-1 dBFS) peaks; signal needs to exceed its threshold') inject(127, 5403) check(cb('buss_meter_value', { buss: 3 }) === '-inf' && cb('buss_meter_pos', { buss: 3 }) === 0 && cb('buss_signal', { buss: 3 }) === false, 'buss meter: 127 is -inf, 0 %, no signal') inject(50, 5452) check(cb('matrix_meter_value', { matrix: 2 }) === '-50' && near(cb('matrix_meter_pos', { matrix: 2 }), (10 / 60) * 100) && cb('matrix_signal', { matrix: 2, thresholdDb: -60 }) === true && cb('matrix_signal', { matrix: 2 }) === false && cb('matrix_peak', { matrix: 2 }) === false, 'matrix output meter: 50 -> "-50", position over -60..0, signal against the dBFS option') inject(0, 5452) check(cb('matrix_peak', { matrix: 2 }) === true && cb('matrix_meter_pos', { matrix: 2 }) === 100, 'matrix output meter: 0 is full scale, peak and 100 %') } // throttling: a flood of meter messages produces at most ~10 feedback checks per second, by instance id const watching7 = inst.watchers.get('meter:' + id7) const gateInstance = `fb-input_meter_value-${JSON.stringify({ channel: 7, meter: 'gate' })}` check(!!watching7 && watching7.size >= 4 && !watching7.has(gateInstance) && inst.watchers.get('meter:' + meterId(7, 'gate'))?.has(gateInstance), `meter feedback instances are tracked per meter id (${watching7?.size} watch meter ${id7}; the gate-reduction one watches its own id)`) // stop the mock's own meter stream (a raw /Meters/clear straight to the console) so only injected values move const raw = dgram.createSocket('udp4') raw.send(encode('/Meters/clear', []), P.mock, '127.0.0.1') await sleep(400) raw.close() ctx.checks.length = 0 ctx.byId.length = 0 const t0 = Date.now() for (let i = 0; i < 100; i++) { inject(i % 128) await sleep(10) } const spanMs = Date.now() - t0 const meterChecks = ctx.byId check(meterChecks.length > 0 && meterChecks.length <= Math.ceil((spanMs / 1000) * 10) + 1, `meter feedback checks throttled to <= 10 Hz (${meterChecks.length} checks in ${spanMs} ms for 100 messages)`) // level meters with a peak hold still running (a live channel near full scale, the injected ones) are re-checked too; only meter instances ever are const meterInstances = new Set([...inst.watchers].filter(([key]) => key.startsWith('meter:')).flatMap(([, ids]) => [...ids])) check(meterChecks.every(([, ids]) => ids.every((x) => meterInstances.has(x))) && !meterChecks.some(([, ids]) => ids.includes(gateInstance)), 'a meter update re-checks only meter instances: those watching the changed id (and meters with a peak hold running), never the gain reduction one that did not move') check(!ctx.checks.some(([, types]) => types.some((t) => /meter|signal|peak/.test(t))), `meter updates never fall back to per-type checks (per-type checks in that window: ${JSON.stringify(ctx.checks.map(([, t]) => t))}, from the keepalive tick)`) const rebuildsBefore = ctx.rebuilds await sleep(200) check(ctx.rebuilds === rebuildsBefore, 'meter traffic never rebuilds the definitions') check(!sent.some((m) => m.address === '/Meters/clear'), 'the module never sends /Meters/clear') } // --------------------------------------------------------------------------- // 8. Relay with a fake iPad: fan-out both ways, /Meters/clear resubscribe // --------------------------------------------------------------------------- { const ipadRx = [] const ipad = dgram.createSocket('udp4') ipad.on('message', (buf) => ipadRx.push(decode(buf))) await new Promise((r) => ipad.bind(P.ipad, '127.0.0.1', r)) ipad.send(encode('/Console/Name/?', []), P.relay, '127.0.0.1') await sleep(300) check(ipadRx.some((m) => m.address === '/Console/Name'), `iPad got /Console/Name via the relay (${ipadRx.length} msgs)`) check(inst.relay.clientCount === 1, 'relay tracks one iPad client') await sleep(60) check(ctx.variables.relay_clients === 1 && ctx.variables.relay_client_ips === '127.0.0.1' && cb('relay_clients', {}) === true, 'relay_clients variable and feedback') ipad.send(encode('/Input_Channels/9/Aux_Send/3/send_level', [-20]), P.relay, '127.0.0.1') await sleep(300) check(near(S.getSend(9, 3).level, -20), 'iPad write relayed to the console; the echo updated the module state') check(ipadRx.some((m) => m.address === '/Input_Channels/9/Aux_Send/3/send_level'), 'console echo of the iPad write fanned back to the iPad') ipadRx.length = 0 await run('input_fader_set', { channel: 21, level: -9 }) await sleep(200) check(ipadRx.some((m) => m.address === '/Input_Channels/21/fader'), 'a change made by the module reaches the iPad too') // /Meters/clear from the iPad: forwarded verbatim, then the module resubscribes. The silent input 7 reports its 127 once per // subscription, so to see the subscription come back its state is first moved off 127 (a timestamp would race the mock's tick). const injectMeter = (id, value) => inst.onConsoleMessage(decode(encode('/Meters/values', [{ i: id }, { i: value }])), { address: '127.0.0.1', port: P.mock }) injectMeter(meterIdForInput(7), 42) check(S.getMeter(meterIdForInput(7)).value === 42, 'input 7 meter state is parked at 42 before the clear') const mark = sent.length const t0 = Date.now() ipad.send(encode('/Meters/clear', []), P.relay, '127.0.0.1') await sleep(120) check(!sent.slice(mark).some((m) => m.address === '/Input_Channels/7/Channel_Input/post_meter'), 'no resubscribe before the delay has passed') check(await waitFor(() => sent.slice(mark).some((m) => m.address === '/Input_Channels/7/Channel_Input/post_meter'), 2000), 'after the iPad sent /Meters/clear the module re-sent its meter subscription') const resub = sent.slice(mark).find((m) => m.address === '/Input_Channels/7/Channel_Input/post_meter') check(decodeArgs(resub).args[0] === meterIdForInput(7), `resubscribed with the same id (${meterIdForInput(7)})`) await sleep(200) const reOut = sent.slice(mark).filter((m) => /\/Output\/meter$/.test(m.address)).map((m) => `${m.address} ${decodeArgs(m).args[0]}`) check(['/Aux_Outputs/3/Output/meter 5403', '/Group_Outputs/9/Output/meter 5409', '/Matrix_Outputs/2/Output/meter 5452', '/Aux_Outputs/1/Output/meter 5401'].every((x) => reOut.includes(x)), `the output meters are re-subscribed too after /Meters/clear (${reOut.length} output subscriptions re-sent)`) check(await waitFor(() => S.getMeter(meterIdForInput(7))?.value === 127, 3000), 'the silent meter reports its 127 again after the resubscribe: the console really has the subscription back') check(await waitFor(() => (S.getMeter(meterIdForInput(2))?.at ?? 0) > t0, 3000), 'a live meter streams again after the resubscribe (it had stopped at the first /Meters/clear)') // back-to-back clears: every clear pushes the resubscribe back, and the newest one always gets a resubscribe after it { const id7 = meterIdForInput(7) const subsSince = (m) => sent.slice(m).filter((x) => x.address === '/Input_Channels/7/Channel_Input/post_meter').length injectMeter(id7, 42) ipad.send(encode('/Meters/clear', []), P.relay, '127.0.0.1') // clear 1 await sleep(110) const m2 = sent.length ipad.send(encode('/Meters/clear', []), P.relay, '127.0.0.1') // clear 2, while the resubscribe for clear 1 is still waiting await sleep(150) check(subsSince(m2) === 0, 'a second clear while the resubscribe is pending moves it back: nothing was re-sent yet, so nothing can land before the newest clear') check(await waitFor(() => subsSince(m2) === 1, 2000), 'the resubscribe follows the newest clear') check(await waitFor(() => S.getMeter(id7).value === 127, 3000), 'and the console reports again after it (clear 2 did not leave the meters unsubscribed)') // a clear right after a resubscribe went out: it needs its own resubscribe injectMeter(id7, 42) const m3 = sent.length const t3 = Date.now() ipad.send(encode('/Meters/clear', []), P.relay, '127.0.0.1') // clear 3 check(await waitFor(() => subsSince(m3) === 1, 2000), 'a clear after the previous resubscribe schedules another one') check(await waitFor(() => S.getMeter(id7).value === 127, 3000) && (await waitFor(() => (S.getMeter(meterIdForInput(2))?.at ?? 0) > t3, 3000)), 'the third clear is followed by working subscriptions too (silent and live meters report again)') // clears in quick succession all collapse into one resubscribe after the last of them injectMeter(id7, 42) const m4 = sent.length for (let k = 0; k < 4; k++) { ipad.send(encode('/Meters/clear', []), P.relay, '127.0.0.1') await sleep(60) } check(await waitFor(() => S.getMeter(id7).value === 127, 3000) && subsSince(m4) === 1, `four clears 60 ms apart give exactly one resubscribe, after the last (${subsSince(m4)})`) } check(!sent.some((m) => m.address === '/Meters/clear'), 'still never sent /Meters/clear ourselves') // unpadded datagram from an iPad is forwarded verbatim const trimmed = encode('/Console/Name/?', []).subarray(0, encode('/Console/Name/?', []).length - 2) const before = ipadRx.length ipad.send(trimmed, P.relay, '127.0.0.1') await sleep(300) console.log(`info unpadded query forwarded; console replies after it: ${ipadRx.length - before} (the mock may or may not accept unpadded input)`) await run('relay_forget', {}) check(inst.relay.clientCount === 0, 'relay_forget clears the iPad client list') ipad.close() } // --------------------------------------------------------------------------- // 9. Reload action; silence -> disconnected -> reconnect reloads // --------------------------------------------------------------------------- { const loads = logCount(/load complete/) const mark = sent.length await run('reload', {}) check(await waitFor(() => logCount(/load complete/) === loads + 1, 20000), 'reload re-ran the whole load (load complete logged again)') const again = sent.slice(mark).map((m) => m.address) check(again.includes('/Console/Matrix_Outputs/?') && again.includes('/Console/Aux_Outputs/types/?') && again.includes('/Input_Channels/1/Channel_Input/name/?') && again.includes('/Input_Channels/1/fader/?'), 'reload re-queries counts, buss types, names and values') inst.timing.silenceMs = 500 mock.kill('SIGSTOP') await sleep(700) inst.tick() check(ctx.statuses.at(-1)?.[0] === 'disconnected' && inst.connectedFlag === false, 'silence from the console -> status disconnected') await sleep(50) check(ctx.variables.connected === 'No' && cb('connected', {}) === false, 'connected variable and feedback follow') const loadsBefore = logCount(/load complete/) mock.kill('SIGCONT') check(await waitFor(() => inst.connectedFlag === true, 8000), 'traffic resumes -> connected again') check(await waitFor(() => logCount(/load complete/) === loadsBefore + 1, 20000), 'reconnect triggered a full reload (load complete logged again)') check(ctx.statuses.at(-1)?.[0] === 'ok' && ctx.variables.connected === 'Yes', 'status ok after reconnect') inst.timing.silenceMs = 20000 } // --------------------------------------------------------------------------- // 11. Config changes touch only what changed; relay clients survive; one lifecycle chain // --------------------------------------------------------------------------- { const ipadRx = [] const ipad = dgram.createSocket('udp4') ipad.on('message', (buf) => ipadRx.push(decode(buf))) await new Promise((r) => ipad.bind(P.ipad, '127.0.0.1', r)) ipad.send(encode('/Console/Name/?', []), P.relay, '127.0.0.1') check(await waitFor(() => inst.relay?.clientCount === 1, 3000), 'an iPad is registered with the relay') const link0 = inst.link const relay0 = inst.relay const clients0 = inst.relayClients const pushes0 = JSON.stringify(ctx.pushes) const loads0 = logCount(/Loading from the console/) const pushFader = async (level) => { ipadRx.length = 0 await run('input_fader_set', { channel: 22, level }) return waitFor(() => ipadRx.some((m) => m.address === '/Input_Channels/22/fader'), 2500) } await inst.configUpdated({ ...config, debugLog: true }) check(inst.link === link0 && inst.relay === relay0 && relay0.clientCount === 1 && inst.config.debugLog === true, 'a debugLog change only flips the flag: same link, same relay, iPad still registered') check(await pushFader(-8), 'a console push reaches the iPad right after the debug toggle') check(logs.some((l) => l.level === 'debug' && /console -> /.test(l.message)), 'debug logging takes effect without reconnecting') await inst.configUpdated({ ...config, debugLog: true, relayTimeout: 120 }) check(inst.relay === relay0 && relay0.timeoutMs === 120000 && relay0.clientCount === 1, 'a relayTimeout change updates the live relay in place') await inst.configUpdated({ ...config, debugLog: true, relayTimeout: 0 }) relay0.clients.get('127.0.0.1').lastSeen = 1 // silent for ages check(relay0.timeoutMs === 0 && relay0.clientCount === 1 && (await pushFader(-7)), 'relayTimeout 0 = never forget: an iPad silent for ages is still listed and still gets console traffic') await inst.configUpdated({ ...config, debugLog: true, relayTimeout: 3600 }) relay0.clients.get('127.0.0.1').lastSeen = Date.now() - 4000 * 1000 check(relay0.clientCount === 0, 'with a 3600 s timeout an iPad silent for longer is no longer live') relay0.clients.get('127.0.0.1').lastSeen = Date.now() await inst.configUpdated({ ...config, debugLog: true, relayTimeout: 60, relayClientPort: 0 }) check(inst.relay === relay0 && relay0.clientPort === 0 && (await pushFader(-6)), 'a relayClientPort change is applied in place (0 = reply to the iPad source port)') await inst.configUpdated({ ...config, relayPort: P.spare, relayClientPort: 0 }) check(inst.relay !== relay0 && inst.relay.port === P.spare && inst.relay.clients === clients0 && inst.relay.clientCount === 1, 'a relay port change reopens the relay but carries the iPad client table across') check(inst.link === link0, 'the console link is untouched by relay changes') check(await pushFader(-5), 'the iPad receives console traffic through the reopened relay without registering again') ipad.send(encode('/Input_Channels/23/Aux_Send/3/send_level', [-21]), P.spare, '127.0.0.1') check(await waitFor(() => near(S.getSend(23, 3).level, -21), 2500), 'an iPad can send to the new relay port') await inst.configUpdated({ ...config, relayEnable: false }) check(inst.relay === null && inst.relayClients === clients0 && clients0.size === 1, 'disabling the relay closes it and keeps the client table') await inst.configUpdated({ ...config }) check(!!inst.relay && inst.relay.port === P.relay && inst.relay.clientCount === 1 && (await pushFader(-4)), 'enabling it again brings the relay back with the same iPad') check(inst.link === link0 && logCount(/Loading from the console/) === loads0 && JSON.stringify(ctx.pushes) === pushes0, 'none of that reloaded the console or rebuilt / re-pushed any definition') // a console-link field reopens the link and reloads; the iPad stays registered; the identical result pushes nothing const loadsDone = logCount(/load complete/) await inst.configUpdated({ ...config, localPort: P.link + 5 }) check(inst.link !== link0 && inst.link.localPort === P.link + 5 && logCount(/Loading from the console/) === loads0 + 1, 'a local port change reopens the console link and starts a load') await inst.configUpdated({ ...config }) check(await waitFor(() => logCount(/load complete/) === loadsDone + 1, 20000), 'changing it back reloads from the console') check(inst.relayClients === clients0 && inst.relay.clientCount === 1 && (await pushFader(-3)), 'the iPad stays registered through a console-link reopen') check(JSON.stringify(ctx.pushes) === pushes0, 'the reload after reopening the link pushed no identical definitions again') ipad.close() } // Overlapping lifecycle calls run one after the other (no EADDRINUSE from two connects racing) { const c = makeContext() const i = new Instance(c) Object.assign(i.timing, { txPaceMs: 3, settleMs: 100 }) const cfg = { host: '127.0.0.1', consolePort: P.spare + 3, localPort: P.spare + 4, relayEnable: true, relayPort: P.spare + 5, relayClientPort: P.spare + 6, relayTimeout: 60, debugLog: false } const before = logCount(/EADDRINUSE|in use/) let threw = null try { await Promise.all([i.init(cfg), i.configUpdated({ ...cfg, consolePort: P.spare + 7 }), i.configUpdated({ ...cfg, consolePort: P.spare + 8 }), i.configUpdated({ ...cfg, consolePort: P.spare + 9, relayPort: P.spare + 2 })]) } catch (e) { threw = e } check(!threw && !!i.link && i.link.port === P.spare + 9 && i.relay?.port === P.spare + 2 && logCount(/EADDRINUSE|in use/) === before, `init and three overlapping configUpdated calls settled in order (link -> ${i.link?.port}, relay ${i.relay?.port}, no bind errors: ${threw?.message || 'ok'})`) let again = null try { await Promise.all([i.configUpdated({ ...cfg, localPort: P.spare + 4, consolePort: P.spare + 3 }), i.destroy()]) } catch (e) { again = e } check(!again && i.link === null && i.relay === null, 'configUpdated followed straight away by destroy leaves nothing open') const probe = dgram.createSocket('udp4') const bound = await new Promise((r) => { probe.once('error', () => r(false)) probe.bind(P.spare + 4, '127.0.0.1', () => r(true)) }) probe.close() check(bound, 'the local port is free again after destroy') } // Relay unit checks: idle timeout (0 = never) and the 16-client cap { const r = new Relay({ port: P.spare + 1, timeoutMs: 0 }) const gone = [] r.on('clientGone', (ip) => gone.push(ip)) r.touch({ address: '10.0.0.1', port: 1000 }) r.clients.get('10.0.0.1').lastSeen = 1 check(r.clientCount === 1 && r.liveClients().length === 1, 'timeout 0: a client silent since the epoch is still live') r.update({ timeoutMs: 1000 }) check(r.clientCount === 0, 'timeout 1 s: the same client is no longer live') const r2 = new Relay({ port: P.spare + 1, timeoutMs: 3600000, maxClients: 16 }) const gone2 = [] r2.on('clientGone', (ip) => gone2.push(ip)) for (let i = 1; i <= 16; i++) { r2.touch({ address: `10.0.0.${i}`, port: 1000 + i }) r2.clients.get(`10.0.0.${i}`).lastSeen = i // ten.0.0.1 is the least recently seen } r2.clients.get('10.0.0.1').lastSeen = 0 r2.touch({ address: '10.0.0.99', port: 2000 }) check(r2.clients.size === 16 && !r2.clients.has('10.0.0.1') && r2.clients.has('10.0.0.99') && gone2.join() === '10.0.0.1', 'the client table is capped at 16: the least recently seen client is evicted for a new one') r2.touch({ address: '10.0.0.2', port: 1002 }) check(r2.clients.size === 16 && r2.clients.has('10.0.0.2'), 'a known client does not evict anyone') check(new Relay({ port: 1 }).maxClients === 16 && new Relay({ port: 1 }).timeoutMs === 3600000, 'relay defaults: 16 clients, 3600 s') } // --------------------------------------------------------------------------- // 12. Config validation, sockets that cannot throw, throttled error logging // --------------------------------------------------------------------------- { const bad = [ [{ host: '' }, /Console IP/], [{ host: '127.0.0.1', consolePort: 0 }, /Console receive port/], [{ host: '127.0.0.1', consolePort: 70000 }, /Console receive port/], [{ host: '127.0.0.1', localPort: 'abc' }, /Local receive port/], [{ host: '127.0.0.1', consolePort: 8000.5 }, /Console receive port/], [{ host: '127.0.0.1', relayEnable: true, relayPort: 0 }, /Relay port/], [{ host: '127.0.0.1', relayEnable: true, relayClientPort: 70000 }, /iPad receive port/], [{ host: '127.0.0.1', relayEnable: true, relayTimeout: -1 }, /idle timeout/], [{ host: '127.0.0.1', relayEnable: true, relayTimeout: 100000 }, /idle timeout/], ] let allFine = true for (const [cfg, re] of bad) { const c = makeContext() const i = new Instance(c) let err = null try { await i.init({ consolePort: P.spare + 3, localPort: P.spare + 4, relayPort: P.spare + 5, ...cfg }) } catch (e) { err = e } const st = c.statuses.at(-1) const ok = !err && st?.[0] === 'bad_config' && re.test(st[1]) && i.link === null && i.relay === null if (!ok) console.log(` ${JSON.stringify(cfg)} -> ${err?.message || JSON.stringify(st)}`) allFine &&= ok await i.destroy() } check(allFine, `${bad.length} invalid configs (empty host, ports 0 / 70000 / text / fractions, bad relay values) give bad_config with a clear message and never throw`) const n = mainMod.normalizeConfig({ host: ' 127.0.0.1 ', consolePort: '8000', localPort: '9000', relayEnable: true, relayPort: '8001', relayClientPort: '0', relayTimeout: '0' }) check(n.cfg?.consolePort === 8000 && n.cfg.localPort === 9000 && n.cfg.relayPort === 8001 && n.cfg.relayClientPort === 0 && n.cfg.relayTimeout === 0 && n.cfg.host === '127.0.0.1', 'numeric strings are coerced; relayClientPort 0 and relayTimeout 0 are valid') check(mainMod.normalizeConfig({ host: '127.0.0.1', relayEnable: false, relayPort: 'junk' }).error === null, 'relay fields are not validated while the relay is off') const fields = new Instance(makeContext()).getConfigFields().filter((f) => f.type === 'number') check(fields.length === 5 && fields.every((f) => f.asInteger === true), `every number config field (${fields.map((f) => f.id).join(', ')}) is asInteger`) const timeoutField = fields.find((f) => f.id === 'relayTimeout') check(timeoutField.default === 3600 && timeoutField.min === 0 && timeoutField.max === 86400, 'relayTimeout defaults to 3600 s, range 0..86400 (0 = never)') } { // sends that would throw synchronously are caught and reported const link = new ConsoleLink({ host: '127.0.0.1', port: 70000, localPort: P.spare + 4 }) link.noRecord = true const errors = [] link.on('error', (e) => errors.push(e)) await link.open() let threw = false let result try { result = link.send('/Console/Name/?', []) } catch { threw = true } check(!threw && result === false && errors.length === 1 && errors[0].dest === '127.0.0.1:70000', `a synchronous socket.send failure (bad port) is caught: send() returns false, one error event with its destination (${errors[0]?.code})`) let threw2 = false try { link.send('/x', [{ nope: 1 }]) } catch { threw2 = true } check(!threw2 && errors.length === 2, 'an argument that cannot be encoded does not throw either') link.close() check((() => { try { return link.send('/x', []) === false } catch { return false } })(), 'sending on a closed link returns false') const relay = new Relay({ port: P.spare + 1, clientPort: 70000 }) const relayErrors = [] relay.on('error', (e) => relayErrors.push(e)) await relay.open() relay.touch({ address: '127.0.0.1', port: 1234 }) let relayThrew = false try { relay.fanout(Buffer.from('x')) } catch { relayThrew = true } relay.close() check(!relayThrew && relayErrors.length === 1 && relayErrors[0].dest === '127.0.0.1:70000', 'relay fan-out catches a synchronous send failure too') } { // at most one log line per error code + destination per 30 s const e = (code, dest) => Object.assign(new Error('boom'), { code, dest }) const lines = () => logCount(/Console socket: boom/) const before = lines() for (let i = 0; i < 25; i++) inst.reportError('Console socket', e('ENETUNREACH', '192.0.2.9:8000')) check(lines() === before + 1, '25 identical send errors produce one log line') inst.reportError('Console socket', e('ENETUNREACH', '192.0.2.10:8000')) inst.reportError('Console socket', e('EHOSTUNREACH', '192.0.2.9:8000')) check(lines() === before + 3, 'a different destination or error code logs its own line') const savedWindow = inst.timing.errorLogMs inst.timing.errorLogMs = 100 await sleep(150) inst.reportError('Console socket', e('ENETUNREACH', '192.0.2.9:8000')) inst.timing.errorLogMs = savedWindow check(lines() === before + 4, 'the same error logs again once the window has passed') } // --------------------------------------------------------------------------- // 13. Index validation: no packet, one warning // --------------------------------------------------------------------------- { check(indexProblem('/Input_Channels/60/fader') === null && indexProblem('/Aux_Outputs/25/mute') === null && indexProblem('/Console/Name') === null, 'indexProblem accepts in-range entity addresses and non-entity addresses') check(['/Input_Channels/0/fader', '/Input_Channels/-1/fader', '/Input_Channels/1.5/fader', '/Input_Channels/61/fader', '/Input_Channels/NaN/fader', '/Input_Channels/undefined/fader', '/Aux_Outputs/26/fader', '/Input_Channels/3/Aux_Send/26/send_on', '/Matrix_Outputs/9/mute', '/Matrix_Inputs/11/Matrix_Send/1/send_on', '/Control_Groups/11/fader', '/Graphic_EQ/17/geq_in', '/Graphic_EQ/1/geq_gain_33', '/Graphic_EQ/1/geq_gain_1.5'].every((a) => indexProblem(a)), 'indexProblem rejects 0, negatives, fractions, NaN, too large numbers and bad send / band numbers') const bad = [ ['input_fader_set', { channel: 0, level: -5 }], ['input_fader_set', { channel: -3, level: -5 }], ['input_fader_set', { channel: 1.5, level: -5 }], ['input_fader_set', { channel: 61, level: -5 }], ['input_fader_set', { channel: NaN, level: -5 }], ['input_fader_adjust', { channel: 0, delta: 1 }], ['input_mute', { channels: [62], state: 'toggle' }], ['input_pan_set', { channel: 99, pan: 0.5 }], ['send_on', { channel: 3, buss: 26, state: 'toggle' }], ['send_level_set', { channel: 3, buss: 0, level: -5 }], ['send_level_adjust', { channel: 3, buss: 2.5, delta: 1 }], ['group_assign', { channel: 3, buss: 30, state: 'on' }], ['buss_mute', { busses: [26], state: 'on' }], ['buss_fader_set', { buss: 0, level: -5 }], ['matrix_mute', { matrix: 9, state: 'on' }], ['matrix_send_on', { matrixIn: 11, matrixOut: 1, state: 'on' }], ['matrix_send_level_set', { matrixIn: 1, matrixOut: 9, level: -5 }], ['cg_mute', { cg: 11, state: 'on' }], ['cg_fader_adjust', { cg: 0, delta: 1 }], ['geq_on', { geq: 17, state: 'on' }], ['geq_band_set', { geq: 1, band: 33, gain: 1 }], ['geq_band_set', { geq: 1, band: 1.5, gain: 1 }], ['geq_band_adjust', { geq: 1, band: 0, delta: 1 }], ['input_phantom', { channel: 70, state: 'toggle' }], ['input_hpf_freq_adjust', { channel: 0, delta: 5 }], ['buss_trim_set', { buss: 26, value: 1 }], ['matrix_trim_set', { matrix: 0, value: 1 }], ['snapshot_fire', { snapshot: -1 }], ['snapshot_fire', { snapshot: 1.5 }], ['snapshot_fire', { snapshot: 10000 }], ['snapshot_fire', { snapshot: NaN }], ] await sleep(inst.timing.writeGuardMs + 400) // let earlier confirm queries finish so only this test's traffic is counted await waitFor(() => !inst.txTimer, 5000) let packets = 0 let wrongWarnings = 0 let slow = 0 for (const [id, options] of bad) { const mark = sent.length const warnings = logs.filter((l) => l.level === 'warn').length const t0 = Date.now() await ctx.actions[id].callback({ id: 'a', controlId: 'c', actionId: id, options, surfaceId: undefined }, {}) await sleep(5) const warned = logs.filter((l) => l.level === 'warn').length - warnings if (Date.now() - t0 > 300) slow++ packets += sent.slice(mark).filter((m) => !/^\/Console\/Name\/\?$/.test(m.address)).length if (warned !== 1) { wrongWarnings++ console.log(` ${id} ${JSON.stringify(options)}: ${warned} warnings`) } } check(packets === 0, `${bad.length} actions with an out-of-range or non-integer index sent no packet`) check(wrongWarnings === 0, 'each rejected action logged exactly one warning') check(slow === 0, 'a rejected toggle or adjust does not sit waiting for a reply') const mark = sent.length check(inst.write('/Input_Channels/0/fader', [1], 1) === false && inst.queryNow('/Input_Channels/1.5/fader') === false && (inst.ensure('t:bad', ['/Input_Channels/70/EQ/eq_in']), true), 'write, queryNow and ensure refuse bad addresses on their own') check(sent.length === mark, 'and send nothing') check(inst.fireSnapshot(0) === true && inst.fireSnapshot(9999) === true, 'snapshot indexes 0 and 9999 are accepted') } // --------------------------------------------------------------------------- // 14. Definitions: skip identical pushes; rebuild only on names, buss modes, snapshots, existence // --------------------------------------------------------------------------- { await sleep(inst.timing.rebuildDebounceMs + 300) const p0 = JSON.stringify(ctx.pushes) inst.rebuildNow() inst.rebuildNow() check(JSON.stringify(ctx.pushes) === p0, 'rebuilding with nothing changed pushes no action, feedback, variable or preset definition') // writing the name an input already has is no change at all const same = S.rawName('input', 2) await run('input_name_set', { channel: 2, name: same }) await sleep(inst.timing.rebuildDebounceMs + 200) check(JSON.stringify(ctx.pushes) === p0, 'writing an unchanged name rebuilds nothing') // stereo / mono flags never rebuild; the variable still follows const flip = (arr, i) => arr.map((v, k) => (k === i ? (v === 2 ? 1 : 2) : v)) const auxModes = [...Array(25)].map((_, i) => (S.busses.get(i + 1).stereo ? 2 : 1)) const inputModes = [...Array(60)].map((_, i) => (S.inputs.get(i + 1).stereo ? 2 : 1)) const wasStereo = S.busses.get(1).stereo inst.onConsoleMessage(decode(encode('/Console/Aux_Outputs/modes', flip(auxModes, 0).map((i) => ({ i })))), { address: '127.0.0.1', port: P.mock }) inst.onConsoleMessage(decode(encode('/Console/Input_Channels/modes', flip(inputModes, 0).map((i) => ({ i })))), { address: '127.0.0.1', port: P.mock }) await sleep(inst.timing.rebuildDebounceMs + 300) check(S.busses.get(1).stereo === !wasStereo && ctx.variables.buss_1_stereo === (wasStereo ? 'Mono' : 'Stereo') && JSON.stringify(ctx.pushes) === p0, 'a changed stereo flag updates the variable but pushes no definitions') inst.onConsoleMessage(decode(encode('/Console/Aux_Outputs/modes', auxModes.map((i) => ({ i })))), { address: '127.0.0.1', port: P.mock }) inst.onConsoleMessage(decode(encode('/Console/Input_Channels/modes', inputModes.map((i) => ({ i })))), { address: '127.0.0.1', port: P.mock }) // a buss mode (aux <-> group) does rebuild: labels and preset groups change const types = [...Array(25)].map((_, i) => (S.busses.get(i + 1).mode === 'aux' ? 1 : 0)) const wasAux = types[2] === 1 inst.onConsoleMessage(decode(encode('/Console/Aux_Outputs/types', types.map((v, i) => ({ i: i === 2 ? 1 - v : v })))), { address: '127.0.0.1', port: P.mock }) await sleep(inst.timing.rebuildDebounceMs + 400) const label3 = ctx.actions.buss_mute.options[0].choices.find((c) => c.id === 3)?.label check(wasAux && label3 === '3 · Group 3 (group)' && ctx.pushes.actions === JSON.parse(p0).actions + 1 && ctx.pushes.presets === JSON.parse(p0).presets + 1, `a changed buss type rebuilds and pushes once (${label3})`) const optIds = (id, o) => ctx.actions[id].options.find((x) => x.id === o).choices.map((c) => c.id) check(optIds('group_fader_set', 'buss').includes(3) && !optIds('aux_fader_set', 'buss').includes(3) && optIds('group_mute', 'busses').includes(3) && !optIds('aux_mute', 'busses').includes(3) && ctx.actions.group_fader_set.options[0].choices.find((c) => c.id === 3).label === '3 · Group 3' && ctx.feedbacks.group_muted.options[0].choices.some((c) => c.id === 3) && !ctx.feedbacks.aux_muted.options[0].choices.some((c) => c.id === 3), 'the buss moved from the aux dropdowns to the group dropdowns (actions and feedbacks)') check(!ctx.presets.aux_mute_3 && !ctx.presets.aux_knob_3 && !ctx.presets.aux_meter_3 && ctx.presetStructure.find((x) => x.id === 'aux_masters').definitions[0].presets.length === 14, 'and its aux master presets are gone from "Aux masters" (it has no group-side name, so no group presets either)') inst.onConsoleMessage(decode(encode('/Console/Aux_Outputs/types', types.map((i) => ({ i })))), { address: '127.0.0.1', port: P.mock }) await sleep(inst.timing.rebuildDebounceMs + 400) check(ctx.actions.buss_mute.options[0].choices.find((c) => c.id === 3)?.label === '3 · TRACKS (aux)' && optIds('aux_fader_set', 'buss').includes(3) && !optIds('group_fader_set', 'buss').includes(3) && !!ctx.presets.aux_mute_3 && ctx.presetStructure.find((x) => x.id === 'aux_masters').definitions[0].presets.includes('aux_mute_3'), 'and back again (dropdowns and the "Aux masters" presets)') // a changed snapshot list rebuilds const p1 = ctx.pushes.actions inst.onConsoleMessage(decode(encode('/Snapshots/name', [{ i: 8 }, { i: 900 }, { i: 0 }, 'Extra'])), { address: '127.0.0.1', port: P.mock }) await sleep(inst.timing.rebuildDebounceMs + 300) check(ctx.pushes.actions === p1 + 1 && ctx.actions.snapshot_fire.options[0].choices.some((c) => c.id === 8 && /Extra/.test(c.label)), 'a new snapshot in the list rebuilds the definitions') // a matrix input name pushed by the console rebuilds the matrix send dropdowns, preset names and titles const pm = { actions: ctx.pushes.actions, presets: ctx.pushes.presets } inst.onConsoleMessage(decode(encode('/Matrix_Inputs/2/Channel_Input/name', [' New Name '])), { address: '127.0.0.1', port: P.mock }) await sleep(inst.timing.rebuildDebounceMs + 400) const knob2 = ctx.presets.msend_knob_2_8 check(S.minputs.get(2).name === 'New Name' && ctx.pushes.actions === pm.actions + 1 && ctx.pushes.presets === pm.presets + 1, 'a matrix input name change rebuilds the definitions once') check(ctx.actions.matrix_send_on.options[0].choices.find((c) => c.id === 2)?.label === '2 · New Name' && ctx.actions.minput_trim_set.options[0].choices.find((c) => c.id === 2)?.label === '2 · New Name' && ctx.feedbacks.matrix_send_on.options[0].choices.find((c) => c.id === 2)?.label === '2 · New Name', 'the matrix send and matrix input param dropdowns show the new name, cleaned') check(ctx.variables.matrix_in_2_name === 'New Name' && knob2.name === `Matrix send level (knob): New Name → 8 · ${S.displayName('matrix', 8)}` && ctx.presets.msend_on_2_8.name.includes('New Name') && knob2.elements[1].text === '$(S31:matrix_in_2_name)' && ctx.presets.msend_on_2_8.style.text.startsWith('$(S31:matrix_in_2_name)'), 'the matrix send preset names and titles carry the new name (titles read the matrix_in_N_name variable)') inst.onConsoleMessage(decode(encode('/Matrix_Inputs/2/Channel_Input/name', ['Studio L'])), { address: '127.0.0.1', port: P.mock }) await sleep(inst.timing.rebuildDebounceMs + 400) } // --------------------------------------------------------------------------- // 15. Feedback instances are tracked per entity key and re-checked by id // --------------------------------------------------------------------------- { const mk = (feedbackId, options, id) => ({ id, controlId: 'c1', feedbackId, type: ctx.feedbacks[feedbackId].type, options, previousOptions: null }) const run1 = (f) => ctx.feedbacks[f.feedbackId].callback(f, { signal: new AbortController().signal }) const a = mk('input_muted', { channel: 5 }, 'inst-A') const b = mk('input_muted', { channel: 6 }, 'inst-B') run1(a) run1(b) check(inst.watchers.get('input:5')?.has('inst-A') && inst.watchers.get('input:6')?.has('inst-B') && !inst.watchers.get('input:5')?.has('inst-B'), 'each feedback instance is registered under the entity it watches') const flipMute = (n) => inst.onConsoleMessage(decode(encode(`/Input_Channels/${n}/mute`, [S.getMute('input', n) ? 0.0 : 1.0])), { address: '127.0.0.1', port: P.mock }) ctx.byId.length = 0 ctx.checks.length = 0 flipMute(5) await sleep(80) const ids = ctx.byId.flatMap(([, x]) => x) check(ids.includes('inst-A') && !ids.includes('inst-B') && !ctx.checks.some(([, t]) => t.includes('input_muted')), 'a change to input 5 re-checks only the instance watching input 5, with no per-type check') ctx.byId.length = 0 flipMute(51) await sleep(80) check(ctx.checks.some(([, t]) => t.includes('input_muted')) && !ctx.byId.flatMap(([, x]) => x).includes('inst-A'), 'with nobody watching input 51 the per-type check is the fallback') // options change: the instance moves to its new key run1({ ...a, options: { channel: 6 } }) check(!inst.watchers.get('input:5')?.has('inst-A') && inst.watchers.get('input:6')?.has('inst-A'), 'a feedback whose options changed is dropped from its old key and registered under the new one') ctx.feedbacks.input_muted.unsubscribe(a, {}) ctx.feedbacks.input_muted.unsubscribe(b, {}) check(!inst.watching.has('inst-A') && !inst.watching.has('inst-B') && !inst.watchers.get('input:6')?.has('inst-A') && !inst.watchers.get('input:6')?.has('inst-B'), 'unsubscribe drops the instance') check(Object.values(ctx.feedbacks).every((f) => typeof f.unsubscribe === 'function'), 'every feedback definition has an unsubscribe hook') // send pairs and the snapshot are tracked too const s1 = mk('send_on', { channel: 3, buss: 24 }, 'inst-S1') const s2 = mk('send_on', { channel: 4, buss: 24 }, 'inst-S2') const sn = mk('snapshot_current', { snapshot: 2 }, 'inst-N') for (const f of [s1, s2, sn]) run1(f) await sleep(120) ctx.byId.length = 0 inst.onConsoleMessage(decode(encode('/Input_Channels/3/Aux_Send/24/send_on', [S.getSend(3, 24).on ? 0.0 : 1.0])), { address: '127.0.0.1', port: P.mock }) inst.onConsoleMessage(decode(encode('/Snapshots/Current_Snapshot', [{ i: S.snapshots.current === 1 ? 2 : 1 }])), { address: '127.0.0.1', port: P.mock }) await sleep(80) const ids2 = ctx.byId.flatMap(([, x]) => x) check(ids2.includes('inst-S1') && !ids2.includes('inst-S2') && ids2.includes('inst-N'), 'a send change re-checks only that send pair; a snapshot change re-checks the snapshot feedbacks') for (const f of [s1, s2, sn]) ctx.feedbacks[f.feedbackId].unsubscribe(f, {}) // matrix sends and graphic EQ bands are tracked per send pair / per band const m1 = mk('matrix_send_on', { matrixIn: 2, matrixOut: 3 }, 'inst-M1') const m2 = mk('matrix_send_level_value', { matrixIn: 3, matrixOut: 2 }, 'inst-M2') const m3 = mk('matrix_send_off', { matrixIn: 2, matrixOut: 3 }, 'inst-M3') const g1 = mk('geq_band_value', { geq: 1, band: 5 }, 'inst-G1') const g2 = mk('geq_band_value', { geq: 1, band: 6 }, 'inst-G2') const g3 = mk('geq_on', { geq: 1 }, 'inst-G3') for (const f of [m1, m2, m3, g1, g2, g3]) run1(f) check(inst.watchers.get('msend:2:3')?.has('inst-M1') && inst.watchers.get('msend:3:2')?.has('inst-M2') && inst.watchers.get('geq:1:gain:5')?.has('inst-G1') && inst.watchers.get('geq:1:gain:6')?.has('inst-G2') && inst.watchers.get('geq:1:on')?.has('inst-G3'), 'matrix send pairs and graphic EQ bands register under msend:in:out and geq:n:gain:band keys') await sleep(150) ctx.byId.length = 0 inst.onConsoleMessage(decode(encode('/Matrix_Inputs/2/Matrix_Send/3/send_level', [-25])), { address: '127.0.0.1', port: P.mock }) inst.onConsoleMessage(decode(encode('/Graphic_EQ/1/geq_gain_5', [3.5])), { address: '127.0.0.1', port: P.mock }) await sleep(80) const ids3 = ctx.byId.flatMap(([, x]) => x) check(ids3.includes('inst-M1') && !ids3.includes('inst-M2') && ids3.includes('inst-G1') && !ids3.includes('inst-G2') && !ids3.includes('inst-G3'), 'a matrix send change re-checks only that send pair, a band change only that band') ctx.byId.length = 0 inst.onConsoleMessage(decode(encode('/Matrix_Inputs/2/Matrix_Send/3/send_on', [0.0])), { address: '127.0.0.1', port: P.mock }) await sleep(80) const ids3b = ctx.byId.flatMap(([, x]) => x) check(ids3b.includes('inst-M1') && ids3b.includes('inst-M3') && !ids3b.includes('inst-M2') && cb('matrix_send_off', { matrixIn: 2, matrixOut: 3 }) === true, 'a matrix send on/off change re-checks matrix_send_on and matrix_send_off of that send pair only, and the off feedback turns true') for (const f of [m1, m2, m3, g1, g2, g3]) ctx.feedbacks[f.feedbackId].unsubscribe(f, {}) // level_compare follows the kind option const lc = mk('level_compare', { kind: 'cg', cg: 2, comparison: 'above', threshold: -50 }, 'inst-L') run1(lc) check(inst.watchers.get('cg:2')?.has('inst-L'), 'level_compare registers under the entity kind it compares') ctx.feedbacks.level_compare.unsubscribe(lc, {}) // the group and aux master feedbacks watch the buss's own key, like the buss_* ones const bm = mk('buss_muted', { buss: 24 }, 'inst-BM') const gm = mk('group_muted', { buss: 24 }, 'inst-GM') const gf = mk('group_fader_pos', { buss: 24 }, 'inst-GF') const gx = mk('group_fader_value', { buss: 25 }, 'inst-GX') const ax = mk('aux_fader_value', { buss: 3 }, 'inst-AX') const gp = mk('group_signal', { buss: 24, thresholdDb: -50 }, 'inst-GP') for (const f of [bm, gm, gf, gx, ax, gp]) run1(f) check(['inst-BM', 'inst-GM', 'inst-GF'].every((id) => inst.watchers.get('buss:24')?.has(id)) && inst.watchers.get('buss:25')?.has('inst-GX') && inst.watchers.get('buss:3')?.has('inst-AX') && inst.watchers.get(`meter:${outputMeterId('buss', 24)}`)?.has('inst-GP'), 'group_* and aux_* feedbacks register under the buss key (meters under the buss meter id)') await sleep(150) ctx.byId.length = 0 inst.onConsoleMessage(decode(encode('/Group_Outputs/24/fader', [-12.25])), { address: '127.0.0.1', port: P.mock }) await sleep(80) const idsG = ctx.byId.flatMap(([, x]) => x) check(idsG.includes('inst-BM') && idsG.includes('inst-GM') && idsG.includes('inst-GF') && !idsG.includes('inst-GX') && !idsG.includes('inst-AX') && !ctx.checks.some(([, t]) => /^(group|aux|buss)_/.test(String(t))), 'a group fader change re-checks only the instances watching that buss, by id and with no per-type check') for (const f of [bm, gm, gf, gx, ax, gp]) ctx.feedbacks[f.feedbackId].unsubscribe(f, {}) // every option referenced by an isVisibleExpression must not become an expression const bad = [] for (const [id, def] of [...Object.entries(ctx.actions), ...Object.entries(ctx.feedbacks)]) { for (const o of def.options) { if (!o.isVisibleExpression) continue for (const m of o.isVisibleExpression.matchAll(/\$\(options:(\w+)\)/g)) { const ref = def.options.find((x) => x.id === m[1]) if (!ref || ref.disableAutoExpression !== true) bad.push(`${id}.${o.id} -> ${m[1]}`) } } } check(Object.values(ctx.feedbacks).some((f) => f.options.some((o) => o.isVisibleExpression)) && bad.length === 0, `options referenced by isVisibleExpression have disableAutoExpression (${bad.join(', ') || 'level_compare.kind'})`) } // --------------------------------------------------------------------------- // 16. Names, results, removed unverified actions // --------------------------------------------------------------------------- { const mark = sent.length const nameBefore = S.rawName('input', 9) const warns = logs.filter((l) => l.level === 'warn').length for (const bad of ['', ' ']) await run('input_name_set', { channel: 9, name: bad }) check(!sent.slice(mark).some((m) => /Channel_Input\/name$/.test(m.address)) && S.rawName('input', 9) === nameBefore && logs.filter((l) => l.level === 'warn').length === warns + 2, 'an empty (or blank) input name is ignored with a warning and nothing is written') const results = [] for (const [id, options] of [['input_fader_set', { channel: 20, level: -9 }], ['input_mute', { channels: [20, 21], state: 'on' }], ['send_on', { channel: 20, buss: 3, state: 'on' }], ['snapshot_next', {}], ['input_fader_set', { channel: 0, level: 1 }], ['raw_osc', { address: '/Console/Name/?', argType: 'none', value: '' }]]) { results.push(await ctx.actions[id].callback({ id: 'a', controlId: 'c', actionId: id, options, surfaceId: undefined }, {})) } check(results.every((x) => x === undefined), 'action callbacks return nothing') check(!('send_prepost' in ctx.actions) && !('send_pre' in ctx.feedbacks), 'the unverified send pre/post action and feedback are gone') check(!Object.keys(ctx.actions).some((id) => /delay_(set|adjust)$/.test(id)) && 'input_delay_on' in ctx.actions && 'buss_delay_on' in ctx.actions, 'the delay value actions are gone; the delay in/out switches stay') check(ctx.actions.input_gain_set.options[1].min === 0 && ctx.actions.input_gain_set.options[1].max === 60 && ctx.actions.input_trim_set.options[1].min === -40 && ctx.actions.input_hpf_freq_set.options[1].min === 20 && ctx.actions.input_hpf_freq_set.options[1].max === 20000, 'analog gain is limited to 0..60, trim to -40..40, filter frequencies to 20..20000') check(/Verify once on your console/.test(ctx.actions.snapshot_fire.description) && /0 = first/.test(ctx.actions.snapshot_fire.description), 'snapshot_fire says it fires by list position and should be verified first') let r = await run('input_gain_set', { channel: 14, value: 99 }) expectWrite(r, '/Input_Channels/14/Channel_Input/analog_gain', ',f', 60, 'a gain above the range is clamped to 60') r = await run('input_hpf_freq_set', { channel: 14, value: 5 }) expectWrite(r, '/Input_Channels/14/Filters/lo_filter_freq', ',f', 20, 'a filter frequency below the range is clamped to 20 Hz') } // --------------------------------------------------------------------------- // 16b. Level fades: the duration / curve options of the set-level actions // --------------------------------------------------------------------------- { // pure helpers: positionToDb is the exact inverse of faderPosition, fadeLevelAt walks a curve const ANCHORS = [[10, 100], [5, 87.5], [0, 75], [-5, 65], [-10, 55], [-20, 40], [-30, 28], [-40, 18], [-50, 11], [-60, 6]] check(ANCHORS.every(([db, pos]) => near(positionToDb(pos), db, 1e-9) && near(faderPosition(db), pos, 1e-9)), 'positionToDb returns every anchor of the fader taper exactly') check(positionToDb(0) === -90 && positionToDb(1) === -90 && positionToDb(0, -150) === -150 && positionToDb(100) === 10 && positionToDb(250) === 10 && positionToDb(-5) === -90 && positionToDb(NaN) === -90 && positionToDb(undefined, -150) === -150, 'positionToDb: position 0 is the floor (-90, or -150 when asked), 100 and more is +10, junk is the floor') let worst = 0 for (let db = -89.9; db <= 10; db += 0.1) worst = Math.max(worst, Math.abs(positionToDb(faderPosition(db)) - db)) let worst150 = 0 for (let db = -149.5; db <= 10; db += 0.5) worst150 = Math.max(worst150, Math.abs(positionToDb(faderPosition(db, -150), -150) - db)) check(worst < 0.01 && worst150 < 0.01, `positionToDb(faderPosition(dB)) returns dB within 0.01 dB over the whole range, for floor -90 and -150 (worst ${worst.toExponential(1)} / ${worst150.toExponential(1)})`) check(FADE_CURVES.join() === 'fader,ease,db' && fadeLevelAt('fader', -90, 0, 1) === 0 && fadeLevelAt('db', -20, 5, 1.5) === 5 && fadeLevelAt('ease', -50, -10, 0) === -50 && fadeLevelAt('db', -150, 0, 0.5, -90) === -45 && fadeLevelAt('db', -30, -200, 0.5, -90) === -60 && fadeLevelAt('fader', -30, -150, 1, -90) === -90 && fadeLevelAt('fader', 0, 40, 1) === 10, 'fadeLevelAt: t >= 1 is exactly the clamped target, t = 0 the start, the lower limit holds') check(fadeLevelAt('db', -150, 0, 0) === -90 && fadeLevelAt('db', -150, 0, 0.5) === -45 && fadeLevelAt('db', 0, -150, 0.5) === -45 && fadeLevelAt('db', 0, -150, 0.999) > -90.1 && fadeLevelAt('db', 0, -150, 1) === -150 && fadeLevelAt('db', -60, 0, 0.5) === -30, 'a dB-linear fade from or to -inf runs between the -90 floor and the target (no silent stretch below -90); the last step is the exact target') { const mono = (c, a, b) => { const v = [0, 0.1, 0.25, 0.5, 0.75, 0.9, 1].map((t) => fadeLevelAt(c, a, b, t)); return v.every((x, i) => i === 0 || (b >= a ? x >= v[i - 1] - 1e-9 : x <= v[i - 1] + 1e-9)) && v.every((x) => x >= Math.min(a, b) - 1e-9 && x <= Math.max(a, b) + 1e-9) } check(['fader', 'ease', 'db'].every((c) => mono(c, -150, -100) && mono(c, -100, -150) && mono(c, -150, 0) && mono(c, -95, -20) && mono(c, -20, -95) && mono(c, 5, -150)), 'no fade curve ever steps outside its start..target range or reverses (including fades that stay below -90 dB)') check(fadeLevelAt('fader', -150, -100, 0.5) === -125, 'a fade that stays below the -90 floor runs linearly in dB (-150 -> -100 is -125 halfway)') } } const go = (actionId, options) => ctx.actions[actionId].callback({ id: 'f1', controlId: 'c', actionId, options, surfaceId: undefined }, {}) const faderAddr = (n) => `/Input_Channels/${n}/fader` const writesTo = (address, mark) => sent.slice(mark).filter((m) => m.address === address).map((m) => ({ db: m.args[0], at: m.at })) const idle = () => waitFor(() => inst.fades.size === 0, 3000) { // option definitions const setIds = ['input_fader_set', 'buss_fader_set', 'matrix_fader_set', 'cg_fader_set', 'send_level_set', 'matrix_send_level_set'] const good = setIds.every((id) => { const o = ctx.actions[id].options const d = o.find((x) => x.id === 'duration') const c = o.find((x) => x.id === 'curve') return d?.type === 'number' && d.default === 0 && d.min === 0 && d.max === 60000 && d.step === 100 && d.asInteger === true && d.disableAutoExpression === true && d.label === 'Fade time (ms, 0 = instant)' && c?.type === 'dropdown' && c.default === 'fader' && c.isVisibleExpression === '$(options:duration) > 0' && c.choices.map((x) => `${x.id}=${x.label}`).join() === 'fader=Fader travel (natural),ease=Fader travel, ease in/out,db=Linear in dB' && o.indexOf(d) === o.findIndex((x) => x.id === 'level') + 1 && o.indexOf(c) === o.indexOf(d) + 1 }) check(good, 'all six set-level actions end with a "Fade time" option (0..60000 ms, step 100, integer, no expression) and a curve dropdown shown only above 0 ms') check(Object.keys(ctx.actions).filter((id) => /_adjust$/.test(id)).every((id) => !ctx.actions[id].options.some((o) => o.id === 'duration')) && !ctx.actions.input_mute.options.some((o) => o.id === 'duration'), 'adjust and switch actions have no fade option') check(ctx.actions.fade_stop?.name === 'Stop all fades' && ctx.actions.fade_stop.options.length === 0 && ctx.feedbacks.fade_running?.type === 'boolean' && ctx.feedbacks.fade_running.name === 'A fade is running' && 'fades_running' in ctx.variableDefs, 'fade_stop action, fade_running feedback and fades_running variable are defined') check(setIds.every((id) => /fade time above 0/.test(ctx.actions[id].description)), 'every set-level action describes the fade') } { // the 'fader' curve: -90 -> 0 in 500 ms is ten evenly spaced steps in fader position, ending exactly on 0 await go('input_fader_set', { channel: 11, level: -90 }) const mark = sent.length const t0 = Date.now() await go('input_fader_set', { channel: 11, level: 0, duration: 500, curve: 'fader' }) check(inst.fades.size === 1 && cb('fade_running', {}) === true, 'a fade is running right after the action (fade_running is true)') await sleep(80) check(ctx.variables.fades_running === 1, 'fades_running reads 1 while it runs') check(await idle(), 'the fade finishes') const w = writesTo(faderAddr(11), mark) const dbs = w.map((x) => x.db) const pos = dbs.map((v) => faderPosition(v)) const gaps = w.slice(1).map((x, k) => x.at - w[k].at).sort((a, b) => a - b) check(w.length === 10, `500 ms at 20 Hz is ten writes (${w.length})`) check(dbs.every((v, k) => k === 0 || v > dbs[k - 1]) && dbs[0] > -90 && dbs[0] < 0 && dbs.at(-1) === 0, `monotonic, the first write is above -90 and below the target, the last is exactly 0 (${dbs.map((v) => Math.round(v * 10) / 10).join(', ')})`) check(pos.every((p, k) => near(p - (k ? pos[k - 1] : 0), 7.5, 0.01)), `the steps are even in fader position (7.5 each: ${pos.map((p) => Math.round(p * 10) / 10).join(', ')})`) check(w.at(-1).at - t0 >= 495 && w.at(-1).at - t0 <= 700 && gaps[Math.floor(gaps.length / 2)] >= 35 && gaps[Math.floor(gaps.length / 2)] <= 75, `it ends between 500 and 700 ms after the action, one step about every 50 ms (ended after ${w.at(-1).at - t0} ms, median step ${gaps[Math.floor(gaps.length / 2)]} ms)`) check(S.getFader('input', 11) === 0 && inst.fades.size === 0 && cb('fade_running', {}) === false, 'the state ends at 0 and fade_running is false again') await sleep(80) check(ctx.variables.fades_running === 0, 'fades_running reads 0 again') check(await waitFor(() => sent.slice(mark).some((m) => m.address === `${faderAddr(11)}/?`), 2000), 'a confirm query follows the last step like after any write') } { // 'db': linear in dB await go('input_fader_set', { channel: 12, level: -60 }) const mark = sent.length await go('input_fader_set', { channel: 12, level: 0, duration: 500, curve: 'db' }) check(await idle(), 'the linear fade finishes') const dbs = writesTo(faderAddr(12), mark).map((x) => x.db) check(dbs.length === 10 && dbs.every((v, k) => near(v, -60 + 6 * (k + 1), 0.01)) && dbs.at(-1) === 0, `'Linear in dB' moves 6 dB per step (${dbs.map((v) => Math.round(v * 100) / 100).join(', ')})`) // the curve option is optional: a button made before fades existed (no duration, no curve) is an instant set const m2 = sent.length await go('input_fader_set', { channel: 12, level: -10 }) check(writesTo(faderAddr(12), m2).length === 1 && inst.fades.size === 0, 'no duration option at all (an old button) is one instant write') const m3 = sent.length await go('input_fader_set', { channel: 12, level: -12, duration: 0, curve: 'ease' }) await sleep(200) const w3 = writesTo(faderAddr(12), m3) check(w3.length === 1 && w3[0].db === -12 && inst.fades.size === 0, 'duration 0 is exactly one write, whatever the curve') } { // 'ease': slow at both ends await go('input_fader_set', { channel: 13, level: -90 }) const mark = sent.length await go('input_fader_set', { channel: 13, level: 0, duration: 1000, curve: 'ease' }) check(await waitFor(() => inst.fades.size === 0, 4000), 'the eased fade finishes') const dbs = writesTo(faderAddr(13), mark).map((x) => x.db) const pos = dbs.map((v) => faderPosition(v)) const d = pos.map((p, k) => p - (k ? pos[k - 1] : 0)) const top = Math.max(...d) const at = d.indexOf(top) check(dbs.length >= 15 && dbs.every((v, k) => k === 0 || v > dbs[k - 1]) && dbs.at(-1) === 0 && d[0] < top * 0.5 && d.at(-1) < top * 0.25 && at > d.length * 0.3 && at < d.length * 0.7, `'ease' is slow at the start and the end and fastest in the middle (steps in fader position: first ${d[0].toFixed(1)}, fastest ${top.toFixed(1)} at step ${at + 1}, last ${d.at(-1).toFixed(1)}; ${dbs.length} writes)`) } { // another set, a fade, an adjust, fade_stop: each ends a running fade where it is let mark = sent.length await go('input_fader_set', { channel: 14, level: -90 }) await go('input_fader_set', { channel: 14, level: 0, duration: 1000, curve: 'fader' }) await sleep(230) const before = writesTo(faderAddr(14), mark).length await go('input_fader_set', { channel: 14, level: -20 }) await sleep(350) let w = writesTo(faderAddr(14), mark) check(before >= 3 && w.length === before + 1 && w.at(-1).db === -20 && inst.fades.size === 0 && near(S.getFader('input', 14), -20), `a plain set ends the running fade: nothing is written after it but the set itself (${before} fade writes, then -20)`) mark = sent.length await go('input_fader_set', { channel: 14, level: -90 }) await go('input_fader_set', { channel: 14, level: 0, duration: 1000, curve: 'fader' }) await sleep(230) const lastOfA = writesTo(faderAddr(14), mark).at(-1).db const markB = sent.length await go('input_fader_set', { channel: 14, level: -70, duration: 300, curve: 'db' }) check(inst.fades.size === 1, 'a second fade on the same fader replaces the first (one fade per address)') check(await idle(), 'the second fade finishes') w = writesTo(faderAddr(14), markB) check(w.length === 6 && w.at(-1).db === -70 && w.every((x, k) => k === 0 || x.db < w[k - 1].db) && w[0].db < lastOfA && w[0].db > -70, `the second fade takes over from where the first one was (${Math.round(lastOfA)} dB, rising) and runs down to -70 without a write from the first (${w.map((x) => Math.round(x.db)).join(', ')})`) mark = sent.length await go('input_fader_set', { channel: 14, level: -90 }) await go('input_fader_set', { channel: 14, level: 0, duration: 1000, curve: 'fader' }) await sleep(230) const markC = sent.length await go('input_fader_adjust', { channel: 14, delta: 1 }) await sleep(350) w = writesTo(faderAddr(14), markC) check(w.length === 1 && inst.fades.size === 0, `an adjust ends the running fade and writes once (${w.length} write)`) // fade_stop: both fades stop, levels stay where they are await go('input_fader_set', { channel: 15, level: -90 }) await go('send_level_set', { channel: 15, buss: 1, level: -90 }) await go('input_fader_set', { channel: 15, level: 0, duration: 2000, curve: 'fader' }) await go('send_level_set', { channel: 15, buss: 1, level: 0, duration: 2000, curve: 'fader' }) await sleep(250) check(inst.fades.size === 2 && ctx.variables.fades_running === 2, 'two fades on different levels run at the same time (fades_running 2)') await go('fade_stop', {}) const markD = sent.length const atStop = S.getFader('input', 15) await sleep(400) check(inst.fades.size === 0 && !sent.slice(markD).some((m) => m.address === faderAddr(15) || m.address === '/Input_Channels/15/Aux_Send/1/send_level') && S.getFader('input', 15) === atStop && atStop > -90 && atStop < 0 && ctx.variables.fades_running === 0 && cb('fade_running', {}) === false, `fade_stop ends every fade, nothing more is written, the levels stay where they were (${Math.round(atStop)} dB) and fades_running reads 0`) await go('fade_stop', {}) check(inst.fades.size === 0, 'fade_stop with nothing running does nothing') } { // aux send and the other fader kinds go through the same engine await go('send_level_set', { channel: 16, buss: 2, level: -30 }) let mark = sent.length await go('send_level_set', { channel: 16, buss: 2, level: -10, duration: 200, curve: 'db' }) check(await idle(), 'an aux send level fades') const sw = writesTo('/Input_Channels/16/Aux_Send/2/send_level', mark).map((x) => x.db) check(sw.length === 4 && sw.at(-1) === -10 && sw.every((v, k) => near(v, -30 + 5 * (k + 1), 0.01)), `aux send fade: four steps of 5 dB (${sw.join(', ')})`) await go('buss_fader_set', { buss: 4, level: -30 }) await go('matrix_fader_set', { matrix: 2, level: -30 }) await go('cg_fader_set', { cg: 2, level: -30 }) mark = sent.length await go('buss_fader_set', { buss: 4, level: -20, duration: 100, curve: 'db' }) await go('matrix_fader_set', { matrix: 2, level: -20, duration: 100, curve: 'db' }) await go('cg_fader_set', { cg: 2, level: -20, duration: 100, curve: 'db' }) check(inst.fades.size === 3, 'buss, matrix output and control group faders fade too (three at once)') check(await idle(), 'they finish') const sides = [['buss', 4], ['matrix', 2], ['cg', 2]].map(([ent, n]) => [S.getFader(ent, n)]) check(sides.every(([v]) => v === -20), 'and every one ends exactly at the target') await sleep(100) } { // a matrix send never goes below -90 await go('matrix_send_level_set', { matrixIn: 4, matrixOut: 4, level: -30 }) const addrM = '/Matrix_Inputs/4/Matrix_Send/4/send_level' let mark = sent.length await go('matrix_send_level_set', { matrixIn: 4, matrixOut: 4, level: -90, duration: 300, curve: 'db' }) check(await idle(), 'a matrix send fades down') let m = writesTo(addrM, mark).map((x) => x.db) check(m.length === 6 && m.at(-1) === -90 && m.every((v) => v >= -90) && m.every((v, k) => k === 0 || v < m[k - 1]), `down to -90: ${m.map((v) => Math.round(v * 10) / 10).join(', ')} (never below -90)`) mark = sent.length await go('matrix_send_level_set', { matrixIn: 4, matrixOut: 4, level: 0, duration: 500, curve: 'fader' }) check(await idle(), 'and up from -90 again') m = writesTo(addrM, mark).map((x) => x.db) check(m.length === 10 && m[0] > -90 && m.at(-1) === 0 && m.every((v) => v >= -90 && v <= 10), `from the -90 floor on the fader taper (${m.map((v) => Math.round(v)).join(', ')})`) // a fade to a target below the floor stops at -90 for a matrix send, at -150 (-inf) for a fader mark = sent.length await inst.setMatrixSendLevel(4, 4, -150, 200, 'db') check(await idle(), 'a matrix send fade to a too low target finishes') m = writesTo(addrM, mark).map((x) => x.db) check(m.length > 0 && m.every((v) => v >= -90) && m.at(-1) === -90, `a matrix send fade clamps its target to -90 (${m.map((v) => Math.round(v)).join(', ')})`) await go('input_fader_set', { channel: 17, level: -60 }) mark = sent.length await go('input_fader_set', { channel: 17, level: -150, duration: 300, curve: 'fader' }) check(await idle(), 'a fader fades out to -inf') const f = writesTo(faderAddr(17), mark).map((x) => x.db) check(f.length >= 3 && f.at(-1) === -150 && f.slice(0, -1).every((v) => v >= -90 && v < -60) && f.every((v, k) => k === 0 || v <= f[k - 1]), `a fader fade to -inf ends exactly on -150 (${f.map((v) => Math.round(v)).join(', ')})`) } { // a start level the console never gives: the target is written at once, with one warning const saved = inst.timing.replyWaitMs inst.timing.replyWaitMs = 250 const warns = logCount(/set without a fade/) const mark = sent.length await inst.fadeLevel(faderAddr(18), 'test:unknown-start', () => undefined, -5, 400, 'fader') inst.timing.replyWaitMs = saved const w = writesTo(faderAddr(18), mark) check(logCount(/set without a fade/) === warns + 1 && w.length === 1 && w[0].db === -5 && inst.fades.size === 0, 'an unknown starting level writes the target at once and logs one warning') // a fade waiting for its starting level is replaced by a later set inst.timing.replyWaitMs = 250 const m2 = sent.length const pending = inst.fadeLevel(faderAddr(18), 'test:unknown-start2', () => undefined, -5, 400, 'fader') await sleep(40) await go('input_fader_set', { channel: 18, level: -33 }) await pending inst.timing.replyWaitMs = saved const w2 = writesTo(faderAddr(18), m2) check(w2.length === 1 && w2[0].db === -33 && inst.fades.size === 0, 'a set while a fade still waits for its starting level wins; the waiting fade does nothing') } check(inst.fades.size === 0 && inst.fadeStarts.size === 0, 'no fade or fade start is left over after the fade tests') // --------------------------------------------------------------------------- // 16c. Group and aux master actions, feedbacks and presets // --------------------------------------------------------------------------- { const GROUPS = [9, 10, 11, 12, 13, 18, 23, 24, 25] const allIds = S.choices.busses().map((c) => c.id) const AUXES = allIds.filter((id) => !GROUPS.includes(id)) const ids = (opts, id) => opts.find((o) => o.id === id).choices.map((c) => c.id) const labels = (opts, id) => opts.find((o) => o.id === id).choices.map((c) => c.label) check(S.choices.groupChoices().map((c) => c.id).join() === GROUPS.join() && AUXES.length === 16, `the mock has ${GROUPS.length} group busses and ${AUXES.length} aux busses`) let ok = true for (const [prefix, want] of [['group', GROUPS], ['aux', AUXES]]) { for (const a of ['fader_set', 'fader_adjust']) ok = ok && ids(ctx.actions[`${prefix}_${a}`].options, 'buss').join() === want.join() for (const a of ['mute', 'solo']) ok = ok && ids(ctx.actions[`${prefix}_${a}`].options, 'busses').join() === want.join() && ctx.actions[`${prefix}_${a}`].options[0].type === 'multidropdown' for (const f of ['muted', 'solo', 'fader_value', 'fader_pos', 'meter_value', 'meter_pos', 'signal', 'peak']) ok = ok && ids(ctx.feedbacks[`${prefix}_${f}`].options, 'buss').join() === want.join() } check(ok, 'every group_* dropdown (actions and feedbacks) lists exactly the nine group busses, every aux_* one exactly the sixteen aux busses') const gl = labels(ctx.actions.group_fader_set.options, 'buss') const al = labels(ctx.actions.aux_fader_set.options, 'buss') check(gl.includes('24 · Studio') && al.includes('4 · CLK-GD-MD') && !gl.concat(al, labels(ctx.actions.group_mute.options, 'busses'), labels(ctx.actions.aux_mute.options, 'busses')).some((l) => /\((aux|group)\)/.test(l)), 'their labels carry no "(group)" / "(aux)" suffix') check(ids(ctx.actions.buss_fader_set.options, 'buss').length === 25 && labels(ctx.actions.buss_fader_set.options, 'buss').includes('24 · Studio (group)') && ids(ctx.actions.buss_mute.options, 'busses').length === 25, 'the buss_* dropdowns still list all 25 busses with the suffix') check(ctx.actions.group_fader_set.options.map((o) => o.id).join() === 'buss,level,duration,curve' && ctx.actions.aux_fader_set.options.map((o) => o.id).join() === 'buss,level,duration,curve' && ctx.actions.group_fader_adjust.options.map((o) => o.id).join() === 'buss,delta' && ctx.actions.aux_mute.options.map((o) => o.id).join() === 'busses,state', 'option ids are the same as the buss_* ones (fade options on the set actions)') check(ctx.actions.group_mute.name === 'Group: mute' && ctx.actions.group_solo.name === 'Group: solo' && ctx.actions.group_fader_set.name === 'Group: set fader' && ctx.actions.group_fader_adjust.name === 'Group: adjust fader (relative)' && ctx.actions.aux_mute.name === 'Aux master: mute' && ctx.actions.aux_solo.name === 'Aux master: solo' && ctx.actions.aux_fader_set.name === 'Aux master: set fader' && ctx.actions.aux_fader_adjust.name === 'Aux master: adjust fader (relative)' && ctx.actions.group_assign.name === 'Group: assign input on/off', 'action names: "Group: ..." and "Aux master: ..." (group_assign keeps its name)') check(/fade time above 0/.test(ctx.actions.group_fader_set.description) && /fade time above 0/.test(ctx.actions.aux_fader_set.description), 'the two set actions describe the fade') // the old ids stay, with names that sort apart const oldActions = ['buss_mute', 'buss_solo', 'buss_fader_set', 'buss_fader_adjust'] const oldFeedbacks = ['buss_muted', 'buss_solo', 'buss_fader_value', 'buss_fader_pos', 'buss_fader_bar', 'buss_signal', 'buss_peak', 'buss_meter_value', 'buss_meter_pos'] check(oldActions.every((id) => ctx.actions[id]?.name.startsWith('Any buss (aux or group): ')) && oldFeedbacks.every((id) => ctx.feedbacks[id]?.name.startsWith('Any buss (aux or group)')), `the old buss_* ids still exist and their names start with "Any buss (aux or group)" (${oldActions.length} actions, ${oldFeedbacks.length} feedbacks)`) check(ctx.feedbacks.group_muted.name === 'Group: muted' && ctx.feedbacks.aux_fader_pos.name === 'Aux master: fader position (0-100, for gauges)' && ctx.feedbacks.group_peak.name.startsWith('Group meter: ') && ctx.feedbacks.aux_signal.name.startsWith('Aux master meter: ') && !('group_fader_bar' in ctx.feedbacks) && !('aux_fader_bar' in ctx.feedbacks), 'feedback names, and no legacy image-bar variants for group / aux masters') // writes: the group side for a group buss, the aux side for an aux let r = await run('group_fader_set', { buss: 24, level: -7 }) expectWrite(r, '/Group_Outputs/24/fader', ',f', -7, 'group_fader_set') r = await run('aux_fader_set', { buss: 1, level: -9 }) expectWrite(r, '/Aux_Outputs/1/fader', ',f', -9, 'aux_fader_set') r = await run('group_mute', { busses: [9, 24], state: 'on' }) expectWrite(r, '/Group_Outputs/9/mute', ',f', 1, 'group_mute (first)') expectWrite(r, '/Group_Outputs/24/mute', ',f', 1, 'group_mute (second)') r = await run('aux_mute', { busses: [3, 4], state: 'on' }) expectWrite(r, '/Aux_Outputs/3/mute', ',f', 1, 'aux_mute (first)') expectWrite(r, '/Aux_Outputs/4/mute', ',f', 1, 'aux_mute (second)') r = await run('group_solo', { busses: [24], state: 'on' }) expectWrite(r, '/Group_Outputs/24/solo', ',f', 1, 'group_solo') r = await run('aux_solo', { busses: [3], state: 'off' }) expectWrite(r, '/Aux_Outputs/3/solo', ',f', 0, 'aux_solo off') r = await run('group_mute', { busses: [9], state: 'toggle' }) expectWrite(r, '/Group_Outputs/9/mute', ',f', 0, 'group_mute toggle (from muted)') await run('group_fader_set', { buss: 23, level: -20 }) r = await run('group_fader_adjust', { buss: 23, delta: 3 }) expectWrite(r, '/Group_Outputs/23/fader', ',f', -17, 'group_fader_adjust') await run('aux_fader_set', { buss: 2, level: -20 }) r = await run('aux_fader_adjust', { buss: 2, delta: -2.5 }) expectWrite(r, '/Aux_Outputs/2/fader', ',f', -22.5, 'aux_fader_adjust') // feedbacks read the same state as the buss_* ones check(cb('group_muted', { buss: 24 }) === true && cb('buss_muted', { buss: 24 }) === true && cb('aux_muted', { buss: 3 }) === true && cb('aux_muted', { buss: 1 }) === false && cb('group_solo', { buss: 24 }) === true && cb('aux_solo', { buss: 3 }) === false && cb('group_fader_value', { buss: 24 }) === '-7' && cb('aux_fader_value', { buss: 1 }) === '-9' && near(cb('group_fader_pos', { buss: 24 }), cb('buss_fader_pos', { buss: 24 })) && near(cb('aux_fader_pos', { buss: 2 }), faderPosition(-22.5)), 'group_* / aux_* feedbacks follow the buss state (muted, solo, fader text and position)') // fades through the same engine const mark = sent.length await go('group_fader_set', { buss: 25, level: -40 }) const m2 = sent.length await go('group_fader_set', { buss: 25, level: -10, duration: 300, curve: 'db' }) check(inst.fades.size === 1, 'a group fader fade is running') check(await idle(), 'the group fade finishes') const gw = writesTo('/Group_Outputs/25/fader', m2).map((x) => x.db) check(gw.length === 6 && gw.at(-1) === -10 && gw.every((v, k) => near(v, -40 + 5 * (k + 1), 0.01)), `group_fader_set fades on /Group_Outputs/25/fader (${gw.map((v) => Math.round(v * 100) / 100).join(', ')})`) await go('aux_fader_set', { buss: 5, level: -40 }) const m3 = sent.length await go('aux_fader_set', { buss: 5, level: -10, duration: 200, curve: 'db' }) check(await idle(), 'the aux master fade finishes') const aw = writesTo('/Aux_Outputs/5/fader', m3).map((x) => x.db) check(aw.length === 4 && aw.at(-1) === -10 && !sent.slice(m3).some((m) => m.address.startsWith('/Group_Outputs/5/')), `aux_fader_set fades on /Aux_Outputs/5/fader (${aw.map((v) => Math.round(v * 100) / 100).join(', ')})`) void mark // presets: Groups and Aux masters sections, group / aux meter sections, no "Busses" section const P2 = ctx.presets const secs = ctx.presetStructure const sec = (id) => secs.find((x) => x.id === id) const idsOf = (section) => section.definitions.flatMap((d) => d.presets) const namedGroups = S.namedBusses('group') const namedAux = S.namedBusses('aux') check(namedGroups.join() === GROUPS.join() && namedAux.length === 15 && sec('groups')?.name === 'Groups' && sec('aux_masters')?.name === 'Aux masters' && !sec('busses') && !sec('buss_meters'), `sections "Groups" (${namedGroups.length} named group busses) and "Aux masters" (${namedAux.length} named aux busses) replace "Busses"`) check(idsOf(sec('groups')).length === 2 * namedGroups.length && idsOf(sec('aux_masters')).length === 2 * namedAux.length && sec('groups').definitions.map((d) => d.name).join() === 'Mute,Fader (knob)' && sec('aux_masters').definitions.map((d) => d.name).join() === 'Mute,Fader (knob)', 'each has a Mute and a Fader (knob) preset per named buss of that type') const gMute = P2.group_mute_24 const gKnob = P2.group_knob_24 const aMute = P2.aux_mute_4 const aKnob = P2.aux_knob_4 check(gMute.name === 'Group mute: 24 · Studio' && gMute.steps[0].down[0].actionId === 'group_mute' && JSON.stringify(gMute.steps[0].down[0].options) === JSON.stringify({ busses: [24], state: 'toggle' }) && gMute.feedbacks[0].feedbackId === 'group_muted' && gMute.style.text === '$(S31:buss_24_name)', 'group mute preset uses group_mute / group_muted') check(gKnob.type === 'layered' && gKnob.name === 'Group fader (knob): 24 · Studio' && gKnob.steps[0].rotate_right[0].actionId === 'group_fader_adjust' && gKnob.steps[0].down[0].actionId === 'group_mute' && gKnob.feedbacks[0].feedbackId === 'group_muted' && gKnob.localVariables.map((v) => `${v.variableName}=${v.feedbackId}`).join() === 'level=group_fader_value,pos=group_fader_pos', 'group fader knob preset uses group_fader_adjust / group_fader_value / group_fader_pos / group_muted') check(aMute.name === 'Aux master mute: 4 · CLK-GD-MD' && aMute.steps[0].down[0].actionId === 'aux_mute' && aMute.feedbacks[0].feedbackId === 'aux_muted' && aKnob.steps[0].rotate_left[0].actionId === 'aux_fader_adjust' && aKnob.localVariables.map((v) => v.feedbackId).join() === 'aux_fader_value,aux_fader_pos' && aKnob.feedbacks[0].feedbackId === 'aux_muted', 'aux master mute and knob presets use aux_*') check(!Object.keys(P2).some((id) => /^buss_(mute|knob|meter)_/.test(id)) && !JSON.stringify(P2).includes('"buss_') , 'no preset refers to a buss_* action or feedback any more') const gMeters = sec('meters_groups') const aMeters = sec('meters_aux') check(gMeters?.name === 'Group meters' && aMeters?.name === 'Aux meters' && idsOf(gMeters).length === 9 && idsOf(aMeters).length === 15 && P2.group_meter_24.localVariables.some((v) => v.feedbackId === 'group_meter_pos') && P2.group_meter_24.feedbacks.map((f) => f.feedbackId).join() === 'group_signal,group_peak' && P2.aux_meter_4.feedbacks.map((f) => f.feedbackId).join() === 'aux_signal,aux_peak' && !sec('buss_meters'), 'group meter and aux meter sections (9 and 15 meters) use group_* / aux_* meter feedbacks') check(idsOf(gMeters).concat(idsOf(aMeters)).every((id) => P2[id].type === 'layered'), 'the group and aux meter presets are layered gauges') } // --------------------------------------------------------------------------- // 17. A slow console (every answer and echo 150 ms late): bind failures, encoder clicks, lazy loads // --------------------------------------------------------------------------- const mockB = startMock(P.mockB, P.linkB, ['--channels', '12', '--delay', '150']) await sleep(400) if (mockB.exitCode !== null) { console.log(`FAIL the second mock console could not start on UDP ${P.mockB}: ${mockB.output.trim()}`) process.exit(1) } const cfgB = { host: '127.0.0.1', consolePort: P.mockB, localPort: P.linkB, relayEnable: true, relayPort: P.relayB, relayClientPort: P.ipadB, relayTimeout: 60, debugLog: false } const evOf = (actionId, options) => ({ id: 'a', controlId: 'c', actionId, options, surfaceId: undefined }) const fastTiming = { txPaceMs: 3, settleMs: 150, rebuildDebounceMs: 150, replyWaitMs: 400, flushMs: 15, resubscribeMs: 200, bindRetryMs: 300, relazyMs: 300 } const occupy = async (port) => { const blocker = dgram.createSocket('udp4') await new Promise((resolve, reject) => { blocker.once('error', reject) blocker.bind(port, resolve) }) return blocker } { // The console-link port is taken: connection_failure, retried, and a config change ends the retrying const blocker = await occupy(P.linkB) const bctx = makeContext() const b = new Instance(bctx) Object.assign(b.timing, fastTiming) await b.init(cfgB) const st = () => bctx.statuses.at(-1) check(st()?.[0] === 'connection_failure' && /Cannot bind local port \d+: EADDRINUSE/.test(st()[1]) && b.link === null, `an occupied local port is shown as connection_failure (${st()?.[1]})`) await sleep(800) check(b.link === null && st()[0] === 'connection_failure', 'it keeps retrying while the port stays occupied') await b.configUpdated({ ...cfgB, localPort: P.linkB + 1 }) check(b.link?.localPort === P.linkB + 1 && b.linkRetryTimer === null && !!b.relay, 'a config change ends the retrying and binds the new port') await b.configUpdated(cfgB) check(st()[0] === 'connection_failure' && b.linkRetryTimer !== null, 'changing back to the occupied port fails again and retries') const loadsB = logCount(/load complete/) blocker.close() check(await waitFor(() => b.link && b.link.localPort === P.linkB, 3000), 'once the port is free the retry binds the console link') check(await waitFor(() => logCount(/load complete/) === loadsB + 1, 20000) && st()[0] === 'ok', 'and the connection loads and reports ok') // Encoder clicks against a console that answers 150 ms late const SB = b.state const fader = () => SB.getFader('input', 1) const clicks = async (intervalMs) => { await bctx.actions.input_fader_set.callback(evOf('input_fader_set', { channel: 1, level: -10 }), {}) await sleep(b.timing.writeGuardMs + 700) const mark = sent.length for (let k = 0; k < 10; k++) { await bctx.actions.input_fader_adjust.callback(evOf('input_fader_adjust', { channel: 1, delta: 1 }), {}) await sleep(intervalMs) } const atEnd = fader() const written = sent.slice(mark).filter((m) => m.address === '/Input_Channels/1/fader').map((m) => m.args[0]) await sleep(b.timing.writeGuardMs + 700) return { atEnd, settled: fader(), written } } b.timing.writeGuardMs = 0 // control: without the guard the same clicks are lost to stale echoes const control = await clicks(40) b.timing.writeGuardMs = 400 check(control.settled < -0.5, `control: with no write guard, 10 clicks at 40 ms against a slow console lose clicks (ends at ${control.settled} dB, not 0)`) for (const interval of [40, 100]) { const r = await clicks(interval) check(near(r.atEnd, 0) && near(r.settled, 0) && r.written.length === 10 && r.written.every((v, k) => near(v, -9 + k)), `10 clicks of +1 dB at ${interval} ms from -10 end at 0 dB and stay there (written ${r.written.map((v) => Math.round(v)).join(',')}; end ${r.atEnd}, settled ${r.settled})`) } const mark = sent.length await bctx.actions.input_fader_set.callback(evOf('input_fader_set', { channel: 2, level: -20 }), {}) await sleep(b.timing.writeGuardMs + 700) check(sent.slice(mark).filter((m) => m.address === '/Input_Channels/2/fader/?').length === 1 && near(SB.getFader('input', 2), -20), 'after the guard one confirm query asks for the value and the state agrees') // the stale-value guard lets an equal value through and a later change from the surface is accepted b.onConsoleMessage(decode(encode('/Input_Channels/2/fader', [-15])), { address: '127.0.0.1', port: P.mockB }) check(near(SB.getFader('input', 2), -15), 'once the guard has ended a value pushed from the surface is applied') // Lazy loads: a toggle on a value the console never answers does nothing; the same key may be asked again later const m2 = sent.length const warns = logCount(/toggle not applied/) const t0 = Date.now() await bctx.actions.input_phantom.callback(evOf('input_phantom', { channel: 40, state: 'toggle' }), {}) const took = Date.now() - t0 const sent2 = sent.slice(m2) check(sent2.some((m) => m.address === '/Input_Channels/40/Channel_Input/phantom/?') && !sent2.some((m) => m.address === '/Input_Channels/40/Channel_Input/phantom') && logCount(/toggle not applied/) === warns + 1 && took >= 350 && took < 1500, `a toggle on a state the console never answers queries, waits (${took} ms), warns and writes nothing (no default to on)`) const probe = '/Input_Channels/40/EQ/eq_in/?' const asks = () => sent.filter((m) => m.address === probe).length const a0 = asks() b.ensure('t:eq', ['/Input_Channels/40/EQ/eq_in']) b.ensure('t:eq', ['/Input_Channels/40/EQ/eq_in']) await sleep(60) check(asks() === a0 + 1, 'a lazy load is requested once, not on every evaluation') await sleep(b.timing.replyWaitMs + 150) b.ensure('t:eq', ['/Input_Channels/40/EQ/eq_in']) await sleep(60) check(asks() === a0 + 2, 'an unanswered lazy load may be requested again after the wait') b.ensure('t:eq', ['/Input_Channels/40/EQ/eq_in']) await sleep(60) check(asks() === a0 + 2, 'but not more than once per wait') await b.destroy() check(b.link === null && b.relay === null, 'destroy closes the link and the relay') } { // The relay port is taken while the console link is fine: warning, retry, back to ok const blocker = await occupy(P.relayB) const rctx = makeContext() const r = new Instance(rctx) Object.assign(r.timing, fastTiming) await r.init(cfgB) const st = () => rctx.statuses.at(-1) check(await waitFor(() => st()?.[0] === 'unknown_warning', 8000) && st()[1] === `iPad relay: port ${P.relayB} in use` && !!r.link && r.relay === null, `an occupied relay port shows a warning while the console link is fine ("${st()?.[1]}")`) check(r.connectedFlag === true && r.relayRetryTimer !== null, 'the console side keeps working and the relay bind is retried') await sleep(800) check(st()[0] === 'unknown_warning', 'the warning stays while the port is occupied') blocker.close() check(await waitFor(() => !!r.relay && st()?.[0] === 'ok', 5000), 'when the port is free the retry opens the relay and the status goes back to ok') const ipad = dgram.createSocket('udp4') ipad.send(encode('/Console/Name/?', []), P.relayB, '127.0.0.1') check(await waitFor(() => r.relay.clientCount === 1, 2000), 'the relay then accepts iPad traffic') ipad.close() // a config change while the relay is blocked cancels the retrying await r.configUpdated({ ...cfgB, relayEnable: false }) check(r.relay === null && r.relayRetryTimer === null && st()[0] === 'ok', 'disabling the relay clears the problem') await r.destroy() } { // Fades end with the instance: a config change that reopens the console link, and destroy const fctx = makeContext() const f = new Instance(fctx) Object.assign(f.timing, fastTiming) const loads0 = logCount(/load complete/) await f.init(cfgB) check(await waitFor(() => logCount(/load complete/) > loads0 && f.state.getFader('input', 3) !== undefined, 20000), 'an instance on the slow console has loaded') const fAddr = '/Input_Channels/3/fader' const fadeWrites = (mark) => sent.slice(mark).filter((m) => m.address === fAddr).length const act = (actionId, options) => fctx.actions[actionId].callback(evOf(actionId, options), {}) await act('input_fader_set', { channel: 3, level: -90 }) await act('input_fader_set', { channel: 3, level: 0, duration: 4000, curve: 'fader' }) await sleep(250) check(f.fades.size === 1 && fctx.variables.fades_running === 1 && f.timers.size > 0, 'a fade is running on the third instance') const m1 = sent.length await f.configUpdated({ ...cfgB, localPort: P.linkB + 2 }) check(f.fades.size === 0 && f.fadeStarts.size === 0 && f.link?.localPort === P.linkB + 2, 'a config change that reopens the console link ends the fade') await sleep(500) check(fadeWrites(m1) === 0 && fctx.variables.fades_running === 0, 'nothing more is written to the fader and fades_running reads 0') await f.configUpdated(cfgB) check(await waitFor(() => logCount(/load complete/) > loads0 + 1 && f.state.getFader('input', 3) !== undefined, 20000), 'the instance reconnects and loads again') await act('input_fader_set', { channel: 3, level: 0, duration: 4000, curve: 'ease' }) await sleep(250) check(f.fades.size === 1, 'a new fade runs after the reconnect') const m2 = sent.length await f.destroy() check(f.fades.size === 0 && f.timers.size === 0, 'destroy ends the fade and leaves no timer behind') await sleep(500) check(fadeWrites(m2) === 0, 'nothing is written after destroy') // a fade that was still waiting for its starting level when the instance went away does not start afterwards const g = new Instance(makeContext()) Object.assign(g.timing, fastTiming) await g.init(cfgB) check(await waitFor(() => !!g.link, 3000), 'a fourth instance binds the console link') const waiting = g.fadeLevel(fAddr, 'test:never', () => undefined, 0, 1000, 'fader') await sleep(50) check(g.fadeStarts.size === 1, 'a fade waits for its starting level') await g.destroy() await waiting check(g.fades.size === 0 && g.fadeStarts.size === 0, 'destroy while a fade waits for its starting level: it never starts') } mockB.kill('SIGTERM') // --------------------------------------------------------------------------- // 10. Every address sent during the whole run was one the console answers // --------------------------------------------------------------------------- { for (const m of sent) globalThis.__catalogCheck(m.address) const off = [...globalThis.__offenders] check(off.length === 0, `every address sent during the whole run is answered by the real console, a documented trigger node or a meter subscription${off.length ? `: ${off.join(', ')}` : ''}`) } // --------------------------------------------------------------------------- // 11. A sleeping iPad is reported once as a warning, not as an error on every fan-out // --------------------------------------------------------------------------- { const before = logs.length const asleep = (code) => Object.assign(new Error(`send ${code} 192.0.2.50:9100`), { code, dest: '192.0.2.50:9100' }) inst.reportError('Relay socket', asleep('EHOSTDOWN')) inst.reportError('Relay socket', asleep('EHOSTUNREACH')) inst.reportError('Relay socket', asleep('EHOSTDOWN')) const fresh = logs.slice(before).filter((l) => /192\.0\.2\.50/.test(l.message)) check(fresh.length === 1 && fresh[0].level === 'warn' && /not reachable/.test(fresh[0].message), `a sleeping iPad logs one warning (${fresh.map((l) => l.level).join(',') || 'none'})`) const other = Object.assign(new Error('send EPERM 192.0.2.51:9100'), { code: 'EPERM', dest: '192.0.2.51:9100' }) inst.reportError('Relay socket', other) check(logs.slice(before).some((l) => l.level === 'error' && /EPERM/.test(l.message)), 'other relay send errors are still logged as errors') } // --------------------------------------------------------------------------- // Shutdown // --------------------------------------------------------------------------- await inst.destroy() mock.kill('SIGTERM') await sleep(200) if (failures) { console.log(`\n${failures} check(s) failed. Mock output:\n${mock.output.slice(-1500)}\nSecond mock output:\n${mockB.output.slice(-800)}\nRecent module log:\n${logs.slice(-30).map((l) => `[${l.level}] ${l.message}`).join('\n')}`) process.exit(1) } console.log('\nAll checks passed.') process.exit(0)