import test from 'node:test'; import assert from 'node:assert/strict'; import { ScoreTransport, notesAt } from '../lib/playground/v2/transport.mjs'; const notes = [[0,8,60,75,1], [2,2,62,75,1], [9,1,64,75,2]]; function rig(activation = async () => {}) { let time = 0; const events = []; const adapter = { enable: activation, cancel: () => events.push(['cancel']), perform: (b,p) => events.push(['perform',b,p]), startAt: (b,p) => events.push(['start',b,p]), schedule: (a,b,p,c,i) => events.push(['schedule',a,b,p,c,i]), position: b => events.push(['position',b]), dispose: () => events.push(['dispose']) }; const transport = new ScoreTransport({duration:10, adapter, now: () => time}); return {transport,events,at: n => {time=n; transport.tick();}}; } test('held-note reconstruction excludes completed notes and reports remaining beats', () => { assert.deepEqual(notesAt(notes,3), [{note:notes[0],remaining:5},{note:notes[1],remaining:1}]); assert.deepEqual(notesAt(notes,8), []); }); test('manual exploration is silent before activation, audible after activation, and settles to a fixed hold', async () => { const r=rig();r.transport.seek(2);assert.equal(r.events.some(e=>e[0]==='perform'),false); await r.transport.play();const cancels=r.events.filter(e=>e[0]==='cancel').length; r.transport.seek(3);r.transport.seek(3.1);assert.equal(r.transport.state.mode,'manual'); assert.equal(r.events.filter(e=>e[0]==='cancel').length,cancels); assert.ok(r.events.some(e=>e[0]==='perform'&&e[1]===3.1)); r.at(1);assert.equal(r.transport.state.mode,'hold');assert.equal(r.transport.state.beat,3.1); r.at(90);assert.equal(r.transport.state.beat,3.1);assert.equal(r.transport.state.mode,'hold'); await r.transport.play();assert.ok(r.events.some(e=>e[0]==='start'&&e[1]===3.1));r.transport.dispose(); }); test('wheel timing and sensitivity change actual beat traversal and filtered live tempo',async()=>{ const slow=rig(),fast=rig();await slow.transport.play();await fast.transport.play(); for(let i=0;i<8;i++){slow.at(i*.1);fast.at(i*.1);slow.transport.scroll(2,0,800);fast.transport.scroll(20,0,800);} assert.ok(fast.transport.state.beat>slow.transport.state.beat*5);assert.ok(fast.transport.state.tempo>slow.transport.state.tempo*3); const before=slow.transport.state.beat;slow.transport.setSensitivity(2);slow.transport.scroll(10,0,800);assert.ok(Math.abs(slow.transport.state.beat-before-.44)<1e-9); const pixel=rig(),line=rig(),page=rig();pixel.transport.scroll(16,0,800);line.transport.scroll(1,1,800);page.transport.scroll(.02,2,800);assert.equal(pixel.transport.state.beat,line.transport.state.beat);assert.equal(page.transport.state.beat,line.transport.state.beat); slow.transport.dispose();fast.transport.dispose(); }); test('silence revokes manual sound until explicit play and pending seeks cannot trigger late activation',async()=>{ const r=rig();await r.transport.play();r.transport.pause();const count=r.events.filter(e=>e[0]==='perform').length;r.transport.seek(5);assert.equal(r.events.filter(e=>e[0]==='perform').length,count); let release;const pending=rig(()=>new Promise(r=>release=r));const play=pending.transport.play();pending.transport.seek(4);release();await play;assert.notEqual(pending.transport.state.mode,'playing');assert.equal(pending.events.some(e=>e[0]==='perform'),false);pending.transport.dispose();r.transport.dispose(); }); test('pause and disposal win over pending audio activation', async () => { let release; const r=rig(() => new Promise(resolve => release=resolve)); const pending=r.transport.play(); assert.equal(r.transport.state.mode,'enabling'); r.transport.pause(); release(); await pending; assert.equal(r.transport.state.mode,'paused'); assert.ok(!r.events.some(e=>e[0]==='start')); const pending2=r.transport.play(); r.transport.dispose(); release(); await pending2; assert.equal(r.events.filter(e=>e[0]==='dispose').length,1); r.transport.dispose(); assert.equal(r.events.filter(e=>e[0]==='dispose').length,1); }); test('activation rejection stays paused with an actionable error', async () => { const r=rig(async()=>{throw Error('Audio unavailable');}); await r.transport.play(); assert.equal(r.transport.state.mode,'paused'); assert.equal(r.transport.state.error,'Audio unavailable'); }); test('pace preserves beat and loop boundary reconstructs only the new position', async () => { const r=rig(); await r.transport.play(); r.at(6); const beat=r.transport.state.beat; r.transport.setPace(48); assert.equal(r.transport.state.beat,beat); r.at(16); assert.ok(Math.abs(r.transport.state.beat-1.2)<1e-9); assert.ok(r.events.some(e=>e[0]==='start'&&Math.abs(e[1]-1.2)<1e-9)); }); test('loop off ends exactly and Play at end restarts', async () => { const r=rig(); r.transport.setLoop(false); await r.transport.play(); r.at(20); assert.equal(r.transport.state.beat,10); assert.equal(r.transport.state.mode,'ended'); await r.transport.play(); assert.equal(r.transport.state.beat,0); assert.equal(r.transport.state.mode,'playing'); }); test('a throttled frame reconstructs held notes instead of dumping crossed notes', async () => { const r=rig(); await r.transport.play(); r.at(8); assert.ok(r.events.some(e=>e[0]==='start'&&e[1]===8*32/60)); assert.ok(!r.events.some(e=>e[0]==='schedule'&&e[1]===0&&e[2]>1)); }); test('audio clock advances independently of rendering and lookahead uses exact future clock times',async()=>{ let clock=5;const scheduled=[];const adapter={clock:()=>clock,enable:async()=>{},position(){},startAt(){},cancel(){},dispose(){},schedule:(...args)=>scheduled.push(args)}; const t=new ScoreTransport({duration:10,adapter,pace:30});await t.play(); assert.equal(scheduled[0][0],0);assert.ok(Math.abs(scheduled[0][1]-.06)<1e-9);assert.deepEqual(scheduled[0].slice(2),[30,5,false]);clock=5.08; await new Promise(resolve=>setTimeout(resolve,45));assert.ok(Math.abs(t.state.beat-.04)<1e-9); assert.ok(scheduled.some(e=>Math.abs(e[3]-5.12)<1e-9));t.dispose(); }); test('lookahead queues the first note across a loop and loop toggles invalidate queued audio',async()=>{ let clock=0;const events=[];const adapter={clock:()=>clock,enable:async()=>{},position(){},startAt(){},cancel(){events.push('cancel')},dispose(){},schedule:(...args)=>events.push(args)}; const t=new ScoreTransport({duration:1,adapter,pace:60});t.seek(.95);await t.play(); assert.ok(events.some(e=>Array.isArray(e)&&e[0]===0&&e[4]===true)); const cancels=events.filter(e=>e==='cancel').length;t.setLoop(false);assert.equal(events.filter(e=>e==='cancel').length,cancels+1); t.dispose(); }); test('primary Hold stays activated; muted audio routing and true silence remain separate',async()=>{ const r=rig();await r.transport.play();r.at(1);r.transport.hold();const beat=r.transport.state.beat; assert.equal(r.transport.state.mode,'hold');assert.equal(r.transport.state.activated,true); for(let i=0;i<50;i++){await r.transport.play();r.transport.hold();} r.at(1000);assert.equal(r.transport.state.beat,beat);r.transport.pause();assert.equal(r.transport.state.activated,false);assert.equal(r.transport.state.tempo,0);r.transport.dispose(); }); test('silent exploration settles its live movement feedback without claiming a sustained sound',()=>{ const r=rig();r.transport.seek(4);assert.ok(r.transport.state.tempo>0);r.at(1); assert.equal(r.transport.state.tempo,0);assert.equal(r.transport.state.mode,'paused');assert.equal(r.transport.state.activated,false);assert.equal(r.transport.state.beat,4);r.transport.dispose(); }); // Tempo changes must update the schedule without revoking the active performance. test('tempo updates preserve activation and reconcile without cancelling or reattacking',async()=>{ const r=rig();r.transport.adapter.retime=(...args)=>r.events.push(['retime',...args]);await r.transport.play();r.at(.1);const cancels=r.events.filter(e=>e[0]==='cancel').length,starts=r.events.filter(e=>e[0]==='start').length; r.transport.setPace(48);assert.equal(r.events.filter(e=>e[0]==='cancel').length,cancels);assert.equal(r.events.filter(e=>e[0]==='start').length,starts);assert.ok(r.events.some(e=>e[0]==='retime'&&e[2]===48)); const changes=r.events.length;r.transport.setPace(48);assert.equal(r.events.length,changes);r.transport.dispose(); }); test('Auto resumes an activated Hold through reconciliation rather than a silence boundary',async()=>{ const r=rig();r.transport.adapter.retime=(...args)=>r.events.push(['retime',...args]);await r.transport.play();r.transport.seek(3);r.transport.hold();const cancels=r.events.filter(e=>e[0]==='cancel').length;await r.transport.play();assert.equal(r.events.filter(e=>e[0]==='cancel').length,cancels);assert.ok(r.events.some(e=>e[0]==='retime'&&e[1]===3));r.transport.dispose(); }); // Fractional traversal must reach the last onset and the exact note-off, in either direction. test('the entire supplied score range remains reachable through seek, end Hold and non-loop finish',async()=>{ const r=rig();r.transport.duration=183;for(const beat of [0,45.75,91.5,137.25,164.7,181.17,183,181.17,0]){r.transport.seek(beat);assert.equal(r.transport.state.beat,beat);}await r.transport.play();r.transport.seek(183);r.transport.hold();assert.equal(r.transport.state.beat,183);assert.equal(r.transport.state.activated,true);r.transport.seek(182);r.transport.setLoop(false);await r.transport.play();r.at(2);assert.equal(r.transport.state.beat,183);assert.equal(r.transport.state.mode,'ended');r.transport.dispose(); }); test('directional playback requires Play, continues in the last scroll direction until Hold',async()=>{ const r=rig();r.transport.directionalPlayback=true; r.transport.scroll(20);assert.equal(r.transport.state.activated,false);assert.equal(r.transport.state.mode,'paused'); await r.transport.play();r.transport.scroll(20);assert.equal(r.transport.state.mode,'playing'); r.transport.scroll(-10);const beat=r.transport.state.beat;assert.equal(r.transport.state.mode,'playing');assert.equal(r.transport.state.tempo,32); r.at(1);assert.ok(r.transport.state.beatforward); r.transport.hold();r.transport.scroll(1);assert.equal(r.transport.state.mode,'playing');r.transport.dispose(); }); test('directional seam and rapid reversals retain unwrapped movement and never revoke activation',async()=>{ const r=rig();r.transport.directionalPlayback=true;await r.transport.play();r.transport.seek(9.9); r.transport.scroll(10);assert.equal(r.transport.state.mode,'playing');assert.ok(r.transport.state.unwrappedBeat>10); r.transport.scroll(-10);assert.equal(r.transport.state.mode,'playing');assert.ok(r.transport.state.beat>9); for(let i=0;i<40;i++){r.transport.scroll(i%2?-2:2);assert.equal(r.transport.state.mode,'playing');assert.equal(r.transport.state.direction,i%2?-1:1);assert.equal(r.transport.state.activated,true);} r.transport.pause();r.transport.scroll(30);assert.equal(r.transport.state.activated,false);assert.equal(r.transport.state.mode,'paused');r.transport.dispose(); }); test('directional pending activation is cancelled by either direction and forward seek preserves Auto',async()=>{ for(const delta of[-1,1]){let release;const r=rig(()=>new Promise(resolve=>release=resolve));r.transport.directionalPlayback=true;const p=r.transport.play();r.transport.scroll(delta);release();await p;assert.equal(r.transport.state.activated,false);assert.equal(r.transport.state.mode,'paused');r.transport.dispose();} const r=rig();r.transport.directionalPlayback=true;await r.transport.play();r.transport.seek(4);assert.equal(r.transport.state.mode,'playing');r.transport.seek(3);assert.equal(r.transport.state.mode,'playing');assert.equal(r.transport.state.direction,-1);r.transport.dispose(); }); test('absolute timeline direction remains score-local after multiple loops',async()=>{ const r=rig();r.transport.directionalPlayback=true;await r.transport.play();r.transport.advance(31); assert.equal(r.transport.state.beat,1);r.transport.seek(3);assert.equal(r.transport.state.mode,'playing');assert.equal(r.transport.state.unwrappedBeat,33); r.transport.seek(2);assert.equal(r.transport.state.mode,'playing');assert.equal(r.transport.state.direction,-1);assert.equal(r.transport.state.unwrappedBeat,32);r.transport.dispose(); });