import test from 'node:test'; import assert from 'node:assert/strict'; import { generate, rotate } from '../labs/car-loop/model.js'; import { rotationPlan, drainOrientation, batchSeeds } from '../labs/car-loop/batch.js'; // Run the real application frame loop with a minimal DOM and a no-op Canvas. // Virtual time exercises dispatch, rotation, merging and arrival together. test('automatic demonstration delivers every car in all sample sizes', async () => { const saved = new Map(['document','location','requestAnimationFrame'].map(k=>[k,globalThis[k]])); const elements = new Map(); const context = new Proxy({}, {get: (o,k)=>o[k] || (()=>{}), set:(o,k,v)=>(o[k]=v,true)}); const element = () => ({style:{setProperty(){}},dataset:{},classList:{toggle(){}},hidden:true,checked:true, setAttribute(){},addEventListener(name,fn){this[name]=fn;},replaceChildren(...children){this.children=children;},getContext(){return context;}}); const get = id => {if(!elements.has(id))elements.set(id,element());return elements.get(id);}; const shapes = ['hex','square'].map(kind=>({...element(),dataset:{kind}})); let frame; try { globalThis.document = {getElementById:get,createElement:element,body:element(),querySelectorAll:s=>s==='[data-kind]'?shapes:[]}; globalThis.location = {search:'?kind=hex'}; globalThis.requestAnimationFrame = fn => {frame=fn;}; await import('../labs/car-loop/app.js'); get('speed').oninput({target:{value:'3'}}); let now=performance.now(); for(const kind of ['hex','square'])for(const scenario of [0,1,2,3]) { shapes.find(s=>s.dataset.kind===kind).onclick(); get('scenario').onchange({target:{value:String(scenario)}}); const total=Number(get('remaining').textContent); get('auto').onclick(); for(let i=0;i<30000 && get('win').hidden;i++){now+=50;frame(now);} assert.equal(Number(get('arrived').textContent),total,`${kind} ${scenario}: ${get('status').textContent}`); assert.equal(get('win').hidden,false); } // Start auto after manually clicking every car that has an exit route. shapes[0].onclick(); get('scenario').onchange({target:{value:'2'}}); for(const button of [...get('car-list').children])button.click(); get('auto').onclick(); for(let i=0;i<30000 && get('win').hidden;i++){now+=50;frame(now);} assert.equal(Number(get('arrived').textContent),22); shapes[1].onclick(); get('scenario').onchange({target:{value:'1'}}); for(const button of [...get('car-list').children])button.click(); assert.equal(Number(get('remaining').textContent),0); get('auto').onclick(); assert.equal(get('auto').textContent,'Ⅱ 暂停演示'); for(let i=0;i<30000 && get('win').hidden;i++){now+=50;frame(now);} assert.equal(Number(get('arrived').textContent),16); // Batch mode exhausts only the current orientation and waits for a player turn. get('mode').onchange({target:{value:'batch'}}); for (const kind of ['hex','square']) for (const scenario of [0,1,2]) { shapes.find(s=>s.dataset.kind===kind).onclick(); get('scenario').onchange({target:{value:String(scenario)}}); const board = generate(kind,batchSeeds[kind][scenario],16); const plan = rotationPlan(board); const first = drainOrientation(board); get('hint').onclick(); const started = Number(get('transit').textContent); assert.ok(started > 0 && started <= first.departed.length,'the first safe departures start immediately'); assert.equal(Number(get('remaining').textContent),16-started,'booked cars remain visibly parked until their slot'); assert.match(get('batch-result').textContent,new RegExp(`待发 ${first.departed.length-started}`)); for(let i=0;i<4000 && get('hint-label').textContent==='放行中';i++){ if (i % 30 === 0) get('speed').oninput({target:{value:String([1,3,1.5][(i/30)%3])}}); now += [16,33,80][i%3]; frame(now); } assert.equal(Number(get('remaining').textContent),first.remaining.length); assert.equal(get('turn-count').textContent,'已转 0 次'); assert.equal(get('hint-label').textContent,'需转盘'); const left = get('remaining').textContent; for(let i=0;i<120;i++){now+=50;frame(now);} assert.equal(get('remaining').textContent,left,'never rotate or restart automatically'); let turns = 0; for (const direction of plan.turns) { const parkedBeforeTurn = get('remaining').textContent; get(direction === -1 ? 'left' : 'right').onclick(); turns++; for(let i=0;i<12;i++){now+=50;frame(now);} assert.equal(get('remaining').textContent,parkedBeforeTurn); assert.notEqual(get('hint-label').textContent,'放行中','turning alone does not launch'); get('hint').onclick(); for(let i=0;i<4000 && get('hint-label').textContent==='放行中';i++){now+=50;frame(now);} } for(let i=0;i<2000 && get('win').hidden;i++){now+=50;frame(now);} assert.equal(Number(get('arrived').textContent),16,`${kind} batch ${scenario}`); assert.equal(get('turn-count').textContent,`已转 ${turns} 次`); assert.equal(get('win').hidden,false); } // Taking the less efficient first direction must still finish without a traffic jam. for (const kind of ['hex','square']) for (const scenario of [0,1,2]) for (const firstTurn of [-1,1]) { shapes.find(s=>s.dataset.kind===kind).onclick(); get('scenario').onchange({target:{value:String(scenario)}}); let board=generate(kind,batchSeeds[kind][scenario],16), remaining=drainOrientation(board).remaining; const run=()=>{get('hint').onclick();for(let i=0;i<4000&&get('hint-label').textContent==='放行中';i++){now+=50;frame(now);}assert.notEqual(get('hint-label').textContent,'放行中',`${kind} ${scenario} ${firstTurn} stalled`);}; run(); board=rotate(board,firstTurn); const continuation=rotationPlan(board,remaining); for(const turn of [firstTurn,...continuation.turns]) { get(turn===-1?'left':'right').onclick();for(let i=0;i<12;i++){now+=50;frame(now);}run(); } for(let i=0;i<2000&&get('win').hidden;i++){now+=50;frame(now);} assert.equal(Number(get('arrived').textContent),16); } // Resetting and switching modes must cancel outstanding departure slots. shapes[0].onclick(); get('scenario').onchange({target:{value:'1'}}); get('hint').onclick(); get('reset').onclick(); for(let i=0;i<400;i++){now+=50;frame(now);} assert.equal(Number(get('remaining').textContent),16); assert.equal(Number(get('transit').textContent),0); assert.equal(Number(get('arrived').textContent),0); get('hint').onclick(); get('mode').onchange({target:{value:'free'}}); for(let i=0;i<400;i++){now+=50;frame(now);} assert.equal(Number(get('remaining').textContent),16); assert.equal(Number(get('transit').textContent),0); } finally { for(const [key,value] of saved)if(value===undefined)delete globalThis[key];else globalThis[key]=value; } });