Files
companion-module-digico-s31/test/selftest.js
T
michaelandClaude Opus 5.5 13092672f7 Fade at the iPad app's rate; relay rate report in debug mode
Measured on the booth relay with debug logging on: while dragging a
fader, the DiGiCo iPad app sends /Input_Channels/N/fader at most every
45 ms (about 22 per second; 16/s on average while moving). Fades now
step every 45 ms to match (was 50 ms). Verified on the S31 with a
switched-off matrix send: 23 steps in 1 s, landing exactly on target.

New debug aid: with "Debug logging" on, the module logs every 5 s, per
iPad, the busiest address patterns with their message rate and the
shortest gap between two messages to the same address.

The fade tests now derive their step counts from the exported
FADE_STEP_MS. npm test: 542 checks pass on Node 25 and on Companion's
Node 22.

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

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