From 85b9c2459c4a53e6e817a29b3b31590a06a57bd1 Mon Sep 17 00:00:00 2001 From: Adrian Wannenmacher Date: Thu, 12 Feb 2026 01:01:03 +0100 Subject: [PATCH] switch from JSON to structural cloning There are two reasons for this: 1. I've reconsidered my original plan to store the past games in localStorage, because that would make it difficult to display them in historical order, and would necessitate more complex logic for updating and removing sessions. 2. I've been unhappy with how I did the testing of the serialization and deserialization logic. So I redid it, and now I'm satisfied with it. I've noticed that the testing methodology for the invalid fromStruct method tests is not fully sound. If a check is accidentally removed that test would not detect that, as long as it is not the very last. That is because then the next error triggers. Therefore that will need to be revisited. --- models/game.js | 74 +++++--- models/game.test.js | 329 +++++++++++++++++++----------------- models/round.js | 120 +++++++------ models/round.test.js | 221 ++++++++++++++++-------- models/round_result.js | 48 ++++-- models/round_result.test.js | 100 +++++++---- models/session.js | 115 ++++++++----- models/session.test.js | 292 ++++++++++++++++++-------------- 8 files changed, 788 insertions(+), 511 deletions(-) diff --git a/models/game.js b/models/game.js index f42526d..8d62dba 100644 --- a/models/game.js +++ b/models/game.js @@ -53,29 +53,14 @@ export default class Game extends EventTarget { if (typeof value === "number") this.#goal = value; + if (this.#goal < 1) + throw new RangeError("goal must be at least 1"); + this.#currentRound = new Round(this.#goal, this.#goal); this.#currentRound.addEventListener( Round.winEvent, this.#boundRoundFinishedHandler); } else if (typeof value === "object") { - if (!("goal" in value)) - throw new TypeError("missing goal in deserialization object"); - if (typeof value.goal !== "number") - throw new TypeError("goal in deserialization object must be number"); - this.#goal = value.goal; - - if (!("rounds" in value)) - throw new TypeError("missing rounds in deserialization object"); - if (!Array.isArray(value.rounds)) - throw new TypeError("rounds in deserialization object must be array"); - for (let r of value.rounds) - this.#rounds.push(new RoundResult(r)); - - if (!("currentRound" in value)) - throw new TypeError("missing currentRound in deserialization object"); - if (this.result.winner === null) - this.#currentRound = new Round(value.currentRound); - else if (value.currentRound !== null) - throw new TypeError("currentRound in finished game must be null"); + this.#fromStruct(value); } else { throw new TypeError("unknown form of Game constructor"); } @@ -151,12 +136,55 @@ export default class Game extends EventTarget { #boundRoundFinishedHandler = this.#handleRoundFinished.bind(this); - /** Export needed data for JSON serialization. */ - toJSON() { + /** Export the data of this `Game` as a plain JS object with fields. + * + * The internals of the returned object are not stabilized, even if they are + * visible. It should be treated as opaque. + * + * There are only two stabile uses of the object: + * 1. It can be passed to the `Game` constructor as a single argument. The + * constructor will then create a behaviourally identical instance to the + * one from which the object was created. This is guaranteed to be + * backwards compatible, i.e. a revised version of this class can still + * use the objects created by an older version. + * 2. It can be stored using IndexedDB. + */ + toStruct() { return { goal: this.#goal, - rounds: this.#rounds, - currentRound: this.#currentRound, + rounds: this.#rounds.map((r) => r.toStruct()), + currentRound: + this.#currentRound !== null ? this.#currentRound.toStruct() : null, }; } + + /** Read in an object created by `Game.toStruct` */ + #fromStruct(value) { + if (typeof value !== "object") + throw new TypeError("struct must be an object"); + + if (typeof value.goal !== "number") + throw new TypeError("struct must contain goal as number"); + if (!Number.isInteger(value.goal) || value.goal < 1) + throw new RangeError("struct must contain goal >= 1 as integer"); + this.#goal = value.goal; + + if (!("rounds" in value)) + throw new TypeError("struct must contain rounds"); + if (!Array.isArray(value.rounds)) + throw new TypeError("struct must contain rounds as array"); + this.#rounds = value.rounds.map((r) => new RoundResult(r)); + + if (typeof value.currentRound !== "object") + throw new TypeError("struct must contain currentRound as object"); + if (this.result.winner === null) { + if (value.currentRound === null) + throw new TypeError( + "struct of ongoing game must contain current round"); + else + this.#currentRound = new Round(value.currentRound); + } else if (value.currentRound !== null) + throw new TypeError( + "struct of finished game must not contain current round"); + } } diff --git a/models/game.test.js b/models/game.test.js index 8f7bc3c..43ff67a 100644 --- a/models/game.test.js +++ b/models/game.test.js @@ -23,6 +23,10 @@ export default function() { ); }); + QUnit.test("low goal", function(assert) { + assert.throws(function() { new Game(0); }, "goal must be 1 or higher"); + }); + QUnit.test("higher goal", function(assert) { let game = new Game(15); assert.strictEqual(game.rounds.length, 0, "no past rounds"); @@ -45,10 +49,8 @@ export default function() { game.currentRound.winner = Team.We; assert.equal(game.rounds.length, 1, "one round played"); - assert.deepEqual( - game.rounds[0].toJSON(), - { points: 2, winner: Team.We}, - "first round correct"); + assert.strictEqual(game.rounds[0].points, 2, "first round points"); + assert.strictEqual(game.rounds[0].winner, Team.We, "first round winner"); assert.notStrictEqual(game.currentRound, null, "current round there"); assert.false(game.currentRound.decided, "current round is not decided"); assert.deepEqual( @@ -70,10 +72,9 @@ export default function() { game.currentRound.winner = Team.They; assert.equal(game.rounds.length, 2, "two round played"); - assert.deepEqual( - game.rounds[1].toJSON(), - { points: 3, winner: Team.They}, - "second round correct"); + assert.strictEqual(game.rounds[1].points, 3, "second round points"); + assert.strictEqual( + game.rounds[1].winner, Team.They, "second round winner"); assert.notStrictEqual(game.currentRound, null, "current round there"); assert.false(game.currentRound.decided, "current round is not decided"); assert.deepEqual( @@ -183,157 +184,6 @@ export default function() { ); }); - QUnit.test("serialization - unfinished", function(assert) { - let game = new Game(); - game.currentRound.winner = Team.We; - game.currentRound.raise(Team.They); - game.currentRound.winner = Team.They; - game.currentRound.raise(Team.We); - - let json = game.toJSON(); - json.currentRound = json.currentRound.toJSON(); - for (let i = 0; i < json.rounds.length; i++) - json.rounds[i] = json.rounds[i].toJSON(); - - assert.deepEqual( - json, - { - goal: 11, - rounds: [ - { points: 2, winner: Team.We }, - { points: 3, winner: Team.They }, - ], - currentRound: { - points: 3, - raisedLast: Team.We, - winner: null, - weLimit: 9, - theyLimit: 8, - }, - }, - "serialized data" - ); - }); - - QUnit.test("serialization - finished", function(assert) { - let game = new Game(3); - game.currentRound.winner = Team.We; - game.currentRound.raise(Team.They); - game.currentRound.winner = Team.They; - - let json = game.toJSON(); - for (let i = 0; i < json.rounds.length; i++) - json.rounds[i] = json.rounds[i].toJSON(); - - assert.deepEqual( - json, - { - goal: 3, - rounds: [ - { points: 2, winner: Team.We }, - { points: 3, winner: Team.They }, - ], - currentRound: null, - }, - "serialized data" - ); - }); - - QUnit.test("deserialize - unfinished", function(assert) { - let currentRound = new Round(2, 3); - currentRound.raise(Team.They); - - let game = new Game({ - goal: 3, - rounds: [{ winner: Team.We, points: 2 }], - currentRound: currentRound.toJSON(), - }); - - assert.strictEqual(game.goal, 3, "goal"); - assert.strictEqual(game.rounds.length, 1, "one round played"); - assert.deepEqual( - game.rounds[0].toJSON(), - { winner: Team.We, points: 2 }, - "correct past round"); - assert.deepEqual( - game.currentRound.toJSON(), - currentRound.toJSON(), - "correct current round"); - assert.deepEqual( - game.result, - { - winner: null, - points: 0, - ourPoints: 2, - theirPoints: 0, - }, - "intermediate results"); - }); - - QUnit.test("deserialize - finished", function(assert) { - let game = new Game({ - goal: 3, - rounds: [{ winner: Team.They, points: 3 }], - currentRound: null, - }); - - assert.strictEqual(game.goal, 3, "goal"); - assert.strictEqual(game.rounds.length, 1, "one round played"); - assert.deepEqual( - game.rounds[0].toJSON(), - { winner: Team.They, points: 3 }, - "correct past round"); - assert.strictEqual(game.currentRound, null, "no current round"); - assert.deepEqual( - game.result, - { - winner: Team.They, - points: 2, - ourPoints: 0, - theirPoints: 3, - }, - "final results"); - }); - - QUnit.test("deserialize - invalid", function(assert) { - let deso = {}; - assert.throws(function() { new Game(deso); }, "no goal"); - - deso.goal = "5"; - assert.throws(function() { new Game(deso); }, "string goal"); - - deso.goal = 5; - assert.throws(function() { new Game(deso); }, "no rounds"); - - deso.rounds = ["nonono"]; - assert.throws(function() { new Game(deso); }, "string rounds"); - - deso.rounds = []; - assert.throws(function() { new Game(deso); }, "no currentRound"); - - deso.currentRound = null; - assert.throws(function() { new Game(deso); }, "missing currentRound"); - - deso.currentRound = "nonono"; - assert.throws(function() { new Game(deso); }, "broken currentRound"); - - deso.rounds = [{ winner: Team.We, points: 5 }]; - deso.currentRound = { - points: 2, - raisedLast: Team.They, - winner: null, - weLimit: 2, - theyLimit: 5}; - assert.throws(function() { new Game(deso); }, "unneeded currentRound"); - - deso.goal = 11; - new Game(deso); - - deso.goal = 5; - deso.currentRound = null; - new Game(deso); - }); - QUnit.test("finished event", function(assert) { let game = new Game(2); game.addEventListener(Game.finishedEvent, function() { @@ -342,5 +192,166 @@ export default function() { game.currentRound.winner = Team.They; assert.verifySteps(["event"], "event was triggered"); }); + + QUnit.test("toStruct - unfinished", function(assert) { + let game = new Game(); + game.currentRound.winner = Team.We; + game.currentRound.raise(Team.They); + game.currentRound.winner = Team.They; + game.currentRound.raise(Team.We); + let struct = game.toStruct(); + + let expected = { + goal: 11, + rounds: [ + { points: 2, winner: Team.We }, + { points: 3, winner: Team.They }, + ], + currentRound: { + points: 3, + raisedLast: Team.We, + winner: null, + ourLimit: 9, + theirLimit: 8 + }, + }; + + assert.deepEqual(struct, expected, "successfull structurizing"); + }); + + QUnit.test("toStruct - finished", function(assert) { + let game = new Game(3); + game.currentRound.winner = Team.We; + game.currentRound.raise(Team.They); + game.currentRound.winner = Team.They; + let struct = game.toStruct(); + + let expected = { + goal: 3, + rounds: [ + { points: 2, winner: Team.We }, + { points: 3, winner: Team.They }, + ], + currentRound: null, + }; + + assert.deepEqual(struct, expected, "successfull structurizing"); + }); + + QUnit.test("fromStruct - current", function(assert) { + let orig = new Game(4); + orig.currentRound.raise(Team.We); + + let copy = new Game(orig.toStruct()); + assert.strictEqual(copy.goal, orig.goal, "goals match"); + assert.strictEqual( + copy.currentRound.points, + orig.currentRound.points, + "current points match"); + assert.strictEqual( + copy.rounds.length, orig.rounds.length, "rounds match"); + + orig.currentRound.winner = Team.We; + copy = new Game(orig.toStruct()); + assert.strictEqual( + copy.rounds.length, orig.rounds.length, "rounds match"); + assert.deepEqual( + copy.rounds[0].toStruct(), orig.rounds[0].toStruct(), "round matches"); + + orig.currentRound.winner = Team.We; + copy = new Game(orig.toStruct()); + assert.deepEqual(copy.result, orig.result, "results match"); + }); + + QUnit.test("fromStruct - invalid", function(assert) { + let struct = {}; + function doIt(message) { + assert.throws(function() { new Game(struct); }, message); + } + + doIt("no goal"); + struct.goal = "3"; + doIt("string goal"); + struct.goal = Math.PI; + doIt("non-int goal"); + struct.goal = 0; + doIt("small goal"); + struct.goal = 3; + + doIt("no rounds"); + struct.rounds = "nope"; + doIt("rounds not array"); + struct.rounds = ["nope", "again"]; + doIt("string array rounds"); + struct.rounds = []; + + doIt("no currentRound"); + struct.currentRound = "nope"; + doIt("string currentround"); + struct.currentRound = null; + doIt("missing currentRound"); + struct.currentRound = new Round().toStruct(); + new Game(struct); + + struct.rounds = [ new RoundResult(3, Team.They).toStruct() ]; + doIt("unneeded currentRound"); + struct.currentRound = null; + new Game(struct); + }); + + // Data Import Tests + // ================= + // + // The tests named "fromStruct - vXX - XXXXX" are there to ensure that + // future versions of the `Game` class still can correctly read in the + // structural data exported by earlier versions. This is needed to ensure + // that the data remains usable. + // + // These tests work by importing an old structural object, and then + // exporting a new one. The new one should match with how the current + // implementation would represent the same state. + // + // Therefore you should not modify the `struct` variables. Instead adjust + // the `expected` variable, to make sure the reexported data matches what + // is now correct. + + QUnit.test("fromStruct - v1 - unfinished", function(assert) { + let past = new RoundResult(2, Team.We); + let current = new Round(2, 3); + current.raise(Team.They); + + let struct = { + goal: 3, + rounds: [ past.toStruct() ], + currentRound: current.toStruct(), + }; + let game = new Game(struct); + + let expected = { + goal: 3, + rounds: [ past.toStruct() ], + currentRound: current.toStruct(), + }; + assert.deepEqual(game.toStruct(), expected, "reexport matches"); + }); + + QUnit.test("fromStruct - v1 - finished", function(assert) { + let round1 = new RoundResult(2, Team.We); + let round2 = new RoundResult(3, Team.They); + + let struct = { + goal: 3, + rounds: [ round1.toStruct(), round2.toStruct() ], + currentRound: null, + }; + let game = new Game(struct); + + let expected = { + goal: 3, + rounds: [ round1.toStruct(), round2.toStruct() ], + currentRound: null, + }; + assert.deepEqual(game.toStruct(), expected, "reexport matches"); + }); }); } diff --git a/models/round.js b/models/round.js index 7742858..fcff15b 100644 --- a/models/round.js +++ b/models/round.js @@ -8,6 +8,15 @@ export const Team = Object.freeze({ We: 1, /** The "they" team, from the perspective of the score keeper. */ They: 2, + + /** Check if the passed value is a team. + * + * @param {Team} team The team to check. + * @returns Whether the value is a team. + */ + isTeam(team) { + return (team === Team.We) || (team === Team.They); + } }); /** A single round of watten. @@ -27,9 +36,15 @@ export class Round extends EventTarget { /** The event triggered when the round is won. */ static winEvent= "roundWon"; - /** The maximum the "we" team may raise to. */ + /** The maximum the "we" team may raise to. + * + * @todo rename to ourLimit + */ #weLimit = 11; - /** The maximum the "they" team may raise to. */ + /** The maximum the "they" team may raise to. + * + * @todo rename to theirLimit + */ #theyLimit = 11; constructor(value, theyLimit) { @@ -44,48 +59,7 @@ export class Round extends EventTarget { this.#weLimit = value; this.#theyLimit = theyLimit; } else if (typeof value === "object" && theyLimit === undefined) { - if (!("points" in value)) - throw new TypeError("missing points in deserialization object"); - if (typeof value.points !== "number") - throw new TypeError("points in deserialization object must be number"); - this.#points = value.points; - - if (!("raisedLast" in value)) - throw new TypeError("missing raisedLast in deserialization object"); - if (value.raisedLast !== Team.We - && value.raisedLast !== Team.They - && value.raisedLast !== null) - { - throw new TypeError( - "team raising last must be an actual team in deserialization object" - ); - } - this.#raisedLast = value.raisedLast; - - if (!("winner" in value)) - throw new TypeError("missing winner in deserialization object"); - if (value.winner !== Team.We - && value.winner !== Team.They - && value.winner !== null) - { - throw new TypeError( - "winning team must be an actual team in deserialization object"); - } - this.#winner = value.winner; - - if (!("weLimit" in value)) - throw new TypeError("missing weLimit in deserialization object"); - if (typeof value.weLimit !== "number") - throw new TypeError( - "weLimit in deserialization object must be a number"); - this.#weLimit = value.weLimit; - - if (!("theyLimit" in value)) - throw new TypeError("missing theyLimit in deserialization object"); - if (typeof value.theyLimit !== "number") - throw new TypeError( - "theyLimit in deserialization object must be a number"); - this.#theyLimit = value.theyLimit; + this.#fromStruct(value); } else { throw new TypeError("unknown form for Round constructor"); } @@ -178,14 +152,62 @@ export class Round extends EventTarget { this.#points += 1; } - /** Export needed data for JSON serialization. */ - toJSON() { + /** Export the data of this `Round` as a plain JS object with fields. + * + * The internals of the returned object are not stabilized, even if they are + * visible. It should be treated as opaque. + * + * There are only two stabile uses of the object: + * 1. It can be passed to the `Round` constructor as a single argument. The + * constructor will then create a behaviourally identical instance to the + * one from which the object was created. This is guaranteed to be + * backwards compatible, i.e. a revised version of this class can still + * use the objects created by an older version. + * 2. It can be stored using IndexedDB. + */ + toStruct() { return { points: this.#points, raisedLast: this.#raisedLast, winner: this.#winner, - weLimit: this.#weLimit, - theyLimit: this.#theyLimit, - }; + ourLimit: this.#weLimit, + theirLimit: this.#theyLimit, + } + } + + /** Read in an object created by `Round.toStruct` */ + #fromStruct(value) { + if (typeof value !== "object") + throw new TypeError("struct must be an object"); + + if (typeof value.points !== "number") + throw new TypeError("struct must contain points as number"); + if (!Number.isInteger(value.points) || value.points < 2) + throw new RangeError("struct must contain points >= 2 as integer"); + this.#points = value.points; + + if (!("raisedLast" in value)) + throw new TypeError("struct must contain raisedLast"); + if (value.raisedLast !== null && !Team.isTeam(value.raisedLast)) + throw new TypeError("struct must contain raisedLast as Team or null"); + this.#raisedLast = value.raisedLast; + + if (!("winner" in value)) + throw new TypeError("struct must contain winner"); + if (value.winner !== null && !Team.isTeam(value.winner)) + throw new TypeError("struct must contain winner as Team or null"); + this.#winner = value.winner; + + if (typeof value.ourLimit !== "number") + throw new TypeError("struct must contain ourLimit as number"); + if (!Number.isInteger(value.ourLimit) || value.ourLimit < 2) + throw new RangeError("struct must contain ourLimit >= 2 as integer"); + this.#weLimit = value.ourLimit; + + if (typeof value.theirLimit !== "number") + throw new TypeError("struct must contain theirLimit as number"); + if (!Number.isInteger(value.theirLimit) || value.theirLimit < 2) + throw new RangeError("struct must contain theirLimit >= 2 as integer"); + this.#theyLimit = value.theirLimit; } } diff --git a/models/round.test.js b/models/round.test.js index f0f0308..53baabc 100644 --- a/models/round.test.js +++ b/models/round.test.js @@ -92,73 +92,6 @@ export default function() { assert.false(round.canRaise(Team.They), "winner cannot raise"); }); - QUnit.test("JSON serialization", function(assert) { - let round = new Round(); - assert.deepEqual( - round.toJSON(), - { - points: 2, - raisedLast: null, - winner: null, - weLimit: 11, - theyLimit: 11, - }, - "correct field override" - ); - }); - - QUnit.test("JSON deserialization", function(assert) { - let round = new Round({ - points: 7, - raisedLast: Team.They, - winner: null, - weLimit: 6, - theyLimit: 11, - }); - assert.strictEqual(round.points, 7, "points correct"); - assert.false(round.canRaise(Team.They), "raiser cannot raise"); - assert.true(round.canRaise(Team.We), "others can raise"); - assert.false(round.decided, "noone won yet"); - - round.raise(Team.We); - assert.strictEqual(round.winner, Team.They, "limits enforcement"); - }); - - QUnit.test("invalid JSON deserialization", function(assert) { - let deso = {}; - assert.throws(function() { new Round(deso) }, "no points"); - - deso.points = "2"; - assert.throws(function() { new Round(deso) }, "string points"); - - deso.points = 2; - assert.throws(function() { new Round(deso) }, "no raisedLast"); - - deso.raisedLast = "Team.We"; - assert.throws(function() { new Round(deso) }, "string raisedLast"); - - deso.raisedLast = Team.We; - assert.throws(function() { new Round(deso) }, "no winner"); - - deso.winner = "Team.They"; - assert.throws(function() { new Round(deso) }, "string winner"); - - deso.winner = Team.They; - assert.throws(function() { new Round(deso) }, "no weLimit"); - - deso.weLimit = "11"; - assert.throws(function() { new Round(deso) }, "string weLimit"); - - deso.weLimit = 11; - assert.throws(function() { new Round(deso) }, "no theyLimit"); - - deso.theyLimit = "11"; - assert.throws(function() { new Round(deso) }, "string theyLimit"); - - deso.theyLimit = 11; - new Round(deso); - }); - QUnit.test("victory causes event", function(assert) { let round = new Round(); round.addEventListener(Round.winEvent, function() { @@ -167,5 +100,159 @@ export default function() { round.winner = Team.We; assert.verifySteps(["event"], "event was triggered"); }); + + QUnit.test("toStruct - unfinished", function(assert) { + let round = new Round(); + let struct = round.toStruct(); + + let expected = { + points: 2, + raisedLast: null, + winner: null, + ourLimit: 11, + theirLimit: 11, + }; + + assert.deepEqual(struct, expected, "successfull structurizing"); + }); + + QUnit.test("toStruct - finished", function(assert) { + let round = new Round(4, 3); + round.raise(Team.We); + round.raise(Team.They); + let struct = round.toStruct(); + + let expected = { + points: 3, + raisedLast: Team.We, + winner: Team.We, + ourLimit: 4, + theirLimit: 3, + }; + + assert.deepEqual(struct, expected, "successfull structurizing"); + }); + + QUnit.test("fromStruct - current", function(assert) { + let orig = new Round(3, 3); + orig.raise(Team.We); + + let copy = new Round(orig.toStruct()); + assert.strictEqual(copy.points, orig.points, "points match"); + assert.strictEqual( + copy.canRaise(Team.We), + orig.canRaise(Team.We), + "can we raise matches"); + assert.strictEqual( + copy.canRaise(Team.They), + orig.canRaise(Team.They), + "can they raise matches"); + + orig.winner = Team.They; + copy = new Round(orig.toStruct()); + assert.strictEqual(copy.winner, orig.winner, "winners match"); + }); + + QUnit.test("fromStruct - invalid", function(assert) { + let struct = {}; + function doIt(message) { + assert.throws(function() { new Round(struct); }, message); + } + + doIt("no points"); + struct.points = "2"; + doIt("string points"); + struct.points = 1.5; + doIt("non-int points"); + struct.points = 1; + doIt("small points"); + struct.points = 2; + + doIt("no raisedLast"); + struct.raisedLast = "we"; + doIt("string raisedLast"); + struct.raisedLast = -1; + doIt("raisedLast not actual team"); + struct.raisedLast = null; + + doIt("no winner"); + struct.winner = "they"; + doIt("string winner"); + struct.winner = -1; + doIt("winner not actual team"); + struct.winner = null; + + doIt("no ourLimit"); + struct.ourLimit = "11"; + doIt("string ourLimit"); + struct.ourLimit = 1; + doIt("small ourLimit"); + struct.ourLimit = 11; + + doIt("no theirLimit"); + struct.theirLimit = "11"; + doIt("string theirLimit"); + struct.theirLimit = 1; + doIt("small theirLimit"); + struct.theirLimit = 11; + + new Round(struct); + }); + + // Data Import Tests + // ================= + // + // The tests named "fromStruct - vXX - XXXXX" are there to ensure that + // future versions of the `Round` class still can correctly read in the + // structural data exported by earlier versions. This is needed to ensure + // that the data remains usable. + // + // These tests work by importing an old structural object, and then + // exporting a new one. The new one should match with how the current + // implementation would represent the same state. + // + // Therefore you should not modify the `struct` variables. Instead adjust + // the `expected` variable, to make sure the reexported data matches what + // is now correct. + + QUnit.test("fromStruct - v1 - unfinished", function(assert) { + let struct = { + points: 2, + raisedLast: null, + winner: null, + ourLimit: 11, + theirLimit: 11, + }; + let round = new Round(struct); + + let expected = { + points: 2, + raisedLast: null, + winner: null, + ourLimit: 11, + theirLimit: 11, + }; + assert.deepEqual(round.toStruct(), expected, "reexport matches"); + }); + + QUnit.test("fromStruct - v1 - finished", function(assert) { + let struct = { + points: 3, + raisedLast: Team.We, + winner: Team.We, + ourLimit: 4, + theirLimit: 3 + }; + let round = new Round(struct); + + let expected = { + points: 3, + raisedLast: Team.We, + winner: Team.We, + ourLimit: 4, + theirLimit: 3 + }; + assert.deepEqual(round.toStruct(), expected, "reexport matches"); + }); }); } diff --git a/models/round_result.js b/models/round_result.js index ea010ab..c27b280 100644 --- a/models/round_result.js +++ b/models/round_result.js @@ -19,17 +19,7 @@ export default class RoundResult { this.#points = value; this.#winner = winner; } else if (typeof value === "object" && winner === undefined) { - if (!("points" in value)) - throw new TypeError("missing points in deserialization object"); - if (typeof value.points !== "number") - throw new TypeEror("points in deserialization object must be number"); - this.#points = value.points; - - if (!("winner" in value)) - throw new TypeError("missing winner in deserialization object"); - if (value.winner !== Team.We && value.winner !== Team.They) - throw new TypeError("winner in deserialization object not real team"); - this.#winner = value.winner; + this.#fromStruct(value); } else { throw new TypeError("unknown form for RoundResult constructor"); } @@ -48,11 +38,41 @@ export default class RoundResult { return this.#winner; } - /** Export needed data for JSON serialization. */ - toJSON() { + /** Export the data of this `RoundResult` as a plain JS object with fields. + * + * The internals of the returned object are not stabilized, even if they are + * visible. It should be treated as opaque. + * + * There are only two stabile uses of the object: + * 1. It can be passed to the `RoundResult` constructor as a single argument. + * The constructor will then create a behaviourally identical instance to + * the one from which the object was created. This is guaranteed to be + * backwards compatible, i.e. a revised version of this class can still + * use the objects created by an older version. + * 2. It can be stored using IndexedDB. + */ + toStruct() { return { points: this.#points, winner: this.#winner, - }; + } + } + + /** Read in an object created by `RoundResult.toStruct` */ + #fromStruct(value) { + if (typeof value !== "object") + throw new TypeError("struct must be an object"); + + if (typeof value.points !== "number") + throw new TypeError("struct must contain points as number"); + if (!Number.isInteger(value.points) || value.points < 2) + throw new RangeError("struct must contain points >= 2 as integer"); + this.#points = value.points; + + if (!("winner" in value)) + throw new TypeError("struct must contain winner"); + if (!Team.isTeam(value.winner)) + throw new TypeError("struct must contain winner as Team"); + this.#winner = value.winner; } } diff --git a/models/round_result.test.js b/models/round_result.test.js index bb9b641..6e304ba 100644 --- a/models/round_result.test.js +++ b/models/round_result.test.js @@ -11,42 +11,78 @@ export default function() { assert.strictEqual(rr.winner, Team.We, "correct winner"); }); - QUnit.test("serialization", function(assert) { - let rr = new RoundResult(3, Team.They); - assert.deepEqual( - rr.toJSON(), - { - points: 3, - winner: Team.They, - }, - "correct serialization object", - ); + QUnit.test("toStruct", function(assert) { + let rr = new RoundResult(2, Team.They); + let struct = rr.toStruct(); + + let expected = { + points: 2, + winner: Team.They, + }; + + assert.deepEqual(struct, expected, "successfull structurizing"); }); - QUnit.test("deserialization", function(assert) { - let rr = new RoundResult({ + QUnit.test("fromStruct - current", function(assert) { + let orig = new RoundResult(3, Team.We); + let copy = new RoundResult(orig.toStruct()); + assert.strictEqual(copy.points, orig.points, "points match"); + assert.strictEqual(copy.winner, orig.winner, "winners match"); + }); + + QUnit.test("fromStruct - invalid", function(assert) { + let struct = {}; + function doIt(message) { + assert.throws(function() { new Round(struct); }, message); + } + + doIt("no points"); + struct.points = "4"; + doIt("string points"); + struct.points = 4.1; + doIt("non-int points"); + struct.points = 1; + doIt("small points"); + struct.points = 4; + + doIt("no winner"); + struct.winner = "they"; + doIt("string winner"); + struct.winner = -1; + doIt("non-team winner"); + struct.winner = Team.They; + + new RoundResult(struct); + }); + + // Data Import Tests + // ================= + // + // The tests named "fromStruct - vXX - XXXXX" are there to ensure that + // future versions of the `RoundResult` class still can correctly read in + // the structural data exported by earlier versions. This is needed to + // ensure that the data remains usable. + // + // These tests work by importing an old structural object, and then + // exporting a new one. The new one should match with how the current + // implementation would represent the same state. + // + // Therefore you should not modify the `struct` variables. Instead adjust + // the `expected` variable, to make sure the reexported data matches what + // is now correct. + + QUnit.test("fromStruct - v1", function(assert) { + let struct = { points: 4, - winner: Team.We, - }); - assert.strictEqual(rr.points, 4, "correct points"); - assert.strictEqual(rr.winner, Team.We, "correct winner"); - }); + winner: Team.They, + }; + let rr = new RoundResult(struct); - QUnit.test("invalid deserialization", function(assert) { - let deso = {}; - assert.throws(function() { new RoundResult(deso); }, "no points"); - - deso.points = "5"; - assert.throws(function() { new RoundResult(deso); }, "string points"); - - deso.points = 5; - assert.throws(function() { new RoundResult(deso); }, "no winner"); - - deso.winner = "Team.They"; - assert.throws(function() { new RoundResult(deso); }, "string winner"); - - deso.winner = Team.They; - new RoundResult(deso); + let expected = { + points: 4, + winner: Team.They, + }; + assert.deepEqual(rr.toStruct(), expected, "reexport matches"); }); }); } diff --git a/models/session.js b/models/session.js index 534a894..123d85e 100644 --- a/models/session.js +++ b/models/session.js @@ -8,7 +8,21 @@ export default class Session { * * Only applies to new games. */ - goal = 11; + #goal = 11; + + /** Get the goal for new games. */ + get goal() { + return this.#goal; + } + + /** Set the goal for new games. */ + set goal(value) { + if (typeof value !== "number") + throw new TypeError("goal must be a number"); + if (!Number.isInteger(value) || value < 1) + throw new RangeError("goal must be integer >= 1"); + this.#goal = value; + } /** The name or members of the "we" team. */ ourTeam = ""; @@ -81,57 +95,70 @@ export default class Session { constructor(value) { if (value === undefined) { } else if (typeof value === "object") { - if (!("goal" in value)) - throw new TypeError("missing goal in deserialization object"); - if (typeof value.goal !== "number") - throw new TypeError("goal in deserialization object must be number"); - this.goal = value.goal; - - if (!("ourTeam" in value)) - throw new TypeError("missing ourTeam in deserialization object"); - if (typeof value.ourTeam !== "string") - throw new TypeError( - "ourTeam in deserialization object must be string"); - this.ourTeam = value.ourTeam; - - if (!("theirTeam" in value)) - throw new TypeError("missing theirTeam in deserialization object"); - if (typeof value.theirTeam !== "string") - throw new TypeError( - "theirTeam in deserialization object must be string"); - this.theirTeam = value.theirTeam; - - if (!("games" in value)) - throw new TypeError("missing games in deserialization object"); - if (!Array.isArray(value.games)) - throw new TypeError("games in deserialization object must be array"); - for (let g of value.games) { - let game = new Game (g); - if (game.result.winner === null) - throw new TypeError("past game cannot be unfinished"); - this.#games.push(game); - } - - if (!("currentGame" in value)) - throw new TypeError("missing currentGame in deserialization object"); - if (value.currentGame !== null) { - this.#currentGame = new Game(value.currentGame); - if (this.#currentGame.result.winner !== null) - throw new Error("currentGame cannot be finished"); - } + this.#fromStruct(value); } else { throw new TypeError("unknown form of Session constructor"); } } - /** Export needed data for JSON serialization. */ - toJSON() { + /** Export the data of this `Session` as a plain JS object with fields. + * + * The internals of the returned object are not stabilized, even if they are + * visible. It should be treated as opaque. + * + * There are only two stabile uses of the object: + * 1. It can be passed to the `Session` constructor as a single argument. The + * constructor will then create a behaviourally identical instance to the + * one from which the object was created. This is guaranteed to be + * backwards compatible, i.e. a revised version of this class can still + * use the objects created by an older version. + * 2. It can be stored using IndexedDB. + */ + toStruct() { return { - goal: this.goal, + goal: this.#goal, ourTeam: this.ourTeam, theirTeam: this.theirTeam, - games: this.#games, - currentGame: this.#currentGame, + games: this.#games.map((g) => g.toStruct()), + currentGame: + this.#currentGame !== null ? this.#currentGame.toStruct() : null, + } + } + + /** Read in an object created by `Session.toStruct` */ + #fromStruct(value) { + if (typeof value !== "object") + throw new TypeError("struct must be an object"); + + if (typeof value.goal !== "number") + throw new TypError("struct must contain goal as number"); + if (!Number.isInteger(value.goal) || value.goal < 1) + throw new RangeError("struct must contain goal >= 1 as integer"); + this.#goal = value.goal; + + if (typeof value.ourTeam !== "string") + throw new TypeError("struct must contain ourTeam as string"); + this.ourTeam = value.ourTeam; + + if (typeof value.theirTeam !== "string") + throw new TypeError("struct must contain theirTeam as string"); + this.theirTeam = value.theirTeam; + + if (!("games" in value)) + throw new TypeError("struct must contain games"); + if (!Array.isArray(value.games)) + throw new TypeError("struct must contain games as array"); + this.#games = value.games.map((g) => new Game(g)); + for (let g of this.#games) + if (g.result.winner === null) + throw new Error("past games must be finished"); + + if (typeof value.currentGame !== "object") + throw new TypeError("struct must contain currentGame as object"); + if (value.currentGame !== null) { + this.#currentGame = new Game(value.currentGame); + if (this.#currentGame.result.winner !== null) + throw new Error("currentGame in struct mustnot be finished"); } } } diff --git a/models/session.test.js b/models/session.test.js index 2f7c36c..b5954e6 100644 --- a/models/session.test.js +++ b/models/session.test.js @@ -19,6 +19,14 @@ export default function() { assert.strictEqual(session.theirTeam, "", "their team name"); }); + QUnit.test("set goal", function(assert) { + let session = new Session(); + assert.strictEqual(session.goal, 11, "initial goal"); + session.goal = 3; + assert.strictEqual(session.goal, 3, "changed goal"); + assert.throws(function() { session.goal = 0; }, "invalid goal"); + }); + QUnit.test("start game", function(assert) { let session = new Session(); session.anotherGame(); @@ -92,160 +100,198 @@ export default function() { "initial game still current"); }); - QUnit.test("serialization - new session", function(assert) { + QUnit.test("toStruct - new session", function(assert) { let session = new Session(); - let json = session.toJSON(); + let struct = session.toStruct(); - assert.deepEqual( - json, - { - goal: 11, - ourTeam: "", - theirTeam: "", - games: [], - currentGame: null, - }, - "correct serialization"); - }); - - QUnit.test("serialization - finished & unfinished game", function(assert) { - let session = new Session(); - session.anotherGame(); - session.currentGame.currentRound.winner = Team.We; - for ( - let i = 0; - session.currentGame !== null && i < session.currentGame.goal; - i += 2 - ) - session.currentGame.currentRound.winner = Team.They; - - session.goal = 15; - session.anotherGame(); - session.currentGame.currentRound.winner = Team.They; - for ( - let i = 0; - session.currentGame !== null && i < session.currentGame.goal - 2; - i += 2 - ) - session.currentGame.currentRound.winner = Team.We; - - session.goal = 5; - session.ourTeam = "This is us!"; - session.theirTeam = "This is them!"; - - let json = session.toJSON(); - json.games = []; - for (let i = 0; i < session.games.length; i++) - json.games.push(session.games[i].toJSON()); - json.currentGame = session.currentGame.toJSON(); - - assert.deepEqual( - json, - { - goal: 5, - ourTeam: "This is us!", - theirTeam: "This is them!", - games: [ - session.games[0].toJSON(), - ], - currentGame: session.currentGame.toJSON(), - }, - "correct serialization"); - assert.strictEqual(json.games[0].goal, 11, "first goal"); - assert.strictEqual(json.currentGame.goal, 15, "second goal"); - }); - - QUnit.test("deserialization - new session", function(assert) { - let game = new Game(); - let json = { + let expected = { goal: 11, ourTeam: "", theirTeam: "", games: [], - currentGame: game.toJSON(), + currentGame: null, }; - json.currentGame.currentRound = game.currentRound.toJSON(); - let session = new Session(json); - assert.strictEqual(session.goal, 11, "goal"); - assert.strictEqual(session.ourTeam, "", "our team name"); - assert.strictEqual(session.theirTeam, "", "their team name"); - assert.strictEqual(session.games.length, 0, "no past games"); - assert.deepEqual(session.currentGame.toJSON(), game.toJSON()); + assert.deepEqual(struct, expected, "successfull structurizing"); }); - QUnit.test("deserialization - un- and finished games", function(assert) { - let finished = new Game(2); - finished.currentRound.winner = Team.We; + QUnit.test("toStruct - finished & unfinished game", function(assert) { + let session = new Session(); + session.goal = 3; + session.anotherGame(); + session.currentGame.currentRound.raise(Team.We); + session.currentGame.currentRound.winner = Team.They; + session.anotherGame(); + session.currentGame.currentRound.winner = Team.We; + session.ourTeam = "This is us!"; + session.theirTeam = "This is them!"; + let struct = session.toStruct(); + let finished = new Game(3); + finished.currentRound.raise(Team.We); + finished.currentRound.winner = Team.They; let unfinished = new Game(3); - unfinished.currentRound.winner = Team.They; - - let json = { - goal: 4, + unfinished.currentRound.winner = Team.We; + let expected = { + goal: 3, ourTeam: "This is us!", theirTeam: "This is them!", - games: [finished], - currentGame: unfinished, + games: [ finished.toStruct() ], + currentGame: unfinished.toStruct() }; - let deso = JSON.parse(JSON.stringify(json)); - let session = new Session(deso); - assert.strictEqual(session.goal, 4, "goal"); - assert.strictEqual(session.ourTeam, "This is us!", "our team name"); - assert.strictEqual(session.theirTeam, "This is them!", "their team"); - assert.strictEqual(session.games.length, 1, "one past game"); - assert.deepEqual( - session.games[0].toJSON(), finished.toJSON(), "finished game"); - assert.notStrictEqual(session.currentGame, null, "unfinished game here"); - assert.deepEqual( - session.currentGame.toJSON(), unfinished.toJSON(), "unfinished game"); + assert.deepEqual(struct, expected, "successfull structurizing"); }); - QUnit.test("deserialization - invalid", function(assert) { - let deso = {}; - assert.throws(function() { new Session(deso); }, "no goal"); + QUnit.test("fromStruct - current", function(assert) { + let orig = new Session(); + orig.goal = 3; + orig.ourTeam = "This is us!"; + orig.theirTeam = "This is them!"; - deso.goal = "11"; - assert.throws(function() { new Session(deso); }, "string goal"); + let copy = new Session(orig.toStruct()); + assert.strictEqual(copy.goal, orig.goal, "goals match"); + assert.strictEqual(copy.ourTeam, orig.ourTeam, "our teams match"); + assert.strictEqual(copy.theirTeam, orig.theirTeam, "their teams match"); + assert.strictEqual( + copy.games.length, orig.games.length, "amount of past games"); + assert.strictEqual( + copy.currentGame, orig.currentGame, "no current games"); + assert.deepEqual(copy.result, orig.result, "results match"); - deso.goal = 11; - assert.throws(function() { new Session(deso); }, "no ourTeam"); + orig.anotherGame(); + orig.currentGame.currentRound.raise(Team.They); + orig.currentGame.currentRound.winner = Team.We; + orig.anotherGame(); + orig.currentGame.currentRound.winner = Team.They; - deso.ourTeam = 11; - assert.throws(function() { new Session(deso); }, "number ourTeam"); + copy = new Session(orig.toStruct()); + assert.strictEqual(copy.games.length, 1, "single past game"); + assert.strictEqual( + copy.games.length, orig.games.length, "amount of past games"); + assert.deepEqual( + copy.games[0].toStruct(), orig.games[0].toStruct(), "past game"); + assert.deepEqual( + copy.currentGame.toStruct(), + orig.currentGame.toStruct(), + "current game"); + assert.deepEqual(copy.result, orig.result, "results match"); + }); - deso.ourTeam = ""; - assert.throws(function() { new Session(deso); }, "no theirTeam"); + QUnit.test("fromStruct - invalid", function(assert) { + let struct = {}; + function doIt(message) { + assert.throws(function() { new Session(struct); }, message); + } - deso.theirTeam = 11; - assert.throws(function() { new Session(deso); }, "number theirTeam"); + let unfinished = new Game(3); + unfinished.currentRound.winner = Team.We; + let finished = new Game(3); + finished.currentRound.raise(Team.We); + finished.currentRound.winner = Team.They; - deso.theirTeam = ""; - assert.throws(function() { new Session(deso); }, "no games"); + doIt("no goal"); + struct.goal = "3"; + doIt("string goal"); + struct.goal = Math.PI; + doIt("non-int goal"); + struct.goal = 0; + doIt("small goal"); + struct.goal = 3; - deso.games = null; - assert.throws(function() { new Session(deso); }, "null games"); + doIt("no ourTeam"); + struct.ourTeam = 5; + doIt("number ourTeam"); + struct.ourTeam = ""; - deso.games = []; - assert.throws(function() { new Session(deso); }, "no currentGame"); + doIt("no theirTeam"); + struct.theirTeam = 6; + doIt("number theirTeam"); + struct.theirTeam = ""; - deso.currentGame = { + doIt("no games"); + struct.games = "nope"; + doIt("string games"); + struct.games = ["nope", "again"]; + doIt("string array games"); + struct.games = [unfinished.toStruct()]; + doIt("unfinished game in games"); + struct.games = [finished.toStruct()]; + + doIt("no currentGame"); + struct.currentGame = "nope"; + doIt("string currentGame"); + struct.currentGame = finished.toStruct(); + doIt("finished currentGame"); + struct.currentGame = unfinished.toStruct(); + + new Session(struct); + + struct.games = []; + struct.currentGame = null; + new Session(struct); + }); + + // Data Import Tests + // ================= + // + // The tests named "fromStruct - vXX - XXXXX" are there to ensure that + // future versions of the `Session` class still can correctly read in the + // structural data exported by earlier versions. This is needed to ensure + // that the data remains usable. + // + // These tests work by importing an old structural object, and then + // exporting a new one. The new one should match with how the current + // implementation would represent the same state. + // + // Therefore you should not modify the `struct` variables. Instead adjust + // the `expected` variable, to make sure the reexported data matches what + // is now correct. + + QUnit.test("fromStruct - v1 - new session", function(assert) { + let struct = { goal: 3, - rounds: [{ winner: Team.They, points: 3 }], - currentRound: null, + ourTeam: "", + theirTeam: "", + games: [], + currentGame: null, }; - assert.throws(function() { new Session(deso); }, "finished currentGame"); + let session = new Session(struct); - deso.currentGame = null; - new Session(deso); - - deso.games = [{ + let expected = { goal: 3, - rounds: [{ winner: Team.They, points: 2}], - currentRound: (new Round(3, 2)).toJSON(), - }]; - assert.throws(function() { new Session(deso); }, "unfinished past"); + ourTeam: "", + theirTeam: "", + games: [], + currentGame: null, + }; + assert.deepEqual(session.toStruct(), expected, "reexport matches"); + }); + + QUnit.test("fromStruct - v1 - finished & unfinished", function(assert) { + let finished = new Game(3); + finished.currentRound.raise(Team.We); + finished.currentRound.winner = Team.They; + let unfinished = new Game(3); + unfinished.currentRound.winner = Team.We; + + let struct = { + goal: 3, + ourTeam: "This is us!", + theirTeam: "This is them!", + games: [ finished.toStruct() ], + currentGame: unfinished.toStruct(), + }; + let session = new Session(struct); + + let expected = { + goal: 3, + ourTeam: "This is us!", + theirTeam: "This is them!", + games: [ finished.toStruct() ], + currentGame: unfinished.toStruct(), + }; + assert.deepEqual(session.toStruct(), expected, "reexport matches"); }); }); }