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)')
check(oldDefault.updatedConfig.relayTimeout===3600&&out.updatedConfig.relayTimeout===90,'the old default idle timeout (180 s) becomes 3600 s; a custom value stays')
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(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')
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(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(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)`)
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')
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
constinputChoices=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])})`)
constbussChoices=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()})`)
// Buss mode from the types array, stereo from modes, control groups 1-based
check(S.busses.get(9).mode==='group'&&S.busses.get(4).mode==='aux'&&S.busses.get(22).mode==='aux','buss mode comes from /Console/Aux_Outputs/types (9 group, 4 and 22 aux)')
check(S.busses.get(1).stereo===true&&S.busses.get(4).stereo===false,'buss stereo/mono comes from the modes array (1 stereo, 4 mono)')
check(S.cgs.get(1)?.name==='DRUMS'&&S.cgs.get(10)?.name===''&&S.cgs.get(0)===undefined&&S.choices.cgs()[0].id===1&&S.choices.cgs()[0].label==='1 · DRUMS','control groups are 1-based (1 = DRUMS, 10 = last, no 0)')
check(!sent.some((m)=>/Control_Groups\/0\//.test(m.address)), 'the module never addresses control group 0')
check(!sent.slice(0,loadSent).some((m)=>/^\/Console\/(Input_Channels|Aux_Outputs|Group_Outputs)\/\?$/.test(m.address)),'the unanswered Input_Channels / Aux_Outputs / Group_Outputs count queries are never sent')
check(S.choices.matrixInputs()[0].label==='1 · Matrix In 1',`matrix inputs (no name address) are labelled "Matrix In N" (${S.choices.matrixInputs()[0].label})`)
check(!sent.slice(0,loadSent).some((m)=>/\/(Filters|Channel_Delay|Insert|Dynamics|EQ)\/|phantom|input_pad|analog_gain|\/trim|\/phase|\/Aux_Send\//.test(m.address)), 'channel parameters and sends are not bulk-loaded at connect')
check(S.matrix.get(1)?.name==='MAINS L'&&S.has('minput',1)&&S.choices.matrixInputs().length===10,'matrix outputs named; matrix inputs (no name address) listed from the count')
check(S.inputs.get(7).stereo===true&&S.inputs.get(1).stereo===false,'input stereo flags come from /Console/Input_Channels/modes')
check(S.console.name==='S31 AB'&&S.console.session==='Mock Session.session','console and session name loaded')
// Only addresses the catalog shows the real console answering
check(offenders.size===0,`every address sent during the load is one the console answers (${offenders.size?[...offenders].join(', '):`${newSet(sent.slice(0,loadSent).map((m)=>foldPattern(m.address))).size} distinct patterns`})`)
globalThis.__catalogCheck=check1
globalThis.__offenders=offenders
}
// Variables
awaitsleep(80)
constV=ctx.variables
check(V.input_1_name==='KICK In'&&V.buss_24_name==='Studio'&&V.buss_24_mode==='Group'&&V.buss_1_stereo==='Stereo'&&V.cg_1_name==='DRUMS'&&V.matrix_1_name==='MAINS L','variables carry live names and modes')
check(typeofV.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'inctx.variableDefs&&'cg_10_mute'inctx.variableDefs&&'last_change'inctx.variableDefs&&!('input_61_name'inctx.variableDefs),'variable definitions exist for entities that exist')
// Definitions: every entity option is a searchable dropdown with numeric ids; levels show -inf
check(entityOptions.length>40&&entityOptions.every((o)=>(o.type==='dropdown'||o.type==='multidropdown')&&o.minChoicesForSearch===0&&o.choices.every((c)=>typeofc.id==='number')),`all ${entityOptions.length} entity options are searchable dropdowns with numeric ids`)
check(levels.length===6&&levels.every((o)=>o.type==='number'&&o.showMinAsNegativeInfinity===true&&o.min===-150)&&thresholds.every((o)=>o.showMinAsNegativeInfinity===true),`level options use showMinAsNegativeInfinity (${levels.length} level + ${thresholds.length} threshold options)`)
check(ctx.actions.input_mute.options[0].choices[0].label==='1 · KICK In'&&ctx.actions.send_on.options[1].choices.every((c)=>S.bussMode(c.id)==='aux'),'action dropdowns show the live labels; send actions list aux busses only')
check(refs.every((id)=>P[id])&&newSet(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')
if(optionKeys(def)!==Object.keys(f.options).sort().join())problems.push(`${id}: ${f.feedbackId} options ${Object.keys(f.options)} vs ${optionKeys(def)}`)
if(def.type==='boolean'&&!f.style)problems.push(`${id}: boolean feedback ${f.feedbackId} without style`)
if(def.type==='advanced'&&f.style)problems.push(`${id}: advanced feedback ${f.feedbackId} with style`)
if(def.type==='value')problems.push(`${id}: value feedback ${f.feedbackId} used as a button feedback`)
}
}
constlocals=newSet()
for(constlvofp.localVariables||[]){
locals.add(lv.variableName)
constdef=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`)
elseif(optionKeys(def)!==Object.keys(lv.options).sort().join())problems.push(`${id}: local ${lv.feedbackId} options`)
}
for(constmofp.style.text.matchAll(/\$\(local:(\w+)\)/g))if(!locals.has(m[1]))problems.push(`${id}: text uses undeclared local ${m[1]}`)
for(constmofp.style.text.matchAll(/\$\(S31:(\w+)\)/g))if(!(m[1]inctx.variableDefs))problems.push(`${id}: text uses undefined variable ${m[1]}`)
}
check(problems.length===0,`preset actions, feedbacks, options and local variables are valid (${problems.length?problems.slice(0,5).join('; '):'no problems'})`)
constknobs=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`)
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')
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)})`)
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)})`)
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')
// A write is confirmed by one query once its guard window ends (not immediately)
{
constmark=sent.length
awaitrun('cg_fader_set',{cg:3,level:-4})
awaitsleep(100)
check(!sent.slice(mark).some((m)=>m.address==='/Control_Groups/3/fader/?'),'no immediate confirm query after a write')
awaitsleep(inst.timing.writeGuardMs+200)
constseq=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')
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(awaitwaitFor(()=>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')
awaitsendRaw([want])
check(awaitwaitFor(()=>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')
awaitsendRaw([before?1:0])// restore
awaitsleep(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)
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)')
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')
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('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('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&&(awaitwaitFor(()=>sent.some((m)=>m.address==='/Input_Channels/33/Dynamics/comp_in/?'),2000)),'an unloaded switch feedback reads false and queries the console once')
awaitsleep(120)
check(S.getParam('input',33,'comp_in')===true&&cb('input_comp_in_state',{channel:33})===true,'the replied switch value now drives the feedback (mock default: comp in)')
check(cb('input_fader_value',{channel:5})==='-12.5'&&cb('input_pan_value',{channel:13})==='R100'&&cb('buss_fader_value',{buss:24})==='-3'&&cb('matrix_fader_value',{matrix:2})==='-16'&&cb('cg_fader_value',{cg:2})==='-8','value feedbacks return dB / pan text')
check(cb('snapshot_name_value',{})==='Offering'||cb('snapshot_name_value',{})===S.snapshotName(S.snapshots.current),'snapshot_name_value follows the current snapshot')
check(cb('send_level_value',{channel:40,buss:8})==='--'&&cb('send_on',{channel:40,buss:8})===false,'an unloaded send pair reads as unknown ("--" / false) and is queued for loading')
check(awaitwaitFor(()=>sent.some((m)=>m.address==='/Input_Channels/40/Aux_Send/8/send_level/?'),2000),'the unloaded pair is queried once its feedback runs')
awaitsleep(120)
check(typeofS.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')
check(!!subWire&&subWire.tags===',ii'&&subWire.args[0]===id7&&subWire.args[1]===1,`the first meter feedback subscribes lazily: ${JSON.stringify(subWire)}`)
cb('input_meter_value',{channel:7,unit:'raw'})
check(sent.slice(mark).filter((m)=>m.address==='/Input_Channels/7/Channel_Input/post_meter').length===1,'a meter is subscribed only once')
check(awaitwaitFor(()=>S.getMeter(id7),3000),'the console streams /Meters/values for the subscribed id')
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(cb('input_meter_value',{channel:7,unit:'raw'})===127&&cb('input_meter_value',{channel:7,unit:'percent'})===100,'input_meter_value raw and percent')
check(cb('input_signal',{channel:7,threshold:5})===true&&cb('input_peak',{channel:7,hold:2})===true,'input_signal and input_peak at full scale')
inject(3)
check(cb('input_signal',{channel:7,threshold:5})===false&&cb('input_peak',{channel:7,hold:2})===true,'signal drops below the threshold while the peak is still held')
awaitsleep(30)
check(cb('input_peak',{channel:7,hold:0})===false,'the peak hold expires')
check(Buffer.from(bar.imageBuffer,'base64').length===thicknessFor(72)*72*4&&bar.imageBufferPosition.x===72-thicknessFor(72),'input_meter_bar returns a right-edge strip of the right size')
// throttling: a flood of meter messages produces at most ~10 feedback checks per second, by instance id
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
check(meterChecks.length>0&&meterChecks.length<=Math.ceil((spanMs/1000)*10)+1,`meter feedback checks throttled to <= 10 Hz (${meterChecks.length} checks in ${spanMs} ms for 100 messages)`)
check(meterChecks.every(([,ids])=>ids.every((x)=>watching7.has(x)))&&!meterChecks.some(([,ids])=>ids.includes(gateInstance)),'a meter update re-checks only the instances watching the changed meter id')
check(ctx.checks.length===0,'meter updates never fall back to per-type checks')
constrebuildsBefore=ctx.rebuilds
awaitsleep(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')
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')
awaitsleep(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')
check(!sent.slice(mark).some((m)=>m.address==='/Input_Channels/7/Channel_Input/post_meter'),'no resubscribe before the delay has passed')
check(awaitwaitFor(()=>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')
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(awaitpushFader(-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')
relay0.clients.get('127.0.0.1').lastSeen=1// silent for ages
check(relay0.timeoutMs===0&&relay0.clientCount===1&&(awaitpushFader(-7)),'relayTimeout 0 = never forget: an iPad silent for ages is still listed and still gets console traffic')
check(inst.relay===relay0&&relay0.clientPort===0&&(awaitpushFader(-6)),'a relayClientPort change is applied in place (0 = reply to the iPad source port)')
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(awaitpushFader(-5),'the iPad receives console traffic through the reopened relay without registering again')
check(inst.relay===null&&inst.relayClients===clients0&&clients0.size===1,'disabling the relay closes it and keeps the client table')
awaitinst.configUpdated({...config})
check(!!inst.relay&&inst.relay.port===P.relay&&inst.relay.clientCount===1&&(awaitpushFader(-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
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')
awaitinst.configUpdated({...config})
check(awaitwaitFor(()=>logCount(/load complete/)===loadsDone+1,20000),'changing it back reloads from the console')
check(inst.relayClients===clients0&&inst.relay.clientCount===1&&(awaitpushFader(-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)
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'})`)
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(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`)
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')
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})`)
letthrew2=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')
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')
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')
constmark=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')
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')
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})`)
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')
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')
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)
awaitsleep(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)=>typeoff.unsubscribe==='function'),'every feedback definition has an unsubscribe hook')
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')
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'})`)
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')
check(!('send_prepost'inctx.actions)&&!('send_pre'inctx.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'inctx.actions&&'buss_delay_on'inctx.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')
// The console-link port is taken: connection_failure, retried, and a config change ends the retrying
constblocker=awaitoccupy(P.linkB)
constbctx=makeContext()
constb=newInstance(bctx)
Object.assign(b.timing,fastTiming)
awaitb.init(cfgB)
constst=()=>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]})`)
awaitsleep(800)
check(b.link===null&&st()[0]==='connection_failure','it keeps retrying while the port stays occupied')
b.timing.writeGuardMs=0// control: without the guard the same clicks are lost to stale echoes
constcontrol=awaitclicks(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(constintervalof[40,100]){
constr=awaitclicks(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})`)
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
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)`)
check(asks()===a0+1,'a lazy load is requested once, not on every evaluation')
awaitsleep(b.timing.replyWaitMs+150)
b.ensure('t:eq',['/Input_Channels/40/EQ/eq_in'])
awaitsleep(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'])
awaitsleep(60)
check(asks()===a0+2,'but not more than once per wait')
awaitb.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
constblocker=awaitoccupy(P.relayB)
constrctx=makeContext()
constr=newInstance(rctx)
Object.assign(r.timing,fastTiming)
awaitr.init(cfgB)
constst=()=>rctx.statuses.at(-1)
check(awaitwaitFor(()=>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')
awaitsleep(800)
check(st()[0]==='unknown_warning','the warning stays while the port is occupied')
blocker.close()
check(awaitwaitFor(()=>!!r.relay&&st()?.[0]==='ok',5000),'when the port is free the retry opens the relay and the status goes back to ok')
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(', ')}`:''}`)