diff --git a/main.js b/main.js index 48f0562f..94db765d 100644 --- a/main.js +++ b/main.js @@ -22,12 +22,12 @@ import { Config } from "./config.js"; import { initialise as electron } from "./app/electron.js"; import { AudioHandler } from "./web/audio-handler.js"; import { Econet } from "./econet.js"; +import { joinQuery, parseQuery, combineQuery } from "./web/query-string.js"; let processor; let video; let dbgr; let frames = 0; -let frameSkip = 0; let syncLights; let discSth; let tapeSth; @@ -36,7 +36,6 @@ let model; const gamepad = new GamePad(); let availableImages; let discImage; -const extraRoms = []; if (typeof starCat === "function") { availableImages = starCat(); @@ -44,119 +43,85 @@ if (typeof starCat === "function") { discImage = availableImages[0].file; } } -let queryString = document.location.search.substring(1) + "&" + window.location.hash.substring(1); -let secondDiscImage = null; -let parsedQuery = {}; -let needsAutoboot = false; -let autoType = ""; -let keyLayout = window.localStorage.keyLayout || "physical"; - const BBC = utils.BBC; const keyCodes = utils.keyCodes; -const emuKeyHandlers = {}; -let cpuMultiplier = 1; -let fastAsPossible = false; -let fastTape = false; -let noSeek = false; -let pauseEmu = false; -let stepEmuWhenPaused = false; -let audioFilterFreq = 7000; -let audioFilterQ = 5; -let stationId = 101; -let econet = null; -if (queryString) { - if (queryString[queryString.length - 1] === "/") - // workaround for shonky python web server - queryString = queryString.substring(0, queryString.length - 1); - queryString.split("&").forEach(function (keyval) { - const keyAndVal = keyval.split("="); - const key = decodeURIComponent(keyAndVal[0]); - let val = null; - if (keyAndVal.length > 1) val = decodeURIComponent(keyAndVal[1]); - parsedQuery[key] = val; - - // eg KEY.CAPSLOCK=CTRL - let bbcKey; - if (key.toUpperCase().indexOf("KEY.") === 0) { - bbcKey = val.toUpperCase(); - - if (BBC[bbcKey]) { - const nativeKey = key.substring(4).toUpperCase(); // remove KEY. - if (keyCodes[nativeKey]) { - console.log("mapping " + nativeKey + " to " + bbcKey); - utils.userKeymap.push({ native: nativeKey, bbc: bbcKey }); - } else { - console.log("unknown key: " + nativeKey); - } +const queryString = joinQuery(document.location.search.substring(1), window.location.hash.substring(1)); +const queryTypeMap = (() => { + const map = new Map(); + map.set("autoboot", "boolIfPresent"); + map.set("autochain", "boolIfPresent"); + map.set("autorun", "boolIfPresent"); + map.set("fastTape", "boolIfPresent"); + map.set("noseek", "boolIfPresent"); + map.set("audiofilterfreq", "float"); + map.set("audiofilterq", "float"); + map.set("stationId", "int"); + map.set("rom", "array"); + map.set("glEnabled", "bool"); + map.set("fakeVideo", "boolIfPresent"); + map.set("cpuMultiplier", "float"); + return map; +})(); +let parsedQuery = parseQuery(queryString, queryTypeMap); +for (const key of Object.keys(parsedQuery)) { + const val = parsedQuery[key]; + if (key.toUpperCase().startsWith("KEY.")) { + const bbcKey = val.toUpperCase(); + + if (BBC[bbcKey]) { + const nativeKey = key.substring(4).toUpperCase(); // remove KEY. + if (keyCodes[nativeKey]) { + console.log("mapping " + nativeKey + " to " + bbcKey); + utils.userKeymap.push({ native: nativeKey, bbc: bbcKey }); } else { - console.log("unknown BBC key: " + val); + console.log("unknown key: " + nativeKey); } - } else if (key.indexOf("GP.") === 0) { - // gamepad mapping - // eg ?GP.FIRE2=RETURN - const gamepadKey = key.substring(3).toUpperCase(); // remove GP. prefix - gamepad.remap(gamepadKey, val.toUpperCase()); } else { - switch (key) { - case "LEFT": - case "RIGHT": - case "UP": - case "DOWN": - case "FIRE": - gamepad.remap(key, val.toUpperCase()); - break; - case "autoboot": - needsAutoboot = "boot"; - break; - case "autochain": - needsAutoboot = "chain"; - break; - case "autorun": - needsAutoboot = "run"; - break; - case "autotype": - needsAutoboot = "type"; - autoType = val; - break; - case "keyLayout": - keyLayout = (val + "").toLowerCase(); - break; - case "disc": - case "disc1": - discImage = val; - break; - case "rom": - extraRoms.push(val); - break; - case "disc2": - secondDiscImage = val; - break; - case "embed": - $(".embed-hide").hide(); - $("body").css("background-color", "transparent"); - break; - case "fasttape": - fastTape = true; - break; - case "noseek": - noSeek = true; - break; - case "audiofilterfreq": - audioFilterFreq = Number(val); - break; - case "audiofilterq": - audioFilterQ = Number(val); - break; - case "stationId": - stationId = Number(val); - break; - } + console.log("unknown BBC key: " + val); } - }); + } else if (key.startsWith("GP.") === 0) { + // gamepad mapping + // eg ?GP.FIRE2=RETURN + const gamepadKey = key.substring(3).toUpperCase(); // remove GP. prefix + gamepad.remap(gamepadKey, val.toUpperCase()); + } } -if (parsedQuery.frameSkip) frameSkip = parseInt(parsedQuery.frameSkip); +for (const key of ["UP", "DOWN", "LEFT", "RIGHT", "FIRE"]) { + const val = parsedQuery[key]; + if (val) gamepad.remap(key, val.toUpperCase()); +} + +let needsAutoboot = false; +let autoType = parsedQuery.autotype; +if (parsedQuery.autoboot) needsAutoboot = "boot"; +else if (parsedQuery.autochain) needsAutoboot = "chain"; +else if (parsedQuery.autorun) needsAutoboot = "run"; +else if (parsedQuery.autotype) needsAutoboot = "type"; +discImage = parsedQuery.disc || parsedQuery.disc1 || discImage; +const extraRoms = parsedQuery.rom; + +const secondDiscImage = parsedQuery.disc2; +const keyLayout = parsedQuery.keyLayout || window.localStorage.keyLayout || "physical"; + +if (parsedQuery.embed) { + $(".embed-hide").hide(); + $("body").css("background-color", "transparent"); +} +const fastTape = !!parsedQuery.fastTape; +const noSeek = !!parsedQuery.noseek; +const audioFilterFreq = parsedQuery.audiofilterfreq || 7000; +const audioFilterQ = parsedQuery.audiofilterq || 5; +const stationId = parsedQuery.stationId === undefined ? 101 : parsedQuery.stationId; +let frameSkip = parsedQuery.frameSkip || 0; + +const emuKeyHandlers = {}; +const cpuMultiplier = parsedQuery.cpuMultiplier || 1; +let fastAsPossible = false; +let pauseEmu = false; +let stepEmuWhenPaused = false; +let econet = null; const printerPort = { outputStrobe: function (level, output) { @@ -210,7 +175,7 @@ const emulationConfig = { }; const config = new Config(function (changed) { - parsedQuery = _.extend(parsedQuery, changed); + parsedQuery = { ...parsedQuery, ...changed }; if ( changed.model || changed.coProcessor !== undefined || @@ -267,17 +232,13 @@ sbBind($(".sidebar.bottom"), parsedQuery.sbBottom, function (div, img) { div.css({ bottom: -img.height() }); }); -if (parsedQuery.cpuMultiplier) { - cpuMultiplier = parseFloat(parsedQuery.cpuMultiplier); +if (cpuMultiplier !== 1) { console.log("CPU multiplier set to " + cpuMultiplier); } const clocksPerSecond = (cpuMultiplier * 2 * 1000 * 1000) | 0; const MaxCyclesPerFrame = clocksPerSecond / 10; -let tryGl = true; -if (parsedQuery.glEnabled !== undefined) { - tryGl = parsedQuery.glEnabled === "true"; -} +const tryGl = parsedQuery.glEnabled === undefined ? true : parsedQuery.glEnabled; const $screen = $("#screen"); const canvas = tryGl ? canvasLib.bestCanvas($screen[0]) : new canvasLib.Canvas($screen[0]); video = new Video(model.isMaster, canvas.fb32, function paint(minx, miny, maxx, maxy) { @@ -286,7 +247,7 @@ video = new Video(model.isMaster, canvas.fb32, function paint(minx, miny, maxx, frames = 0; canvas.paint(minx, miny, maxx, maxy); }); -if (parsedQuery.fakeVideo !== undefined) video = new FakeVideo(); +if (parsedQuery.fakeVideo) video = new FakeVideo(); const audioHandler = new AudioHandler($("#audio-warning"), audioFilterFreq, audioFilterQ, noSeek); // Firefox will report that audio is suspended even when it will @@ -706,11 +667,7 @@ discSth = new StairwayToHell(sthStartLoad, makeOnCat(discSthClick), sthOnError, tapeSth = new StairwayToHell(sthStartLoad, makeOnCat(tapeSthClick), sthOnError, true); $("#sth .autoboot").click(function () { - if ($("#sth .autoboot").prop("checked")) { - parsedQuery.autoboot = ""; - } else { - delete parsedQuery.autoboot; - } + parsedQuery.autoboot = $("#sth .autoboot").prop("checked"); updateUrl(); }); @@ -836,14 +793,7 @@ function autoRunBasic() { } function updateUrl() { - let url = window.location.origin + window.location.pathname; - let sep = "?"; - $.each(parsedQuery, function (key, value) { - if (key.length > 0 && value) { - url += sep + encodeURIComponent(key) + "=" + encodeURIComponent(value); - sep = "&"; - } - }); + const url = `${window.location.origin}${window.location.pathname}?${combineQuery(parsedQuery, queryTypeMap)}`; window.history.pushState(null, null, url); } @@ -1553,8 +1503,8 @@ function stop(debug) { const desiredAspectRatio = cubOrigWidth / cubOrigHeight; const minWidth = cubOrigWidth / 4; const minHeight = cubOrigHeight / 4; - const borderReservedSize = parsedQuery.embed !== undefined ? 0 : 100; - const bottomReservedSize = parsedQuery.embed !== undefined ? 0 : 68; + const borderReservedSize = parsedQuery.embed ? 0 : 100; + const bottomReservedSize = parsedQuery.embed ? 0 : 68; function resizeTv() { let navbarHeight = $("#header-bar").outerHeight(); diff --git a/package.json b/package.json index 70abbdb1..f9c60e9e 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,9 @@ "releaseType": "release" } }, + "mocha": { + "spec": "tests/unit/**/*.js" + }, "scripts": { "start": "webpack serve", "build": "webpack --node-env production", @@ -76,8 +79,8 @@ "lint-fix": "eslint . --fix", "format": "prettier --write .", "test-long:cpu": "node tests/test-suite.js", - "test:unit": "mocha tests/unit", - "test:integration": "mocha tests/integration", + "test:unit": "mocha --recursive tests/unit", + "test:integration": "mocha --recursive tests/integration", "test:dormann": "node tests/test-dormann.js", "test": "npm-run-all -p test:*", "test-long": "npm-run-all -p test-long:*" diff --git a/tests/unit/web/test-query-string.js b/tests/unit/web/test-query-string.js new file mode 100644 index 00000000..c12eb462 --- /dev/null +++ b/tests/unit/web/test-query-string.js @@ -0,0 +1,110 @@ +import { describe, it } from "mocha"; +import assert from "assert"; + +import { combineQuery, joinQuery, parseQuery } from "../../../web/query-string.js"; + +describe("Query parser tests", () => { + it("should join queries", () => { + assert.deepStrictEqual(joinQuery(), ""); + assert.deepStrictEqual(joinQuery("moo"), "moo"); + assert.deepStrictEqual(joinQuery("moo&ian"), "moo&ian"); + assert.deepStrictEqual(joinQuery("moo&ian", "blab"), "moo&ian&blab"); + assert.deepStrictEqual(joinQuery("", "blab"), "blab"); + }); + it("should handle empty cases", () => { + assert.deepStrictEqual(parseQuery(""), {}); + }); + it("should handle single toggles", () => { + assert.deepStrictEqual(parseQuery("bob"), { bob: null }); + }); + it("should handle single various params", () => { + assert.deepStrictEqual(parseQuery("splat=1&dither=sniff"), { splat: "1", dither: "sniff" }); + }); + it("should pick last of repeated things if not array type", () => { + assert.deepStrictEqual(parseQuery("rom=1&rom=2&rom=3"), { rom: "3" }); + }); + it("should handle array types", () => { + const types = new Map(); + types.set("rom", "array"); + assert.deepStrictEqual(parseQuery("rom=1", types), { rom: ["1"] }); + assert.deepStrictEqual(parseQuery("rom=1&rom=2&rom=3", types), { rom: ["1", "2", "3"] }); + }); + it("should handle int types", () => { + const types = new Map(); + types.set("someInt", "int"); + assert.deepStrictEqual(parseQuery("someInt=123", types), { someInt: 123 }); + assert.deepStrictEqual(parseQuery("someInt=123.45", types), { someInt: 123 }); + assert.deepStrictEqual(parseQuery("someInt=moo", types), { someInt: NaN }); + }); + it("should handle float types", () => { + const types = new Map(); + types.set("someFloat", "float"); + assert.deepStrictEqual(parseQuery("someFloat=123", types), { someFloat: 123 }); + assert.deepStrictEqual(parseQuery("someFloat=123.45", types), { someFloat: 123.45 }); + assert.deepStrictEqual(parseQuery("someFloat=moo", types), { someFloat: NaN }); + }); + it("should handle bool types", () => { + const types = new Map(); + types.set("someBool", "bool"); + assert.deepStrictEqual(parseQuery("someBool=123", types), { someBool: false }); + assert.deepStrictEqual(parseQuery("someBool", types), { someBool: false }); + assert.deepStrictEqual(parseQuery("someBool=false", types), { someBool: false }); + assert.deepStrictEqual(parseQuery("someBool=true", types), { someBool: true }); + assert.deepStrictEqual(parseQuery("", types), {}); + }); + it("should handle boolIfPresent types", () => { + const types = new Map(); + types.set("someBool", "boolIfPresent"); + assert.deepStrictEqual(parseQuery("someBool=123", types), { someBool: true }); + assert.deepStrictEqual(parseQuery("someBool", types), { someBool: true }); + assert.deepStrictEqual(parseQuery("someBool=false", types), { someBool: false }); + assert.deepStrictEqual(parseQuery("someBool=true", types), { someBool: true }); + assert.deepStrictEqual(parseQuery("", types), {}); + }); +}); + +describe("Query combiner tests", () => { + it("should combine empty things", () => { + assert.equal(combineQuery({}), ""); + }); + it("should combine simple strings", () => { + assert.equal( + combineQuery({ a: "a", b: "b", somethingLong: "somethingLong" }), + "a=a&b=b&somethingLong=somethingLong" + ); + }); + it("should escape strings", () => { + assert.equal( + combineQuery({ horrid: "this & that", "bad key": "value" }), + "horrid=this%20%26%20that&bad%20key=value" + ); + }); + it("should honour types", () => { + const types = new Map(); + types.set("int", "int"); + types.set("float", "float"); + types.set("boolean1", "bool"); + types.set("boolean2", "bool"); + types.set("boolean3", "bool"); + types.set("amIHere", "boolIfPresent"); + types.set("amNotHere", "boolIfPresent"); + types.set("array", "array"); + assert.equal( + combineQuery( + { + string: "stringy", + int: 123, + float: 123.456, + boolean1: true, + boolean2: false, + boolean3: "something truthy", + amIHere: true, + amNotHere: false, + array: ["one", "two", "three", "a space"], + }, + types + ), + "string=stringy&int=123&float=123.456&boolean1=true&boolean2=false&boolean3=true&amIHere&array=one&array=two&array=three&array=a%20space" + ); + }); +}); diff --git a/web/query-string.js b/web/query-string.js new file mode 100644 index 00000000..463ee8a6 --- /dev/null +++ b/web/query-string.js @@ -0,0 +1,82 @@ +export function joinQuery(...queries) { + let result = ""; + for (const query of queries) { + if (query) { + if (result) result += "&"; + result += query; + } + } + return result; +} + +export function parseQuery(queryString, argTypes) { + if (!queryString) return {}; + argTypes = argTypes || new Map(); + const keys = queryString.split("&"); + const parsedQuery = {}; + for (const keyval of keys) { + const keyAndVal = keyval.split("="); + const key = decodeURIComponent(keyAndVal[0]); + const val = keyAndVal.length > 1 ? decodeURIComponent(keyAndVal[1]) : null; + switch (argTypes.get(key)) { + case undefined: + case "string": + parsedQuery[key] = val; + break; + case "array": + if (!(key in parsedQuery)) parsedQuery[key] = []; + parsedQuery[key].push(val); + break; + case "int": + parsedQuery[key] = parseInt(val); + break; + case "float": + parsedQuery[key] = parseFloat(val); + break; + case "bool": + parsedQuery[key] = val === "true"; + break; + case "boolIfPresent": + parsedQuery[key] = val !== "false"; + break; + default: + throw new Error(`Unknown arg type ${argTypes.get(key)}`); + } + } + return parsedQuery; +} + +export function combineQuery(parsedQuery, argTypes) { + argTypes = argTypes || new Map(); + const urlParts = []; + for (const key of Object.keys(parsedQuery)) { + const val = parsedQuery[key]; + switch (argTypes.get(key)) { + case undefined: + case "string": + urlParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(val)}`); + break; + case "array": { + for (const subVal of val) { + urlParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(subVal)}`); + } + break; + } + case "int": + urlParts.push(`${encodeURIComponent(key)}=${val | 0}`); + break; + case "float": + urlParts.push(`${encodeURIComponent(key)}=${Number(val)}`); + break; + case "bool": + urlParts.push(`${encodeURIComponent(key)}=${val ? "true" : "false"}`); + break; + case "boolIfPresent": + if (val) urlParts.push(`${encodeURIComponent(key)}`); + break; + default: + throw new Error(`Unknown arg type ${argTypes.get(key)}`); + } + } + return urlParts.join("&"); +}