cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

fix/release-build-and-order
goq / portal / controller-runtime.test.mjs
controller-runtime.test.mjs 7.9K
1import assert from 'node:assert/strict';2import { readFileSync } from 'node:fs';3import test from 'node:test';45import { createControllerRuntime } from './controller-runtime.mjs';6import { neutralGamepadInputState } from './controller-state.mjs';78const main = readFileSync(new URL('./main.js', import.meta.url), 'utf8');910function gamepad({11 index = 0,12 id = 'test-pad',13 mapping = 'standard',14 timestamp = 1,15 axes = [0, 0, 0, 0],16 pressed = [],17 values = {},18} = {}) {19 const buttons = Array(17).fill(0);20 for (const button of pressed) buttons[button] = 1;21 for (const [button, value] of Object.entries(values)) buttons[Number(button)] = value;22 return {23 connected: true,24 index,25 id,26 mapping,27 timestamp,28 axes,29 buttons,30 };31}3233function harness() {34 let pads = [];35 let remoteRoute = false;36 const scheduled = [];37 const sent = [];38 const navigated = [];39 const statuses = [];40 const warnings = [];41 let activations = 0;42 let backs = 0;43 let exits = 0;44 const runtime = createControllerRuntime({45 getGamepads: () => pads,46 schedulePoll: (callback) => scheduled.push(callback),47 isRemoteRoute: () => remoteRoute,48 sendRemoteState: (state) => sent.push(state),49 onNavigate: (direction) => navigated.push(direction),50 onActivate: () => { activations++; },51 onBack: () => { backs++; },52 onStatus: (state) => statuses.push(state),53 onExit: () => { exits++; },54 warn: (...args) => warnings.push(args),55 });56 return {57 runtime,58 scheduled,59 sent,60 navigated,61 statuses,62 warnings,63 get activations() { return activations; },64 get backs() { return backs; },65 get exits() { return exits; },66 setPads(next) { pads = next; },67 setRemoteRoute(next) { remoteRoute = next; },68 };69}7071function flushPublisher() {72 return new Promise((resolve) => setImmediate(resolve));73}7475test('polling selects a standard controller and publishes only signature changes', async () => {76 const fixture = harness();77 const observed = [];78 fixture.runtime.setObserver((state) => observed.push(state));79 fixture.setRemoteRoute(true);80 fixture.setPads([gamepad({ timestamp: 10, axes: [0.25, 0, 0, 0] })]);8182 fixture.runtime.poll(0);83 await flushPublisher();84 assert.equal(fixture.statuses.length, 1);85 assert.equal(fixture.statuses[0].sequence, 1);86 assert.equal(fixture.runtime.latest.sequence, 1);87 assert.equal(observed.length, 1);88 assert.equal(fixture.sent.length, 1);89 assert.equal(fixture.sent[0].left_x, 8192);90 assert.equal(fixture.scheduled.length, 1);9192 fixture.setPads([gamepad({ timestamp: 999, axes: [0.25, 0, 0, 0] })]);93 fixture.runtime.poll(16);94 await flushPublisher();95 assert.equal(fixture.statuses.length, 1);96 assert.equal(observed.length, 1);97 assert.equal(fixture.runtime.latest.sequence, 1);9899 fixture.setPads([gamepad({ timestamp: 1000, axes: [0.5, 0, 0, 0] })]);100 fixture.runtime.poll(32);101 await flushPublisher();102 assert.equal(fixture.statuses.length, 2);103 assert.equal(fixture.statuses[1].sequence, 2);104 assert.equal(fixture.runtime.latest.sequence, 2);105 assert.equal(observed.length, 2);106});107108test('local routing preserves navigation repeat and A/B edge actions', () => {109 const fixture = harness();110 fixture.setPads([gamepad({ pressed: [0, 1, 12] })]);111112 fixture.runtime.poll(0);113 assert.deepEqual(fixture.navigated, ['up']);114 assert.equal(fixture.activations, 1);115 assert.equal(fixture.backs, 1);116117 fixture.runtime.poll(100);118 assert.deepEqual(fixture.navigated, ['up']);119 assert.equal(fixture.activations, 1);120 assert.equal(fixture.backs, 1);121122 fixture.runtime.poll(360);123 assert.deepEqual(fixture.navigated, ['up', 'up']);124 assert.equal(fixture.activations, 1);125 assert.equal(fixture.backs, 1);126});127128test('remote routing reserves Back+Start and exits after one uninterrupted second', async () => {129 const fixture = harness();130 fixture.setRemoteRoute(true);131 fixture.setPads([gamepad({ pressed: [8, 9, 12] })]);132133 fixture.runtime.poll(0);134 fixture.runtime.poll(999);135 assert.equal(fixture.exits, 0);136 fixture.runtime.poll(1000);137 assert.equal(fixture.exits, 1);138 fixture.runtime.poll(2000);139 assert.equal(fixture.exits, 1);140 assert.deepEqual(fixture.navigated, []);141 await flushPublisher();142 assert.equal(fixture.sent.length, 1);143 assert.equal(fixture.sent[0].back, false);144 assert.equal(fixture.sent[0].start, false);145 assert.equal(fixture.sent[0].dpad_up, true);146147 fixture.setPads([gamepad()]);148 fixture.runtime.poll(2001);149 fixture.setPads([gamepad({ pressed: [8, 9] })]);150 fixture.runtime.poll(2100);151 fixture.runtime.poll(3100);152 assert.equal(fixture.exits, 2);153});154155test('controller loss publishes a neutral remote state while the control route stays active', async () => {156 const fixture = harness();157 fixture.setRemoteRoute(true);158 fixture.setPads([gamepad({ pressed: [0], axes: [1, 0, 0, 0] })]);159 fixture.runtime.poll(0);160 await flushPublisher();161 assert.equal(fixture.sent[0].a, true);162163 fixture.setPads([]);164 fixture.runtime.poll(16);165 await flushPublisher();166 assert.deepEqual(fixture.sent[1], neutralGamepadInputState());167 assert.equal(fixture.statuses.at(-1).connected, false);168});169170test('manual publication preserves observer and remote routing without changing sequence', async () => {171 const fixture = harness();172 fixture.setRemoteRoute(true);173 fixture.setPads([gamepad({ pressed: [0] })]);174 fixture.runtime.poll(0);175 await flushPublisher();176 const sequence = fixture.runtime.latest.sequence;177 const observed = [];178 fixture.runtime.setObserver((state) => observed.push(state));179180 fixture.runtime.publishCurrentState();181 await flushPublisher();182 assert.equal(fixture.runtime.latest.sequence, sequence);183 assert.equal(observed.length, 1);184 assert.equal(fixture.sent.length, 2);185 assert.throws(() => fixture.runtime.setObserver('invalid'), /observer must be a function or null/);186});187188test('observer and gamepad polling failures are contained without stopping the loop', async () => {189 const fixture = harness();190 fixture.setRemoteRoute(true);191 fixture.setPads([gamepad()]);192 fixture.runtime.setObserver(() => { throw new Error('observer boom'); });193 fixture.runtime.poll(0);194 await flushPublisher();195 assert.equal(fixture.sent.length, 1);196 assert.match(String(fixture.warnings[0][1]), /observer boom/);197198 const scheduled = [];199 const warnings = [];200 const runtime = createControllerRuntime({201 getGamepads: () => { throw new Error('poll boom'); },202 schedulePoll: (callback) => scheduled.push(callback),203 warn: (...args) => warnings.push(args),204 });205 runtime.poll(16);206 assert.equal(scheduled.length, 1);207 assert.equal(warnings[0][0], 'gamepad poll failed:');208 assert.match(String(warnings[0][1]), /poll boom/);209});210211test('start schedules the first poll and resetEscape cancels an in-progress hold', () => {212 const fixture = harness();213 fixture.runtime.start();214 assert.equal(fixture.scheduled.length, 1);215216 fixture.setRemoteRoute(true);217 fixture.setPads([gamepad({ pressed: [8, 9] })]);218 fixture.runtime.poll(0);219 fixture.runtime.resetEscape();220 fixture.runtime.poll(1000);221 assert.equal(fixture.exits, 0);222 fixture.runtime.poll(2000);223 assert.equal(fixture.exits, 1);224});225226test('Portal delegates controller state while retaining DOM navigation and polling start', () => {227 assert.match(main, /import \{ createControllerRuntime \} from '\.\/controller-runtime\.mjs';/);228 assert.match(main, /controllerRuntime = createControllerRuntime\(\{/);229 assert.match(main, /onNavigate: navigateControllerFocus/);230 assert.match(main, /onActivate: activateControllerFocus/);231 assert.match(main, /onBack: controllerBack/);232 assert.match(main, /onStatus: updateControllerStatus/);233 assert.match(main, /requestAnimationFrame\(controllerRuntime\.poll\)/);234 assert.match(main, /function controllerScope\(\)/);235 assert.match(main, /function setControllerFocus\(element\)/);236 assert.doesNotMatch(main, /const controllerPublisher =/);237 assert.doesNotMatch(main, /let controllerSequence =/);238 assert.doesNotMatch(main, /function pollControllers\(/);239});