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"); }); }); }