Files
companion-module-digico-s31/test/selftest.js
T
michaelandClaude Opus 5.5 205f91e7d2 Timed fades, mute-style matrix sends, meters on matrix send knobs
- Every set-level action (input, bus, matrix and control group faders,
  aux and matrix sends) has a fade time and a curve: fader travel
  (natural), fader travel with ease in/out, or linear in dB. Fades step
  at 20 Hz, land exactly on the target, never overshoot, and are
  cancelled by any new move on the same fader; new "Stop all fades"
  action, fades_running variable and fade_running feedback. Verified on
  the S31 with a switched-off matrix send: 20 writes in 1 s, ending
  exactly on target, then restored.
- Matrix sends now look like mutes in the presets: red when the send is
  off, black when it is on (new matrix_send_off feedback, true only when
  the send is known to be off).
- Matrix send knobs carry a meter column on the right. The S31 has no
  meter for matrix inputs, so it shows the destination output's meter.
  Meter gauges use a faint track so an empty meter reads as empty.
- Test fixes: the meter-resubscribe check no longer depends on a silent
  channel changing value, plus back-to-back /Meters/clear coverage.

npm test: 504 checks pass on Node 25 and on Companion's Node 22.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:20:07 -04:00

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