Files
companion-module-digico-s31/test/selftest.js
T
michaelandClaude Opus 5.5 57645be1e9 Write on/off switches as float 0/1, as the S31 requires
Verified on a live S31 against an unused, silent input (restored after):
a float fader write is accepted and echoed, but a mute written as int 0/1
or as an OSC True/False is silently ignored, while a float 1.0/0.0 works.
DiGiCo's 2014 SD command list says int, which is wrong for the S31.

Every switch (mute, solo, send on, group assign, EQ/dynamics/filter/
insert/delay switches, phantom, phase, pad, GEQ in) now goes out as a
float. The mock console now ignores int and T/F switch writes the same
way the console does, so a regression to int fails 38 checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:32:17 -04:00

1453 lines
98 KiB
JavaScript

/**
* 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 { LEVEL, PARAMS, adjustedLevel, fmtCue, fmtDb, fmtPan, indexProblem, meterId, meterIdForInput, parse } 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'
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 })
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')
}
{
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')
}
// ---------------------------------------------------------------------------
// 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')
check(S.choices.matrixInputs()[0].label === '1 · Matrix In 1', `matrix inputs (no name address) are labelled "Matrix In N" (${S.choices.matrixInputs()[0].label})`)
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.choices.matrixInputs().length === 10, 'matrix outputs named; matrix inputs (no name address) listed from the count')
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
]
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.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 === 6 && levels.every((o) => o.type === 'number' && o.showMinAsNegativeInfinity === true && o.min === -150) && thresholds.every((o) => o.showMinAsNegativeInfinity === true), `level options use showMinAsNegativeInfinity (${levels.length} level + ${thresholds.length} threshold options)`)
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_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']
// 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', '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 [param, p] of Object.entries(PARAMS)) if (p.type === 'switch') for (const ent of Object.keys(p.paths)) fbExpected.push(`${ent}_${param}_state`)
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)`)
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') 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 (def.type === 'boolean' && !f.style) problems.push(`${id}: boolean feedback ${f.feedbackId} without style`)
if (def.type === 'advanced' && f.style) problems.push(`${id}: advanced feedback ${f.feedbackId} with style`)
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`)
}
for (const m of p.style.text.matchAll(/\$\(local:(\w+)\)/g)) if (!locals.has(m[1])) problems.push(`${id}: text uses undeclared local ${m[1]}`)
for (const m of p.style.text.matchAll(/\$\(S31:(\w+)\)/g)) if (!(m[1] in ctx.variableDefs)) problems.push(`${id}: text 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', 'busses', 'cgs', 'matrix', 'snapshots', 'console', 'meters'].every((s) => sections.includes(s)) && sections.some((s) => s.startsWith('sends_')) && sections.some((s) => s.startsWith('group_')), `preset sections cover inputs, busses, sends, groups, matrix, control groups, snapshots, meters, console (${sections.length} sections)`)
const bytes = JSON.stringify(P).length
console.log(`info presets: ${ids.length} presets, ${bytes} bytes serialized, ${inst.presetStats?.droppedSendKnobs ? 'send knobs dropped' : 'nothing dropped'}`)
check(ids.length <= MAX_PRESETS && bytes <= MAX_PRESET_BYTES, `preset count ${ids.length} <= ${MAX_PRESETS} and size ${bytes} <= ${MAX_PRESET_BYTES} bytes`)
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_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')
// 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('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')
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],
]) {
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
// ---------------------------------------------------------------------------
{
const id7 = meterIdForInput(7)
check(id7 >= 5000 && meterIdForInput(60) === 5060, `meter ids start at 5000 (input 7 -> ${id7})`)
const mark = sent.length
cb('input_meter_value', { channel: 7, unit: 'raw' })
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, unit: 'raw' })
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')
{
const m2 = sent.length
cb('input_meter_value', { channel: 7, meter: 'gate', unit: 'raw' })
cb('input_peak', { channel: 7, meter: 'comp', hold: 1 })
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 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) => inst.onConsoleMessage(decode(encode('/Meters/values', [{ i: id7 }, { i: value }])), { address: '127.0.0.1', port: P.mock })
inject(127)
check(cb('input_meter_value', { channel: 7, unit: 'raw' }) === 127 && cb('input_meter_value', { channel: 7, unit: 'percent' }) === 100, 'input_meter_value raw and percent')
check(cb('input_signal', { channel: 7, threshold: 5 }) === true && cb('input_peak', { channel: 7, hold: 2 }) === true, 'input_signal and input_peak at full scale')
inject(3)
check(cb('input_signal', { channel: 7, threshold: 5 }) === false && cb('input_peak', { channel: 7, hold: 2 }) === true, 'signal drops below the threshold while the peak is still held')
await sleep(30)
check(cb('input_peak', { channel: 7, hold: 0 }) === false, 'the peak hold expires')
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 returns a right-edge strip of the right size')
// 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', unit: 'raw' })}`
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)`)
check(meterChecks.every(([, ids]) => ids.every((x) => watching7.has(x))) && !meterChecks.some(([, ids]) => ids.includes(gateInstance)), 'a meter update re-checks only the instances watching the changed meter id')
check(ctx.checks.length === 0, 'meter updates never fall back to per-type checks')
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
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)})`)
check(await waitFor(() => (S.getMeter(meterIdForInput(7))?.at ?? 0) > t0 + 250, 3000), 'meter values flow again after the resubscribe')
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})`)
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)', 'and back again')
// 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')
}
// ---------------------------------------------------------------------------
// 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, {})
// 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, {})
// 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')
}
// ---------------------------------------------------------------------------
// 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()
}
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(', ')}` : ''}`)
}
// ---------------------------------------------------------------------------
// 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)