Files
HexTrails/tests/airport.test.js
T
toshiocn 1be0e9caf1 feat(airport): add the Hex Airport dispatch lab as a baseline snapshot
Six-side aprons, frozen levels, solver, rewards and verify scripts; the edge-runway readability work continues from here.
2026-09-21 14:25:09 +08:00

210 lines
14 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { hexagon, trace, opposite } from '../src/core.js';
import { pathPoints, sweepPath, position, PORTS } from '../src/path-math.js';
import { AirportGame, catalogs, checkLanding, createState, transition, solveAirport, replay, validateAirportLevel, uniquenessIssues, SIDES, boardRadius, apronDistance, waitingPose } from '../labs/airport/core.js';
import { LEVELS } from '../labs/airport/levels.js';
import { requestReward } from '../labs/airport/rewards.js';
const clone=x=>structuredClone(x);
const fixture=()=>({id:'fixture',name:'直线机场',cells:hexagon(2),stands:[{id:'P1',q:0,r:0,color:8}],
arrivals:[{id:'in1',color:8,side:0}],originals:[],moveLimit:10,reward:{steps:5,maxClaims:2}});
const tap=id=>({type:'tap',id});
const execute=(game,action)=>{const result=game.begin(action);game.finish(action.type==='rotate'?'rotation':action.id);return result;};
test('frozen levels have unique routes at every side/orientation and exact budgeted proofs',()=>{
for(const level of LEVELS){
assert.deepEqual(validateAirportLevel(level),[],level.id);
assert.deepEqual(uniquenessIssues(level),[]);
const answer=solveAirport(level);
assert.equal(answer.status,'solved');assert.equal(answer.minimumActions,level.evidence.minimumActions);
const result=replay(level,level.solution);assert.ok(result.won);assert.ok(result.moves>=0);
assert.equal(result.state.planes.filter(p=>p.kind==='original'&&p.state==='ground').length,level.evidence.remainingOriginals);
assert.ok(level.evidence.remainingOriginals>0);
assert.ok(replay(level,level.experience).won);
const all=solveAirport(level,createState(level),{clearFirst:true});
assert.equal(all.status,'solved');assert.equal(all.minimumActions,level.evidence.clearFirst.minimumActions);
assert.ok(all.minimumActions>level.moveLimit,'clearing all must cost more than free budget');
}
});
const bayNormal=side=>{const n=PORTS[side],l=Math.hypot(n.x,n.y),x=n.x/l,y=n.y/l;const c=Math.cos(Math.PI/6),s=Math.sin(Math.PI/6);return{x:x*c-y*s,y:x*s+y*c};};
test('waiting aprons sit on the board outline flat sides, outside every cell',()=>{
assert.deepEqual(SIDES,['上方','右上方','右下方','下方','左下方','左上方']);
assert.equal(boardRadius(hexagon(2)),2);
assert.equal(boardRadius(hexagon(3)),3);
for(let side=0;side<6;side++){
const plane={id:'in1',kind:'arrival',state:'waiting',side};
const pose=waitingPose(plane,[plane],2),n=bayNormal(side);
assert.ok(Math.abs(pose.x-n.x*apronDistance(2))<1e-9&&Math.abs(pose.y-n.y*apronDistance(2))<1e-9,`side ${side} bay on its flat-side normal`);
for(const cell of hexagon(2)){const p=position(cell.q,cell.r);assert.ok(pose.x*n.x+pose.y*n.y>p.x*n.x+p.y*n.y+0.5,`side ${side} bay must stand off the board`);}
}
});
test('each waiting bay sits on a flat side of the pointy-top outline, nose pointing at the board',()=>{
// The cell cluster's outline is pointy-top: its wide flat sides are half a step
// (30°) from the port normals at screen angles -60/0/60/120/180/240.
for(let side=0;side<6;side++){
const plane={id:'in1',kind:'arrival',state:'waiting',side};
const pose=waitingPose(plane,[plane],2),n=bayNormal(side);
const dir={x:pose.x/Math.hypot(pose.x,pose.y),y:pose.y/Math.hypot(pose.x,pose.y)};
assert.ok(Math.abs(dir.x-n.x)<1e-9&&Math.abs(dir.y-n.y)<1e-9,`side ${side} must lie on its flat-side normal`);
assert.ok(Math.abs(Math.hypot(pose.x,pose.y)-apronDistance(2))<1e-9);
// Nose vector in screen space is (sin(angle), -cos(angle)); it must equal the
// inward normal (opposite of the outward normal).
const inward={x:-n.x,y:-n.y};
assert.ok(Math.abs(Math.sin(pose.angle)-inward.x)<1e-9&&Math.abs(-Math.cos(pose.angle)-inward.y)<1e-9,`side ${side} nose must point at the board`);
}
});
test('same-side aircraft stack outward along the flat-side normal, never tangent',()=>{
const group=[0,1,2].map(i=>({id:`in${i}`,kind:'arrival',state:'waiting',side:0}));
const n=bayNormal(0),tangent={x:-n.y,y:n.x};
let previous=null;
for(const plane of group){
const pose=waitingPose(plane,group,2);
// Zero tangential component: position is a scalar multiple of the normal.
assert.ok(Math.abs(pose.x*tangent.x+pose.y*tangent.y)<1e-9,`${plane.id} drifts along the tangent`);
const dist=Math.hypot(pose.x,pose.y);
if(previous!==null) assert.ok(dist>previous,`${plane.id} must be farther out than the previous`);
previous=dist;
}
});
test('landing is the exact reverse of centre-out geometry and retains inward heading',()=>{
const level=fixture(),state=createState(level),route=checkLanding(level,state,'in1');
assert.equal(route.status,'land');assert.equal(route.dir,3);
const reversed=pathPoints(trace(level,{id:'virtual',q:0,r:0,dir:0},[],true)).reverse();
assert.deepEqual(route.points,reversed);assert.deepEqual(route.points.at(-1),position(0,0));
assert.ok(catalogs(level)[0].arrivals.every(r=>r.dir===opposite(Number(r.signature.split(':')[1]))));
});
test('landing selects across a side, not an exact entrance or another side',()=>{
const level=LEVELS[2];
let checkedNoRoute=false,checkedLand=false;
for(let o=0;o<6;o++)for(let side=0;side<6;side++){
const state=createState(level);state.orientation=o;state.planes=state.planes.filter(p=>p.kind==='arrival');state.planes[0].side=side;
const route=checkLanding(level,state,state.planes[0].id);
const matches=catalogs(level)[o].arrivals.filter(r=>r.color===state.planes[0].color&&r.side===side);
if(!matches.length){assert.equal(route.status,'no-route');checkedNoRoute=true;}
else{assert.equal(route.status,'land');assert.equal(route.side,side);checkedLand=true;}
}
assert.ok(checkedNoRoute&&checkedLand);
});
test('clearing a colored original does not create a stand or count toward the goal',()=>{
const level=fixture();level.originals=[{id:'red',q:2,r:0,dir:2,color:8}];
const game=new AirportGame(level);assert.equal(execute(game,tap('red')).status,'exit');
assert.equal(game.won,false);assert.equal(game.level.stands.length,1);
assert.equal(game.state.planes.find(p=>p.id==='red').state,'gone');
});
test('incoming collision reports only the first obstacle, then the next',()=>{
const level=fixture();level.originals=[{id:'near',q:0,r:-1,dir:0,color:0},{id:'outer',q:0,r:-2,dir:0,color:2}];
const state=createState(level);
assert.equal(checkLanding(level,state,'in1').blocker,'outer');
state.planes.find(p=>p.id==='outer').state='gone';
assert.equal(checkLanding(level,state,'in1').blocker,'near');
});
test('curved/grazing collision uses the shared continuous hex sweep, including terminal leg',()=>{
let grazing=0,terminal=0;
for(const level of LEVELS)for(let o=0;o<6;o++)for(const route of catalogs(level)[o].arrivals){
const visited=new Set(route.steps.map(s=>`${s.q},${s.r}`));
for(const cell of level.cells){
if(cell.q===route.q&&cell.r===route.r)continue;
const obstacle={...position(cell.q,cell.r),id:'blocker'},hit=sweepPath(route.points,[obstacle]);
if(!hit)continue;
const state={orientation:o,planes:[{id:'in',kind:'arrival',state:'waiting',color:route.color,side:route.side},{id:'blocker',kind:'original',state:'ground',color:0,dir:0,q:cell.q,r:cell.r}]};
const actual=checkLanding(level,state,'in');assert.equal(actual.status,'blocked');assert.equal(actual.blocker,'blocker');
if(!visited.has(`${cell.q},${cell.r}`))grazing++;
if(sweepPath(route.points.slice(-2),[obstacle]))terminal++;
}
}
assert.ok(grazing>0,'fixtures must exercise neighboring-cell grazing');assert.ok(terminal>0,'terminal segment is swept');
});
test('atomic reservation prevents two fast landings into the same free stand',()=>{
const level=fixture();level.arrivals.push({id:'in2',color:8,side:0});
const orientation=catalogs(level).findIndex(c=>c.arrivals.length);
const route=catalogs(level)[orientation].arrivals[0];level.arrivals.forEach(p=>p.side=route.side);
const game=new AirportGame(level);game.state.orientation=orientation;
const first=game.begin(tap('in1'));
assert.equal(first.status,'land');assert.ok(game.busy.has('in1'));
assert.equal(game.begin(tap('in1')).status,'busy');
assert.equal(game.begin(tap('in2')).status,'no-stand');
assert.equal(game.moves,level.moveLimit);assert.equal(game.state.planes.filter(p=>p.state==='parked').length,1);
});
test('off-board taps are free, while ground departures and rotations cost one',()=>{
const level=fixture();level.originals=[{id:'out',q:0,r:1,dir:0,color:0}];
const game=new AirportGame(level);
assert.equal(execute(game,tap('out')).status,'exit');assert.equal(game.moves,level.moveLimit-1);
assert.equal(execute(game,{type:'rotate',direction:1}).status,'rotated');assert.equal(game.moves,level.moveLimit-2);
assert.equal(execute(game,tap('in1')).status,'land');assert.equal(game.moves,level.moveLimit-2);
});
test('returning arrival waits at actual exit side; landing count is not cumulative',()=>{
const level=fixture();level.arrivals.push({id:'in2',color:0,side:1});level.stands.push({id:'P2',q:2,r:-1,color:0});
const game=new AirportGame(level);assert.equal(execute(game,tap('in1')).status,'land');
const departure=execute(game,tap('in1'));assert.equal(departure.status,'exit');assert.equal(departure.side,3);
const plane=game.state.planes.find(p=>p.id==='in1');assert.equal(plane.state,'waiting');assert.equal(plane.side,3);assert.equal(plane.q,undefined);
assert.equal(game.won,false);assert.equal(execute(game,tap('in1')).status,'land');assert.equal(game.moves,level.moveLimit-1);assert.equal(game.won,false);
});
test('rotation keeps aircraft and stands fixed; only rotations cost here',()=>{
const level=fixture();level.originals=[{id:'block',q:0,r:-1,dir:3,color:2}];
const game=new AirportGame(level),planes=clone(game.state.planes),stands=clone(level.stands);
assert.equal(execute(game,tap('in1')).status,'blocked');assert.equal(game.moves,level.moveLimit);assert.deepEqual(game.state.planes,planes);
for(let i=0;i<6;i++)assert.equal(execute(game,{type:'rotate',direction:1}).status,'rotated');
assert.equal(game.state.orientation,0);assert.deepEqual(game.state.planes,planes);assert.deepEqual(level.stands,stands);assert.equal(game.moves,level.moveLimit-6);
assert.equal(execute(game,tap('missing')).status,'invalid');assert.equal(game.moves,level.moveLimit-6);
});
test('a free landing still wins after the budget is spent',()=>{
const level=fixture();level.moveLimit=1;level.originals=[{id:'out',q:0,r:1,dir:0,color:0}];
const game=new AirportGame(level);
assert.equal(game.begin(tap('out')).status,'exit');assert.equal(game.moves,0);
assert.equal(game.begin(tap('in1')).status,'land');assert.equal(game.moves,0);assert.ok(game.won);
assert.equal(game.begin({type:'rotate',direction:1}).status,'finished');
});
test('validator rejects ambiguous targets, equal-target/different-heading routes and bad counts',()=>{
const level=fixture();assert.deepEqual(validateAirportLevel(level),[]);
level.stands.push({id:'P2',q:1,r:0,color:8});level.arrivals.push({id:'in2',color:8,side:1});
assert.ok(validateAirportLevel(level).includes('存在多个合法停靠结果'));
const game=new AirportGame(level);assert.equal(game.begin(tap('in1')).status,'ambiguous');assert.equal(game.moves,10);
const bent=fixture();bent.cells.forEach(c=>c.tracks=[1,0,3,2,5,4]);bent.stands[0].q=2;bent.stands[0].r=-2;
assert.ok(uniquenessIssues(bent).some(i=>new Set(i.results.map(r=>r.split(':')[0])).size===1));
const malformed=fixture();malformed.arrivals=[];assert.ok(validateAirportLevel(malformed).length>0);
});
test('empty stands do not obstruct original takeoff regardless of color',()=>{
const level=fixture();level.originals=[{id:'out',q:0,r:1,dir:0,color:0}];
const game=new AirportGame(level);assert.equal(execute(game,tap('out')).status,'exit');assert.equal(game.level.stands[0].color,8);
});
test('solver distinguishes a rotation rescue, proven deadlock and search limits',()=>{
const level=LEVELS[1],state=createState(level);
assert.equal(solveAirport(level,state,{maxStates:1}).status,'unknown');
const rotating=LEVELS.find(l=>l.solution.some(a=>a.type==='rotate'));assert.equal(solveAirport(rotating).status,'solved');
const dead=fixture();dead.cells.forEach(c=>c.tracks=[1,0,3,2,5,4]);
assert.equal(solveAirport(dead).status,'deadlock');
assert.equal(solveAirport(fixture(),createState(fixture()),{maxDepth:0}).status,'unknown');
});
test('teaching arc: rotation connects the runway and blockers must be cleared',()=>{
// L3: the off-board plane is not connected at start; one rotation opens the runway.
const l3=LEVELS[2];let s3=createState(l3);
assert.equal(checkLanding(l3,s3,'in1').status,'no-route');
s3=transition(l3,s3,{type:'rotate',direction:-1}).state;
assert.equal(checkLanding(l3,s3,'in1').status,'land');
// L4: an original blocks a runway at the start; the proven replay rotates and clears a blocker.
const l4=LEVELS[3];const s4=createState(l4);
assert.ok(l4.arrivals.some(a=>checkLanding(l4,s4,a.id).status==='blocked'));
assert.ok(l4.solution.some(a=>a.type==='rotate'));
assert.ok(l4.solution.some(a=>a.type==='tap'&&a.id.startsWith('out')));
assert.ok(replay(l4,l4.solution).won);
});
test('reward receipts are idempotent, limited and do not mutate the board',async()=>{
const game=new AirportGame(fixture()),state=clone(game.state);game.moves=0;
assert.equal((await requestReward(game,async()=>({completed:false,id:'a'}))).status,'cancelled');assert.equal(game.moves,0);
assert.equal((await requestReward(game,async()=>{throw Error('network');})).status,'failed');
assert.equal((await requestReward(game,async()=>({completed:true,id:'a'}))).status,'granted');assert.equal(game.moves,5);
assert.equal((await requestReward(game,async()=>({completed:true,id:'a'}))).status,'cancelled');assert.equal(game.moves,5);
assert.deepEqual(game.state,state);
assert.equal(game.grantReward({completed:'false',id:'invalid'}),false);
assert.equal(game.grantReward({completed:true,id:'second'}),true);assert.equal(game.moves,10);
assert.equal(game.grantReward({completed:true,id:'over-limit'}),false);
game.deadlocked=true;assert.equal(game.grantReward({completed:true,id:'b'}),false);
});
test('reward completion cannot revive a restarted session or accept concurrent claims',async()=>{
const game=new AirportGame(fixture());let current=true,resolve;
const pending=requestReward(game,()=>new Promise(r=>resolve=r),()=>current);
assert.equal((await requestReward(game,async()=>({completed:true,id:'double'}))).status,'unavailable');
current=false;resolve({completed:true,id:'old'});assert.equal((await pending).status,'stale');assert.equal(game.moves,10);
});