From 8c3141cc35c77c4083b267b40ab8c5ed33fbdecc Mon Sep 17 00:00:00 2001 From: Matt Godbolt Date: Wed, 12 Apr 2023 07:31:50 -0500 Subject: [PATCH 1/3] Configure some tests, extract query parsing --- main.js | 3 - package.json | 7 ++- tests/unit/web/test-query-string.js | 96 +++++++++++++++++++++++++++++ web/query-string.js | 74 ++++++++++++++++++++++ 4 files changed, 175 insertions(+), 5 deletions(-) create mode 100644 tests/unit/web/test-query-string.js create mode 100644 web/query-string.js diff --git a/main.js b/main.js index 48f0562f..a90171b4 100644 --- a/main.js +++ b/main.js @@ -66,9 +66,6 @@ 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]); 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..2725d8e7 --- /dev/null +++ b/tests/unit/web/test-query-string.js @@ -0,0 +1,96 @@ +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 }); + }); +}); + +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("array", "array"); + assert.equal( + combineQuery( + { + string: "string", + int: 123, + float: 123.456, + boolean1: true, + boolean2: false, + boolean3: "something truthy", + array: ["one", "two", "three", "a space"], + }, + types + ), + "string=string&int=123&float=123.456&boolean1=true&boolean2=false&boolean3=true&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..a0868f30 --- /dev/null +++ b/web/query-string.js @@ -0,0 +1,74 @@ +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; + 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; + } + } + return urlParts.join("&"); +} From ef7a64bdf46434f94d78d8811af363621b7b3fd5 Mon Sep 17 00:00:00 2001 From: Matt Godbolt Date: Sat, 1 Jul 2023 11:15:59 +0100 Subject: [PATCH 2/3] Further work in progress --- main.js | 195 +++++++++++----------------- tests/unit/web/test-query-string.js | 18 ++- web/query-string.js | 6 + 3 files changed, 98 insertions(+), 121 deletions(-) diff --git a/main.js b/main.js index a90171b4..4f5b81f2 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,116 +43,84 @@ 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) { - 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"); +})(); +let parsedQuery = parseQuery(queryString, queryTypeMap); +window.parsedQuery = parsedQuery; // DO NOT COMMIT +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 = {}; +let cpuMultiplier = 1; +let fastAsPossible = false; +let pauseEmu = false; +let stepEmuWhenPaused = false; +let econet = null; const printerPort = { outputStrobe: function (level, output) { @@ -207,7 +174,7 @@ const emulationConfig = { }; const config = new Config(function (changed) { - parsedQuery = _.extend(parsedQuery, changed); + parsedQuery = { ...parsedQuery, ...changed }; if ( changed.model || changed.coProcessor !== undefined || @@ -271,10 +238,7 @@ if (parsedQuery.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) { @@ -283,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 @@ -833,14 +797,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); } @@ -1550,8 +1507,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/tests/unit/web/test-query-string.js b/tests/unit/web/test-query-string.js index 2725d8e7..c12eb462 100644 --- a/tests/unit/web/test-query-string.js +++ b/tests/unit/web/test-query-string.js @@ -50,6 +50,16 @@ describe("Query parser tests", () => { 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), {}); }); }); @@ -76,21 +86,25 @@ describe("Query combiner tests", () => { 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: "string", + 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=string&int=123&float=123.456&boolean1=true&boolean2=false&boolean3=true&array=one&array=two&array=three&array=a%20space" + "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 index a0868f30..38182d90 100644 --- a/web/query-string.js +++ b/web/query-string.js @@ -36,6 +36,9 @@ export function parseQuery(queryString, argTypes) { case "bool": parsedQuery[key] = val === "true"; break; + case "boolIfPresent": + parsedQuery[key] = val !== "false"; + break; default: throw new Error(`Unknown arg type ${argTypes.get(key)}`); } @@ -68,6 +71,9 @@ export function combineQuery(parsedQuery, argTypes) { case "bool": urlParts.push(`${encodeURIComponent(key)}=${val ? "true" : "false"}`); break; + case "boolIfPresent": + if (val) urlParts.push(`${encodeURIComponent(key)}`); + break; } } return urlParts.join("&"); From 80a1dda3dfe177f8b1e9ca5ba4ae170fbf8bab08 Mon Sep 17 00:00:00 2001 From: Matt Godbolt Date: Sat, 1 Jul 2023 11:44:37 +0100 Subject: [PATCH 3/3] More self review --- main.js | 16 ++++++---------- web/query-string.js | 2 ++ 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/main.js b/main.js index 4f5b81f2..94db765d 100644 --- a/main.js +++ b/main.js @@ -52,7 +52,7 @@ const queryTypeMap = (() => { map.set("autoboot", "boolIfPresent"); map.set("autochain", "boolIfPresent"); map.set("autorun", "boolIfPresent"); - map.set("fasttape", "boolIfPresent"); + map.set("fastTape", "boolIfPresent"); map.set("noseek", "boolIfPresent"); map.set("audiofilterfreq", "float"); map.set("audiofilterq", "float"); @@ -60,9 +60,10 @@ const queryTypeMap = (() => { map.set("rom", "array"); map.set("glEnabled", "bool"); map.set("fakeVideo", "boolIfPresent"); + map.set("cpuMultiplier", "float"); + return map; })(); let parsedQuery = parseQuery(queryString, queryTypeMap); -window.parsedQuery = parsedQuery; // DO NOT COMMIT for (const key of Object.keys(parsedQuery)) { const val = parsedQuery[key]; if (key.toUpperCase().startsWith("KEY.")) { @@ -116,7 +117,7 @@ const stationId = parsedQuery.stationId === undefined ? 101 : parsedQuery.statio let frameSkip = parsedQuery.frameSkip || 0; const emuKeyHandlers = {}; -let cpuMultiplier = 1; +const cpuMultiplier = parsedQuery.cpuMultiplier || 1; let fastAsPossible = false; let pauseEmu = false; let stepEmuWhenPaused = false; @@ -231,8 +232,7 @@ 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; @@ -667,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(); }); diff --git a/web/query-string.js b/web/query-string.js index 38182d90..463ee8a6 100644 --- a/web/query-string.js +++ b/web/query-string.js @@ -74,6 +74,8 @@ export function combineQuery(parsedQuery, argTypes) { case "boolIfPresent": if (val) urlParts.push(`${encodeURIComponent(key)}`); break; + default: + throw new Error(`Unknown arg type ${argTypes.get(key)}`); } } return urlParts.join("&");