Sped up the test suite with a parallel runner and a world cache

- run_tests.js discovers and runs each *_test.js in its own worker process
  (--jobs N to cap the pool, --file <name> for a single suite)
- GameState caches the deterministic terrain, city sites and road network by
  (seed, mapConfig, civ count), rebuilding only the mutable per-game state
- the snapshot ships mapConfig and the client rebuilds terrain from it, so the
  DOM tests can use the small map fixture and a non-default config renders right
- shortestPaths can stop once every target city is settled
This commit is contained in:
2026-09-18 21:25:02 +02:00
parent 825cff7fa5
commit 7ed6fe065f
14 changed files with 376 additions and 207 deletions
+4
View File
@@ -48,6 +48,10 @@ export class MapView {
} }
this.seed = -1; this.seed = -1;
// Signature of the terrain currently built, so a snapshot with the same
// seed and config does not rebuild the world.
this._terrainSignature = null;
this.mapConfig = null;
this.topology = null; this.topology = null;
this.tiles = {}; this.tiles = {};
this.terrainStats = null; this.terrainStats = null;
+1 -1
View File
@@ -13,7 +13,7 @@ export const entityMethods = {
}, },
applySnapshot(state) { applySnapshot(state) {
this.ensureTerrain(state.seed); this.ensureTerrain(state.seed, state.mapConfig || null);
this.setRoads(state.roads || []); this.setRoads(state.roads || []);
this.localCiv = state.viewer; this.localCiv = state.viewer;
this._applyTerritory(state.territory); this._applyTerritory(state.territory);
+11 -4
View File
@@ -18,16 +18,23 @@ import { OVERVIEW_BLOCK, OVERVIEW_FOG, CHUNK_SIZE, CHUNK_MARGIN, CHUNK_CACHE_LIM
import { overviewColour, blendColours } from "./utils.js"; import { overviewColour, blendColours } from "./utils.js";
export const terrainMethods = { export const terrainMethods = {
ensureTerrain(seed) { ensureTerrain(seed, mapConfig = null) {
if (seed === this.seed) return; // The snapshot carries the config the seed was generated with; fall back to
// the shared default for callers that predate it. A signature rather than a
// reference test keeps a fresh snapshot object from rebuilding the world.
const config = mapConfig || MAP_CONFIG;
const signature = `${seed}|${JSON.stringify(config)}`;
if (signature === this._terrainSignature) return;
this._terrainSignature = signature;
this.seed = seed; this.seed = seed;
this.mapConfig = config;
// A new world brings a new road network; drop the old one (and the cache // A new world brings a new road network; drop the old one (and the cache
// signature) before the chunks are rebuilt, so setRoads always installs the // signature) before the chunks are rebuilt, so setRoads always installs the
// new tiles. // new tiles.
this.roads = new Set(); this.roads = new Set();
this._roadsSignature = null; this._roadsSignature = null;
this.topology = new MapTopology(MAP_CONFIG.mapSize, MAP_CONFIG.topology === "cylindrical"); this.topology = new MapTopology(config.mapSize, config.topology === "cylindrical");
const generator = new MapGenerator(MAP_CONFIG, this.topology); const generator = new MapGenerator(config, this.topology);
generator.generate(seed); generator.generate(seed);
this.tiles = generator.tiles; this.tiles = generator.tiles;
this.terrainStats = new TerrainStats(this.tiles); this.terrainStats = new TerrainStats(this.tiles);
+2 -14
View File
@@ -9,9 +9,7 @@
// methods can freely call each other through `this`; the split is purely about // methods can freely call each other through `this`; the split is purely about
// keeping each concern in a file small enough to read. // keeping each concern in a file small enough to read.
import { MapTopology, key } from "./hex.js"; import { key } from "./hex.js";
import { MapGenerator } from "./map_generator.js";
import { TerrainStats } from "./terrain_stats.js";
import { HexPathfinder } from "./hex_pathfinder.js"; import { HexPathfinder } from "./hex_pathfinder.js";
import { randomSeed } from "./rng.js"; import { randomSeed } from "./rng.js";
import { import {
@@ -158,17 +156,7 @@ export class GameState {
configure(civs, worldSeed = 0) { configure(civs, worldSeed = 0) {
this.civilisations = civs.slice(); this.civilisations = civs.slice();
this.seed = worldSeed !== 0 ? worldSeed | 0 : randomSeed(); this.seed = worldSeed !== 0 ? worldSeed | 0 : randomSeed();
this.topology = new MapTopology( this._buildTerrain();
this.mapConfig.mapSize,
this.mapConfig.topology === "cylindrical"
);
this._generator = new MapGenerator(this.mapConfig, this.topology);
this._generator.generate(this.seed);
this.tiles = this._generator.tiles;
this.landCells = this._generator.landCells;
this.terrainStats = new TerrainStats(this.tiles);
this._minMovementCost = this.terrainStats.minMovementCostMultiplier();
this._maxStepLength = this._computeMaxStepLength();
this._resetCollections(); this._resetCollections();
for (let i = 0; i < this.civilisations.length; i++) { for (let i = 0; i < this.civilisations.length; i++) {
+4
View File
@@ -12,6 +12,10 @@ export const serializationMethods = {
serializeShared() { serializeShared() {
return { return {
seed: this.seed, seed: this.seed,
// The world-generation config the seed was generated with. The browser
// rebuilds the same terrain from it, so a game on a non-default map size
// or shape is rendered correctly instead of falling back to the default.
mapConfig: this.mapConfig,
totalHours: this.totalHours, totalHours: this.totalHours,
civs: this.civilisations.map((c) => ({ id: c.id, name: c.name })), civs: this.civilisations.map((c) => ({ id: c.id, name: c.name })),
protos: this.protoUnits.map((p) => ({ id: p.id, name: p.name })), protos: this.protoUnits.map((p) => ({ id: p.id, name: p.name })),
+85 -6
View File
@@ -5,15 +5,90 @@
import { ECONOMY } from "../data/economy.js"; import { ECONOMY } from "../data/economy.js";
import { ROADS } from "../data/roads.js"; import { ROADS } from "../data/roads.js";
import { buildRoadNetwork } from "../roads.js"; import { buildRoadNetwork } from "../roads.js";
import { MapTopology, key, parseKey } from "../hex.js";
import { MapGenerator } from "../map_generator.js";
import { TerrainStats } from "../terrain_stats.js";
import { Random } from "../rng.js"; import { Random } from "../rng.js";
import { key, parseKey } from "../hex.js";
// World generation is a pure function of (seed, mapConfig): the same inputs
// always produce the same terrain, city sites and road network. Caching the
// shape lets a new game with a known seed -- a server restart, or one test
// fixture after another -- skip regenerating the whole world every time. Only
// the immutable terrain pieces are shared; every mutable collection is rebuilt
// per GameState.
const WORLD_CACHE_LIMIT = 16;
const terrainCache = new Map();
const settlementCache = new Map();
function cachePut(cache, cacheKey, value) {
cache.set(cacheKey, value);
if (cache.size > WORLD_CACHE_LIMIT) cache.delete(cache.keys().next().value);
}
function copyCoords(coords) {
return { x: coords.x, y: coords.y };
}
export const worldMethods = { export const worldMethods = {
// Builds or reuses the topology, tiles and terrain stats for `this.seed` and
// `this.mapConfig`.
_buildTerrain() {
const cacheKey = `${this.seed}|${JSON.stringify(this.mapConfig)}`;
const cached = terrainCache.get(cacheKey);
if (cached) {
this.topology = cached.topology;
this.tiles = cached.tiles;
this.landCells = cached.landCells;
this.terrainStats = cached.terrainStats;
this._generator = cached.generator;
this._minMovementCost = cached.minMovementCost;
this._maxStepLength = cached.maxStepLength;
return;
}
this.topology = new MapTopology(
this.mapConfig.mapSize,
this.mapConfig.topology === "cylindrical"
);
this._generator = new MapGenerator(this.mapConfig, this.topology);
this._generator.generate(this.seed);
this.tiles = this._generator.tiles;
this.landCells = this._generator.landCells;
this.terrainStats = new TerrainStats(this.tiles);
this._minMovementCost = this.terrainStats.minMovementCostMultiplier();
this._maxStepLength = this._computeMaxStepLength();
cachePut(terrainCache, cacheKey, {
topology: this.topology,
tiles: this.tiles,
landCells: this.landCells,
terrainStats: this.terrainStats,
generator: this._generator,
minMovementCost: this._minMovementCost,
maxStepLength: this._maxStepLength,
});
},
_placeSettlements() { _placeSettlements() {
const capitals = this._placeCapitals(this.civilisations.length); // City sites and the road network depend only on the seed, the map config
for (const site of this._placeCities(capitals)) this._spawnCity(site); // and how many nations are playing, so they are cached alongside the
// terrain and reused when a known world is started again.
const cacheKey = `${this.seed}|${JSON.stringify(this.mapConfig)}|${this.civilisations.length}`;
const cached = settlementCache.get(cacheKey);
const capitals = cached
? cached.capitals.map(copyCoords)
: this._placeCapitals(this.civilisations.length);
const sites = cached
? cached.sites.map((site) => ({ ...site, coords: copyCoords(site.coords) }))
: this._placeCities(capitals);
for (const site of sites) this._spawnCity(site);
this._rebuildCityIndex(); this._rebuildCityIndex();
this._generateRoads(); this._generateRoads(cached ? cached.roads : null);
if (!cached) {
cachePut(settlementCache, cacheKey, {
capitals: capitals.map(copyCoords),
sites: sites.map((site) => ({ ...site, coords: copyCoords(site.coords) })),
roads: new Set(this.roads),
});
}
for (let i = 0; i < Math.min(this.civilisations.length, capitals.length); i++) { for (let i = 0; i < Math.min(this.civilisations.length, capitals.length); i++) {
this._spawnUnit(capitals[i], i, this.protoUnits[0]); this._spawnUnit(capitals[i], i, this.protoUnits[0]);
} }
@@ -99,13 +174,17 @@ export const worldMethods = {
// Lays the pre-generated road network. Cities are founded with a road, so // Lays the pre-generated road network. Cities are founded with a road, so
// they are free nodes while the network is drawn; the chosen links are the // they are free nodes while the network is drawn; the chosen links are the
// greedy spanner over every possible city-to-city route. // greedy spanner over every possible city-to-city route.
_generateRoads() { _generateRoads(roadKeys = null) {
for (const city of this.cities) { for (const city of this.cities) {
if (!city.improvements.includes(ROADS.improvementName)) { if (!city.improvements.includes(ROADS.improvementName)) {
city.improvements.push(ROADS.improvementName); city.improvements.push(ROADS.improvementName);
} }
} }
this.roads = buildRoadNetwork(this.cities, this.topology, this.tiles); // A cached world hands back the (read-only) road tiles; copy them so no
// state can alter the cache.
this.roads = roadKeys
? new Set(roadKeys)
: buildRoadNetwork(this.cities, this.topology, this.tiles);
}, },
_spawnUnit(center, civIndex, proto, homeCityId = null) { _spawnUnit(center, civIndex, proto, homeCityId = null) {
+23 -3
View File
@@ -76,11 +76,17 @@ class MinHeap {
// Dijkstra over the land tiles from `source`, costing each step through // Dijkstra over the land tiles from `source`, costing each step through
// `stepCost(coords, tile)`. Returns the distance to every reachable tile and // `stepCost(coords, tile)`. Returns the distance to every reachable tile and
// the parent needed to rebuild the route. // the parent needed to rebuild the route.
export function shortestPaths(source, topology, tiles, stepCost) { //
// `targets` (optional) is a set of canonical keys the caller actually needs:
// once every one of them is settled the search stops, which keeps the
// road-network build from flooding the whole continent from each city.
export function shortestPaths(source, topology, tiles, stepCost, targets = null) {
const sourceKey = key(source.x, source.y); const sourceKey = key(source.x, source.y);
const distance = new Map([[sourceKey, 0]]); const distance = new Map([[sourceKey, 0]]);
const parent = new Map(); const parent = new Map();
const settled = new Set(); const settled = new Set();
const remaining = targets ? new Set(targets) : null;
if (remaining) remaining.delete(sourceKey);
const heap = new MinHeap(); const heap = new MinHeap();
heap.push(0, source); heap.push(0, source);
while (!heap.isEmpty()) { while (!heap.isEmpty()) {
@@ -88,6 +94,10 @@ export function shortestPaths(source, topology, tiles, stepCost) {
const currentKey = key(current.x, current.y); const currentKey = key(current.x, current.y);
if (settled.has(currentKey)) continue; if (settled.has(currentKey)) continue;
settled.add(currentKey); settled.add(currentKey);
if (remaining) {
remaining.delete(currentKey);
if (remaining.size === 0 && distance.has(currentKey)) break;
}
for (const neighbour of topology.neighbours(current.x, current.y)) { for (const neighbour of topology.neighbours(current.x, current.y)) {
const nk = key(neighbour.x, neighbour.y); const nk = key(neighbour.x, neighbour.y);
const tile = tiles[nk]; const tile = tiles[nk];
@@ -154,8 +164,18 @@ export function buildRoadNetwork(cities, topology, tiles, options = {}) {
for (const k of cityKeys) roads.add(k); for (const k of cityKeys) roads.add(k);
if (cities.length < 2) return roads; if (cities.length < 2) return roads;
// Every possible link, with the cheapest route and its cost. // Every possible link, with the cheapest route and its cost. Each search may
const searches = cities.map((city) => shortestPaths(city.coords, topology, tiles, stepCost)); // stop as soon as it has reached every other city, instead of flooding the
// whole continent.
const searches = cities.map((city, index) =>
shortestPaths(
city.coords,
topology,
tiles,
stepCost,
cityKeys.filter((_, other) => other !== index)
)
);
const edges = []; const edges = [];
for (let a = 0; a < cities.length; a++) { for (let a = 0; a < cities.length; a++) {
for (let b = a + 1; b < cities.length; b++) { for (let b = a + 1; b < cities.length; b++) {
+3 -3
View File
@@ -1,7 +1,7 @@
import { TestCase } from "./framework/test_case.js"; import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom, FakeWebSocket } from "./framework/dom.js"; import { setupDom, teardownDom, FakeWebSocket } from "./framework/dom.js";
import { CIVILISATIONS } from "../shared/data.js"; import { CIVILISATIONS } from "../shared/data.js";
import { defaultState } from "./framework/helpers.js"; import { smallState } from "./framework/helpers.js";
const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
@@ -48,7 +48,7 @@ export class AppTest extends TestCase {
joinSocket.simulateMessage({ t: "civ_assigned", civ: 1 }); joinSocket.simulateMessage({ t: "civ_assigned", civ: 1 });
this.assertTrue(env.$("#screen-game").hasClass("active")); this.assertTrue(env.$("#screen-game").hasClass("active"));
joinSocket.simulateMessage({ t: "game_state", state: defaultState().snapshot(1) }); joinSocket.simulateMessage({ t: "game_state", state: smallState().snapshot(1) });
this.assertEqual(env.$("#civ-name").text(), "Britain"); this.assertEqual(env.$("#civ-name").text(), "Britain");
this.assertFalse(env.$("#stat-pop").text() === "0"); this.assertFalse(env.$("#stat-pop").text() === "0");
} finally { } finally {
@@ -135,7 +135,7 @@ export class AppTest extends TestCase {
this.assertEqual(setupMessage.setup.player_civ, 0); this.assertEqual(setupMessage.setup.player_civ, 0);
this.assertEqual(setupMessage.setup.civilisations, ["france", "britain", "slovenia"]); this.assertEqual(setupMessage.setup.civilisations, ["france", "britain", "slovenia"]);
socket.simulateMessage({ t: "game_state", state: defaultState(["france", "britain", "slovenia"], 4242).snapshot(0) }); socket.simulateMessage({ t: "game_state", state: smallState(["france", "britain", "slovenia"], 4242).snapshot(0) });
this.assertEqual(env.$("#civ-name").text(), "France"); this.assertEqual(env.$("#civ-name").text(), "France");
this.assertFalse(env.$("#stat-pop").text() === "0"); this.assertFalse(env.$("#stat-pop").text() === "0");
} finally { } finally {
+5 -5
View File
@@ -1,6 +1,6 @@
import { TestCase } from "./framework/test_case.js"; import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.js"; import { setupDom, teardownDom } from "./framework/dom.js";
import { smallState, defaultState, mapLayers } from "./framework/helpers.js"; import { smallState, mapLayers } from "./framework/helpers.js";
import { key } from "../shared/hex.js"; import { key } from "../shared/hex.js";
import { PROTO_UNITS, CIVILISATIONS, STATUS_BATTLE, BATTLE } from "../shared/data.js"; import { PROTO_UNITS, CIVILISATIONS, STATUS_BATTLE, BATTLE } from "../shared/data.js";
import { MapView } from "../client/js/map_view.js"; import { MapView } from "../client/js/map_view.js";
@@ -488,7 +488,7 @@ export class CombatTest extends TestCase {
} }
async test_client_prediction_mirrors_occupancy() { async test_client_prediction_mirrors_occupancy() {
const state = defaultState(); const state = smallState();
const pair = findEmptyLandPair(state); const pair = findEmptyLandPair(state);
const attacker = state._spawnUnit(pair.a, 0, state.protoUnits[0]); const attacker = state._spawnUnit(pair.a, 0, state.protoUnits[0]);
state._spawnUnit(pair.b, 1, state.protoUnits[0]); state._spawnUnit(pair.b, 1, state.protoUnits[0]);
@@ -529,7 +529,7 @@ export class CombatTest extends TestCase {
} }
async test_client_prediction_charges_onto_the_target() { async test_client_prediction_charges_onto_the_target() {
const state = defaultState(); const state = smallState();
const pair = findEmptyLandPair(state); const pair = findEmptyLandPair(state);
const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]); const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]);
state._spawnUnit(pair.b, 1, state.protoUnits[0]); state._spawnUnit(pair.b, 1, state.protoUnits[0]);
@@ -553,7 +553,7 @@ export class CombatTest extends TestCase {
} }
async test_client_animates_the_charge_onto_the_enemy() { async test_client_animates_the_charge_onto_the_enemy() {
const state = defaultState(); const state = smallState();
const pair = findEmptyLandPair(state); const pair = findEmptyLandPair(state);
const attacker = state._spawnUnit(pair.a, 0, state.protoUnits[0]); const attacker = state._spawnUnit(pair.a, 0, state.protoUnits[0]);
state._spawnUnit(pair.b, 1, state.protoUnits[0]); state._spawnUnit(pair.b, 1, state.protoUnits[0]);
@@ -577,7 +577,7 @@ export class CombatTest extends TestCase {
} }
async test_client_draws_half_size_stacks_with_the_battle_icon_between_them() { async test_client_draws_half_size_stacks_with_the_battle_icon_between_them() {
const state = defaultState(); const state = smallState();
const { b } = engagement(state); const { b } = engagement(state);
const env = await setupDom(); const env = await setupDom();
+25 -25
View File
@@ -2,7 +2,7 @@ import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.js"; import { setupDom, teardownDom } from "./framework/dom.js";
import { GameScreen } from "../client/js/game_screen.js"; import { GameScreen } from "../client/js/game_screen.js";
import { BUILDINGS, CIVILISATIONS } from "../shared/data.js"; import { BUILDINGS, CIVILISATIONS } from "../shared/data.js";
import { defaultState, adjacentLand } from "./framework/helpers.js"; import { smallState, adjacentLand } from "./framework/helpers.js";
function stubNetwork() { function stubNetwork() {
return { return {
@@ -37,7 +37,7 @@ export class GameScreenTest extends TestCase {
screen.enter(); screen.enter();
this.assertTrue(env.$("#screen-game").hasClass("active")); this.assertTrue(env.$("#screen-game").hasClass("active"));
const state = defaultState(); const state = smallState();
screen.onState(state.snapshot(0)); screen.onState(state.snapshot(0));
this.assertEqual(env.$("#civ-name").text(), "France"); this.assertEqual(env.$("#civ-name").text(), "France");
this.assertEqual(env.$("#player-name").text(), "Carol"); this.assertEqual(env.$("#player-name").text(), "Carol");
@@ -77,7 +77,7 @@ export class GameScreenTest extends TestCase {
try { try {
const screen = new GameScreen(stubNetwork(), makeConfig()); const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter(); screen.enter();
screen.onState(defaultState().snapshot(0)); screen.onState(smallState().snapshot(0));
env.$("#civ-flag").click(); env.$("#civ-flag").click();
this.assertFalse(env.$("#modal-nation").hasClass("hidden")); this.assertFalse(env.$("#modal-nation").hasClass("hidden"));
this.assertSize(env.$("#government-list .row-card"), 3); this.assertSize(env.$("#government-list .row-card"), 3);
@@ -93,7 +93,7 @@ export class GameScreenTest extends TestCase {
try { try {
const screen = new GameScreen(stubNetwork(), makeConfig()); const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter(); screen.enter();
const state = defaultState(); const state = smallState();
state.budgets.set(0, 1.0e15); state.budgets.set(0, 1.0e15);
screen.onState(state.snapshot(0)); screen.onState(state.snapshot(0));
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
@@ -120,7 +120,7 @@ export class GameScreenTest extends TestCase {
try { try {
const screen = new GameScreen(stubNetwork(), makeConfig()); const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter(); screen.enter();
const state = defaultState(); const state = smallState();
screen.onState(state.snapshot(0)); screen.onState(state.snapshot(0));
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
screen.map.onCitySelected(capital.id); screen.map.onCitySelected(capital.id);
@@ -143,7 +143,7 @@ export class GameScreenTest extends TestCase {
try { try {
const screen = new GameScreen(stubNetwork(), makeConfig()); const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter(); screen.enter();
const state = defaultState(); const state = smallState();
state.budgets.set(0, 1.0e15); state.budgets.set(0, 1.0e15);
screen.onState(state.snapshot(0)); screen.onState(state.snapshot(0));
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
@@ -173,7 +173,7 @@ export class GameScreenTest extends TestCase {
const network = stubNetwork(); const network = stubNetwork();
const screen = new GameScreen(network, makeConfig()); const screen = new GameScreen(network, makeConfig());
screen.enter(); screen.enter();
const state = defaultState(); const state = smallState();
screen.onState(state.snapshot(0)); screen.onState(state.snapshot(0));
const raw = state.units.find((u) => u.civ === 0); const raw = state.units.find((u) => u.civ === 0);
const unit = screen._unit(raw.id); const unit = screen._unit(raw.id);
@@ -204,7 +204,7 @@ export class GameScreenTest extends TestCase {
const network = stubNetwork(); const network = stubNetwork();
const screen = new GameScreen(network, makeConfig()); const screen = new GameScreen(network, makeConfig());
screen.enter(); screen.enter();
const state = defaultState(); const state = smallState();
const home = state.units.find((u) => u.civ === 0); const home = state.units.find((u) => u.civ === 0);
const artillery = state._spawnUnit( const artillery = state._spawnUnit(
home.coords, home.coords,
@@ -268,7 +268,7 @@ export class GameScreenTest extends TestCase {
try { try {
const screen = new GameScreen(stubNetwork(), makeConfig()); const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter(); screen.enter();
const state = defaultState(); const state = smallState();
screen.onState(state.snapshot(0)); screen.onState(state.snapshot(0));
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
screen.map.onTileRequested(capital.coords); screen.map.onTileRequested(capital.coords);
@@ -320,7 +320,7 @@ export class GameScreenTest extends TestCase {
try { try {
const screen = new GameScreen(stubNetwork(), makeConfig()); const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter(); screen.enter();
screen.onState(defaultState().snapshot(0)); screen.onState(smallState().snapshot(0));
const before = screen.map.camera.x; const before = screen.map.camera.x;
env.$(env.window).trigger(env.$.Event("keydown", { key: "d" })); env.$(env.window).trigger(env.$.Event("keydown", { key: "d" }));
@@ -353,7 +353,7 @@ export class GameScreenTest extends TestCase {
try { try {
const screen = new GameScreen(stubNetwork(), makeConfig()); const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter(); screen.enter();
const state = defaultState(); const state = smallState();
state.research.set(0, 1.0e6); state.research.set(0, 1.0e6);
state.budgets.set(0, 1.0e15); state.budgets.set(0, 1.0e15);
const method = state.technologies.findIndex((t) => t.id === "scientific_method"); const method = state.technologies.findIndex((t) => t.id === "scientific_method");
@@ -380,7 +380,7 @@ export class GameScreenTest extends TestCase {
try { try {
const screen = new GameScreen(stubNetwork(), makeConfig()); const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter(); screen.enter();
const state = defaultState(); const state = smallState();
const mountain = state.landCells.find( const mountain = state.landCells.find(
(c) => state.tiles[`${c.x},${c.y}`].terrainType === "Mountain" (c) => state.tiles[`${c.x},${c.y}`].terrainType === "Mountain"
); );
@@ -418,7 +418,7 @@ export class GameScreenTest extends TestCase {
try { try {
const screen = new GameScreen(stubNetwork(), makeConfig()); const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter(); screen.enter();
const state = defaultState(); const state = smallState();
const plain = state.landCells.find( const plain = state.landCells.find(
(c) => state.tiles[`${c.x},${c.y}`].terrainType === "Land" && (c) => state.tiles[`${c.x},${c.y}`].terrainType === "Land" &&
!state.cityAt(c) && state.topology.neighbours(c.x, c.y).some((n) => state._isLand(n)) !state.cityAt(c) && state.topology.neighbours(c.x, c.y).some((n) => state._isLand(n))
@@ -448,7 +448,7 @@ export class GameScreenTest extends TestCase {
const network = stubNetwork(); const network = stubNetwork();
const screen = new GameScreen(network, makeConfig()); const screen = new GameScreen(network, makeConfig());
screen.enter(); screen.enter();
const state = defaultState(); const state = smallState();
screen.onState(state.snapshot(0)); screen.onState(state.snapshot(0));
const unit = state.units.find((u) => u.civ === 0); const unit = state.units.find((u) => u.civ === 0);
const goal = { x: 900, y: 900 }; const goal = { x: 900, y: 900 };
@@ -478,7 +478,7 @@ export class GameScreenTest extends TestCase {
const network = stubNetwork(); const network = stubNetwork();
const screen = new GameScreen(network, makeConfig()); const screen = new GameScreen(network, makeConfig());
screen.enter(); screen.enter();
const state = defaultState(); const state = smallState();
screen.onState(state.snapshot(0)); screen.onState(state.snapshot(0));
const unit = state.units.find((u) => u.civ === 0); const unit = state.units.find((u) => u.civ === 0);
const goal = { x: 902, y: 902 }; const goal = { x: 902, y: 902 };
@@ -499,7 +499,7 @@ export class GameScreenTest extends TestCase {
const network = stubNetwork(); const network = stubNetwork();
const screen = new GameScreen(network, makeConfig()); const screen = new GameScreen(network, makeConfig());
screen.enter(); screen.enter();
const state = defaultState(); const state = smallState();
state.requestDeclareWar(0, 1); state.requestDeclareWar(0, 1);
screen.onState(state.snapshot(0)); screen.onState(state.snapshot(0));
const unit = state.units.find((u) => u.civ === 0); const unit = state.units.find((u) => u.civ === 0);
@@ -522,7 +522,7 @@ export class GameScreenTest extends TestCase {
const network = stubNetwork(); const network = stubNetwork();
const screen = new GameScreen(network, makeConfig()); const screen = new GameScreen(network, makeConfig());
screen.enter(); screen.enter();
screen.onState(defaultState().snapshot(0)); screen.onState(smallState().snapshot(0));
env.$("#civ-flag").click(); env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab=diplomacy]").click(); env.$("#modal-nation .tab[data-tab=diplomacy]").click();
const row = env.$("#diplomacy-list tbody tr").first(); const row = env.$("#diplomacy-list tbody tr").first();
@@ -541,7 +541,7 @@ export class GameScreenTest extends TestCase {
try { try {
const screen = new GameScreen(stubNetwork(), makeConfig()); const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter(); screen.enter();
const state = defaultState(); const state = smallState();
state.requestDeclareWar(0, 1); state.requestDeclareWar(0, 1);
screen.onState(state.snapshot(0)); screen.onState(state.snapshot(0));
env.$("#news-button").click(); env.$("#news-button").click();
@@ -558,7 +558,7 @@ export class GameScreenTest extends TestCase {
try { try {
const screen = new GameScreen(stubNetwork(), makeConfig()); const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter(); screen.enter();
const state = defaultState(); const state = smallState();
// The first snapshot is history: it must not flood the feed. // The first snapshot is history: it must not flood the feed.
screen.onState(state.snapshot(0)); screen.onState(state.snapshot(0));
this.assertSize(env.$("#game-feed .feed-line"), 0); this.assertSize(env.$("#game-feed .feed-line"), 0);
@@ -578,7 +578,7 @@ export class GameScreenTest extends TestCase {
try { try {
const screen = new GameScreen(stubNetwork(), makeConfig()); const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter(); screen.enter();
const state = defaultState(); const state = smallState();
screen.onState(state.snapshot(0)); screen.onState(state.snapshot(0));
state.requestDeclareWar(1, 0); state.requestDeclareWar(1, 0);
@@ -598,7 +598,7 @@ export class GameScreenTest extends TestCase {
try { try {
const screen = new GameScreen(stubNetwork(), makeConfig()); const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter(); screen.enter();
const state = defaultState(); const state = smallState();
const unit = state.units.find((u) => u.civ === 0); const unit = state.units.find((u) => u.civ === 0);
const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]); const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
screen.onState(state.snapshot(0)); screen.onState(state.snapshot(0));
@@ -627,7 +627,7 @@ export class GameScreenTest extends TestCase {
const network = stubNetwork(); const network = stubNetwork();
const screen = new GameScreen(network, makeConfig()); const screen = new GameScreen(network, makeConfig());
screen.enter(); screen.enter();
const state = defaultState(); const state = smallState();
const unit = state.units.find((u) => u.civ === 0); const unit = state.units.find((u) => u.civ === 0);
const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]); const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
screen.onState(state.snapshot(0)); screen.onState(state.snapshot(0));
@@ -659,7 +659,7 @@ export class GameScreenTest extends TestCase {
const network = stubNetwork(); const network = stubNetwork();
const screen = new GameScreen(network, makeConfig()); const screen = new GameScreen(network, makeConfig());
screen.enter(); screen.enter();
const state = defaultState(); const state = smallState();
const capital = state.units.find((u) => u.civ === 0); const capital = state.units.find((u) => u.civ === 0);
const infantry = state._spawnUnit(capital.coords, 0, state.protoUnits[0]); const infantry = state._spawnUnit(capital.coords, 0, state.protoUnits[0]);
const artillery = state._spawnUnit(capital.coords, 0, state.protoUnits[2]); const artillery = state._spawnUnit(capital.coords, 0, state.protoUnits[2]);
@@ -686,7 +686,7 @@ export class GameScreenTest extends TestCase {
const network = stubNetwork(); const network = stubNetwork();
const screen = new GameScreen(network, makeConfig()); const screen = new GameScreen(network, makeConfig());
screen.enter(); screen.enter();
const state = defaultState(); const state = smallState();
const coords = findEnemyTile(state); const coords = findEnemyTile(state);
this.assertNotNull(coords, "found an enemy tile"); this.assertNotNull(coords, "found an enemy tile");
state.tilePopulation.set(`${coords.x},${coords.y}`, 100000); state.tilePopulation.set(`${coords.x},${coords.y}`, 100000);
@@ -717,7 +717,7 @@ export class GameScreenTest extends TestCase {
try { try {
const screen = new GameScreen(stubNetwork(), makeConfig()); const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter(); screen.enter();
const state = defaultState(); const state = smallState();
const unit = state.units.find((u) => u.civ === 0); const unit = state.units.find((u) => u.civ === 0);
screen.onState(state.snapshot(0)); screen.onState(state.snapshot(0));
screen._selectUnits([unit.id]); screen._selectUnits([unit.id]);
+16
View File
@@ -39,6 +39,22 @@ export class GameStateTest extends TestCase {
this.assertEqual(a.territory, b.territory); this.assertEqual(a.territory, b.territory);
} }
// The generated world is cached and reused for a repeated seed, so the
// mutable pieces must be rebuilt per game rather than shared.
test_reused_worlds_are_independent() {
const a = smallState();
const b = smallState();
const roads = b.roads.size;
const population = b.cities[0].population;
a.roads.add("999999,999999");
a.cities[0].population = -1;
a.units[0].coords = { x: 0, y: 0 };
this.assertSize(b.roads, roads, "the cached roads are copied per game");
this.assertNotEqual(b.cities[0].population, -1, "cities are not shared");
this.assertNotEqual(b.units[0].coords, a.units[0].coords, "units are not shared");
this.assertEqual(b.cities[0].population, population, "the other game is untouched");
}
test_foreign_territory_is_a_wall_unless_targeted() { test_foreign_territory_is_a_wall_unless_targeted() {
const state = smallState(["france", "britain", "poland"]); const state = smallState(["france", "britain", "poland"]);
const mover = state.units.find((u) => u.civ === 0); const mover = state.units.find((u) => u.civ === 0);
+30 -28
View File
@@ -3,7 +3,7 @@ import { setupDom, teardownDom } from "./framework/dom.js";
import { MapView, inwardOffset } from "../client/js/map_view.js"; import { MapView, inwardOffset } from "../client/js/map_view.js";
import { mapToLocal, COL_STEP } from "../shared/hex.js"; import { mapToLocal, COL_STEP } from "../shared/hex.js";
import { CIVILISATIONS, PROTO_UNITS } from "../shared/data.js"; import { CIVILISATIONS, PROTO_UNITS } from "../shared/data.js";
import { defaultState, mapLayers, adjacentLand, seaTile } from "./framework/helpers.js"; import { defaultState, smallState, mapLayers, adjacentLand, seaTile } from "./framework/helpers.js";
export class MapViewTest extends TestCase { export class MapViewTest extends TestCase {
async build(state) { async build(state) {
@@ -18,6 +18,8 @@ export class MapViewTest extends TestCase {
} }
async test_renders_terrain_territory_fog_and_entities() { async test_renders_terrain_territory_fog_and_entities() {
// A full-size map, so the viewport genuinely covers only part of it and the
// chunking is exercised; the other view tests use the small fixture.
const state = defaultState(); const state = defaultState();
const { env, map, snapshot } = await this.build(state); const { env, map, snapshot } = await this.build(state);
try { try {
@@ -39,7 +41,7 @@ export class MapViewTest extends TestCase {
} }
async test_renders_the_pre_generated_road_network() { async test_renders_the_pre_generated_road_network() {
const state = defaultState(); const state = smallState();
const { env, map, snapshot } = await this.build(state); const { env, map, snapshot } = await this.build(state);
try { try {
this.assertSize(map.roads, snapshot.roads.length, "the snapshot's road tiles are mirrored"); this.assertSize(map.roads, snapshot.roads.length, "the snapshot's road tiles are mirrored");
@@ -53,7 +55,7 @@ export class MapViewTest extends TestCase {
} }
async test_unit_views_show_their_owners_flag() { async test_unit_views_show_their_owners_flag() {
const state = defaultState(); const state = smallState();
const { env } = await this.build(state); const { env } = await this.build(state);
try { try {
const flags = env.$("#layer-entities .unit .unit-flag"); const flags = env.$("#layer-entities .unit .unit-flag");
@@ -68,7 +70,7 @@ export class MapViewTest extends TestCase {
} }
async test_centers_on_the_capital() { async test_centers_on_the_capital() {
const state = defaultState(); const state = smallState();
const { env, map } = await this.build(state); const { env, map } = await this.build(state);
try { try {
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
@@ -82,7 +84,7 @@ export class MapViewTest extends TestCase {
} }
async test_tile_at_screen_round_trips() { async test_tile_at_screen_round_trips() {
const state = defaultState(); const state = smallState();
const { env, map } = await this.build(state); const { env, map } = await this.build(state);
try { try {
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital); const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
@@ -96,7 +98,7 @@ export class MapViewTest extends TestCase {
} }
async test_wrapped_tile_lookup_stays_in_range() { async test_wrapped_tile_lookup_stays_in_range() {
const state = defaultState(); const state = smallState();
const { env, map } = await this.build(state); const { env, map } = await this.build(state);
try { try {
const coords = map.tileAtScreen(100000, 300); const coords = map.tileAtScreen(100000, 300);
@@ -109,7 +111,7 @@ export class MapViewTest extends TestCase {
} }
async test_terrain_wraps_across_the_seam() { async test_terrain_wraps_across_the_seam() {
const state = defaultState(); const state = smallState();
const { env, map } = await this.build(state); const { env, map } = await this.build(state);
try { try {
this.assertGreater(map._period, 0, "the map has a wrap period"); this.assertGreater(map._period, 0, "the map has a wrap period");
@@ -134,7 +136,7 @@ export class MapViewTest extends TestCase {
} }
async test_chunks_meet_seamlessly_at_the_wrap() { async test_chunks_meet_seamlessly_at_the_wrap() {
const state = defaultState(); const state = smallState();
const { env, map } = await this.build(state); const { env, map } = await this.build(state);
try { try {
// Sit on the seam (just past the east edge) so both the east-edge chunk // Sit on the seam (just past the east edge) so both the east-edge chunk
@@ -166,7 +168,7 @@ export class MapViewTest extends TestCase {
} }
async test_dragging_wraps_with_transforms_not_layout() { async test_dragging_wraps_with_transforms_not_layout() {
const state = defaultState(); const state = smallState();
const { env, map } = await this.build(state); const { env, map } = await this.build(state);
try { try {
const rec = map._chunks.values().next().value; const rec = map._chunks.values().next().value;
@@ -186,7 +188,7 @@ export class MapViewTest extends TestCase {
} }
async test_wrap_shift_picks_the_copy_nearest_the_camera() { async test_wrap_shift_picks_the_copy_nearest_the_camera() {
const state = defaultState(); const state = smallState();
const { env, map } = await this.build(state); const { env, map } = await this.build(state);
try { try {
const period = map._period; const period = map._period;
@@ -201,7 +203,7 @@ export class MapViewTest extends TestCase {
} }
async test_zooming_out_switches_to_the_overview() { async test_zooming_out_switches_to_the_overview() {
const state = defaultState(); const state = smallState();
const { env, map } = await this.build(state); const { env, map } = await this.build(state);
try { try {
this.assertFalse(map.lod, "detailed at close zoom"); this.assertFalse(map.lod, "detailed at close zoom");
@@ -240,7 +242,7 @@ export class MapViewTest extends TestCase {
} }
async test_neighbouring_borders_are_inset_not_overlaid() { async test_neighbouring_borders_are_inset_not_overlaid() {
const state = defaultState(); const state = smallState();
const { env, map } = await this.build(state); const { env, map } = await this.build(state);
try { try {
// Bring the two-tile territory into view so its chunk is materialised. // Bring the two-tile territory into view so its chunk is materialised.
@@ -267,7 +269,7 @@ export class MapViewTest extends TestCase {
} }
async test_selecting_a_unit_marks_its_view() { async test_selecting_a_unit_marks_its_view() {
const state = defaultState(); const state = smallState();
const { env, map, snapshot } = await this.build(state); const { env, map, snapshot } = await this.build(state);
try { try {
const unit = snapshot.units.find((u) => u.civ === 0); const unit = snapshot.units.find((u) => u.civ === 0);
@@ -280,7 +282,7 @@ export class MapViewTest extends TestCase {
} }
async test_predict_move_starts_a_local_route() { async test_predict_move_starts_a_local_route() {
const state = defaultState(); const state = smallState();
const { env, map, snapshot } = await this.build(state); const { env, map, snapshot } = await this.build(state);
try { try {
const unit = snapshot.units.find((u) => u.civ === 0); const unit = snapshot.units.find((u) => u.civ === 0);
@@ -300,7 +302,7 @@ export class MapViewTest extends TestCase {
} }
async test_plan_move_computes_a_route_without_predicting() { async test_plan_move_computes_a_route_without_predicting() {
const state = defaultState(); const state = smallState();
const { env, map, snapshot } = await this.build(state); const { env, map, snapshot } = await this.build(state);
try { try {
const unit = snapshot.units.find((u) => u.civ === 0); const unit = snapshot.units.find((u) => u.civ === 0);
@@ -321,7 +323,7 @@ export class MapViewTest extends TestCase {
} }
async test_plan_move_reuses_a_shared_route() { async test_plan_move_reuses_a_shared_route() {
const state = defaultState(); const state = smallState();
const { env, map, snapshot } = await this.build(state); const { env, map, snapshot } = await this.build(state);
try { try {
const unit = snapshot.units.find((u) => u.civ === 0); const unit = snapshot.units.find((u) => u.civ === 0);
@@ -344,7 +346,7 @@ export class MapViewTest extends TestCase {
} }
async test_pan_moves_the_camera() { async test_pan_moves_the_camera() {
const state = defaultState(); const state = smallState();
const { env, map } = await this.build(state); const { env, map } = await this.build(state);
try { try {
const beforeX = map.camera.x; const beforeX = map.camera.x;
@@ -358,7 +360,7 @@ export class MapViewTest extends TestCase {
} }
async test_syncs_and_draws_an_authoritative_path() { async test_syncs_and_draws_an_authoritative_path() {
const state = defaultState(); const state = smallState();
const unit = state.units.find((u) => u.civ === 0); const unit = state.units.find((u) => u.civ === 0);
const goal = adjacentLand(state, unit); const goal = adjacentLand(state, unit);
state.requestMove(unit.id, goal); state.requestMove(unit.id, goal);
@@ -417,7 +419,7 @@ export class MapViewTest extends TestCase {
} }
async test_right_click_on_a_unit_icon_orders_the_selected_stack() { async test_right_click_on_a_unit_icon_orders_the_selected_stack() {
const state = defaultState(); const state = smallState();
const unit = state.units.find((u) => u.civ === 0); const unit = state.units.find((u) => u.civ === 0);
const goal = adjacentLand(state, unit); const goal = adjacentLand(state, unit);
const target = state._spawnUnit(goal, 0, state.protoUnits[0]); const target = state._spawnUnit(goal, 0, state.protoUnits[0]);
@@ -448,7 +450,7 @@ export class MapViewTest extends TestCase {
} }
async test_left_click_selects_city_or_clears() { async test_left_click_selects_city_or_clears() {
const state = defaultState(); const state = smallState();
const { env, map, snapshot } = await this.build(state); const { env, map, snapshot } = await this.build(state);
try { try {
let chosen = null; let chosen = null;
@@ -478,7 +480,7 @@ export class MapViewTest extends TestCase {
} }
async test_city_marker_click_reports_the_city() { async test_city_marker_click_reports_the_city() {
const state = defaultState(); const state = smallState();
const { env, map, snapshot } = await this.build(state); const { env, map, snapshot } = await this.build(state);
try { try {
let chosen = null; let chosen = null;
@@ -495,7 +497,7 @@ export class MapViewTest extends TestCase {
} }
async test_clear_selection_notifies() { async test_clear_selection_notifies() {
const state = defaultState(); const state = smallState();
const { env, map, snapshot } = await this.build(state); const { env, map, snapshot } = await this.build(state);
try { try {
const unit = snapshot.units.find((u) => u.civ === 0); const unit = snapshot.units.find((u) => u.civ === 0);
@@ -513,7 +515,7 @@ export class MapViewTest extends TestCase {
} }
async test_stacked_units_show_one_icon_with_a_count_badge() { async test_stacked_units_show_one_icon_with_a_count_badge() {
const state = defaultState(); const state = smallState();
const unit = state.units.find((u) => u.civ === 0); const unit = state.units.find((u) => u.civ === 0);
const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]); const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
const { env, map } = await this.build(state); const { env, map } = await this.build(state);
@@ -531,7 +533,7 @@ export class MapViewTest extends TestCase {
} }
async test_right_click_on_a_friendly_stack_opens_the_menu() { async test_right_click_on_a_friendly_stack_opens_the_menu() {
const state = defaultState(); const state = smallState();
const unit = state.units.find((u) => u.civ === 0); const unit = state.units.find((u) => u.civ === 0);
const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]); const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
const { env, map } = await this.build(state); const { env, map } = await this.build(state);
@@ -551,7 +553,7 @@ export class MapViewTest extends TestCase {
} }
async test_stack_ids_group_units_sharing_a_tile() { async test_stack_ids_group_units_sharing_a_tile() {
const state = defaultState(); const state = smallState();
const unit = state.units.find((u) => u.civ === 0); const unit = state.units.find((u) => u.civ === 0);
const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]); const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
const { env, map } = await this.build(state); const { env, map } = await this.build(state);
@@ -564,7 +566,7 @@ export class MapViewTest extends TestCase {
} }
async test_right_click_on_a_friendly_stack_while_selected_moves_there() { async test_right_click_on_a_friendly_stack_while_selected_moves_there() {
const state = defaultState(); const state = smallState();
const unit = state.units.find((u) => u.civ === 0); const unit = state.units.find((u) => u.civ === 0);
const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]); const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
const { env, map } = await this.build(state); const { env, map } = await this.build(state);
@@ -584,7 +586,7 @@ export class MapViewTest extends TestCase {
} }
} }
async test_right_click_on_an_enemy_stack_attacks_it() { const state = defaultState(); async test_right_click_on_an_enemy_stack_attacks_it() { const state = smallState();
const unit = state.units.find((u) => u.civ === 0); const unit = state.units.find((u) => u.civ === 0);
const goal = adjacentLand(state, unit); const goal = adjacentLand(state, unit);
state._spawnUnit(goal, 1, state.protoUnits[0]); state._spawnUnit(goal, 1, state.protoUnits[0]);
@@ -605,7 +607,7 @@ export class MapViewTest extends TestCase {
} }
async test_ctrl_right_click_schedules_a_destination() { async test_ctrl_right_click_schedules_a_destination() {
const state = defaultState(); const state = smallState();
const unit = state.units.find((u) => u.civ === 0); const unit = state.units.find((u) => u.civ === 0);
const goal = adjacentLand(state, unit); const goal = adjacentLand(state, unit);
const { env, map } = await this.build(state); const { env, map } = await this.build(state);
+165 -116
View File
@@ -1,127 +1,176 @@
// Test runner. Instantiates every suite, calls its `test_*` methods and prints // Test runner. Every `tests/*_test.js` file is discovered automatically and run
// a summary, exiting non-zero when anything fails. Run with: // as a suite. By default the files are farmed out across a pool of worker
// processes, so the whole run is bounded by the slowest file rather than their
// sum. A single file can be run in-process for debugging.
// //
// node tests/run_tests.js // node tests/run_tests.js # all files, parallel
// node tests/run_tests.js --jobs 4 # cap the worker pool
// node tests/run_tests.js --file map_view_test.js
//
// In worker mode (`--file`) the file's exported TestCase classes are run and a
// single machine-readable result line is printed for the parent to collect.
import { HexTest } from "./hex_test.js"; import { readdirSync } from "node:fs";
import { NoiseTest } from "./noise_test.js"; import { spawn } from "node:child_process";
import { RngTest } from "./rng_test.js"; import { fileURLToPath } from "node:url";
import { DataTest } from "./data_test.js"; import { dirname } from "node:path";
import { MapGeneratorTest } from "./map_generator_test.js"; import { cpus } from "node:os";
import { HexPathfinderTest } from "./hex_pathfinder_test.js";
import { TerrainStatsTest } from "./terrain_stats_test.js";
import { RoadsTest } from "./roads_test.js";
import { EconomyTest } from "./economy_test.js";
import { TextFormatTest } from "./text_format_test.js";
import { GameClockTest } from "./game_clock_test.js";
import { LoginManagerTest } from "./login_manager_test.js";
import { RulesTest } from "./rules_test.js";
import { CombatTest } from "./combat_test.js";
import { StackingTest, PillageTest } from "./stacking_test.js";
import { SiegeTest } from "./siege_test.js";
import { AttackOrderTest, ScheduledOrderTest } from "./orders_test.js";
import { GameStateTest } from "./game_state_test.js";
import { GameStateBuildingsTest } from "./game_state_buildings_test.js";
import { GameStateGovernmentTest } from "./game_state_government_test.js";
import { GameStateTechnologyTest } from "./game_state_technology_test.js";
import { DiplomacyStateTest, DiplomacyServerTest } from "./diplomacy_test.js";
import { ConflictsStateTest, ConflictsModalTest } from "./conflicts_test.js";
import { GameServerTest, GameServerOrdersTest, GameServerLoginTest, GameServerLobbyTest } from "./game_server_test.js";
import { ServerStaticTest, ServerLoopTest, ServerProtocolTest } from "./server_test.js";
import { WebSocketProtocolTest } from "./websocket_test.js";
import { NetTest } from "./net_test.js";
import { MapViewTest } from "./map_view_test.js";
import { AirTest } from "./air_test.js";
import { CityModalTest, NationModalTest, ConfirmModalTest, NewsModalTest } from "./modals_test.js";
import { GameScreenTest } from "./game_screen_test.js";
import { GameFeedTest } from "./feed_test.js";
import { AppTest } from "./app_test.js";
import {
OrderStateRegressionTest,
EconomyOrderEdgesTest,
AttritionTest,
CasualtyRecordingTest,
CatalogueSplitTest,
TextFormatRegressionTest,
} from "./regression_test.js";
const SUITES = [ const HERE = dirname(fileURLToPath(import.meta.url));
HexTest, const SELF = fileURLToPath(import.meta.url);
NoiseTest, const RESULT_MARKER = "__TEST_RESULT__";
RngTest, const MAX_JOBS = 8;
DataTest,
MapGeneratorTest,
HexPathfinderTest,
TerrainStatsTest,
RoadsTest,
EconomyTest,
TextFormatTest,
GameClockTest,
LoginManagerTest,
RulesTest,
CombatTest,
StackingTest,
PillageTest,
SiegeTest,
AttackOrderTest,
ScheduledOrderTest,
GameStateTest,
GameStateBuildingsTest,
GameStateGovernmentTest,
GameStateTechnologyTest,
DiplomacyStateTest,
DiplomacyServerTest,
ConflictsStateTest,
ConflictsModalTest,
GameServerTest,
GameServerOrdersTest,
GameServerLoginTest,
GameServerLobbyTest,
ServerStaticTest,
ServerLoopTest,
ServerProtocolTest,
WebSocketProtocolTest,
NetTest,
MapViewTest,
AirTest,
CityModalTest,
NationModalTest,
ConfirmModalTest,
NewsModalTest,
GameScreenTest,
GameFeedTest,
AppTest,
OrderStateRegressionTest,
EconomyOrderEdgesTest,
AttritionTest,
CasualtyRecordingTest,
CatalogueSplitTest,
TextFormatRegressionTest,
];
let passed = 0; function flagValue(args, flag) {
const failures = []; const inline = args.find((arg) => arg.startsWith(`${flag}=`));
if (inline) return inline.slice(flag.length + 1);
const index = args.indexOf(flag);
return index >= 0 ? args[index + 1] : null;
}
for (const Suite of SUITES) { // Every test file in this directory, in a stable order.
const instance = new Suite(); function discoverFiles() {
const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(instance)) return readdirSync(HERE)
.filter((name) => name.startsWith("test_") && name !== "constructor") .filter((name) => name.endsWith("_test.js"))
.sort(); .sort();
for (const method of methods) { }
try {
await instance[method](); // The TestCase classes a module exports, with their sorted test methods. Using
passed += 1; // the exports rather than a hand-written list means a new test file is picked
} catch (error) { // up with no runner edit.
failures.push(`${Suite.name}::${method}: ${error.message}`); function testClasses(module) {
const classes = [];
for (const value of Object.values(module)) {
if (typeof value !== "function" || !value.prototype) continue;
const methods = Object.getOwnPropertyNames(value.prototype)
.filter((name) => name.startsWith("test_") && name !== "constructor")
.sort();
if (methods.length > 0) classes.push({ ctor: value, methods });
}
return classes;
}
async function runFile(file) {
const started = Date.now();
const module = await import(new URL(file, import.meta.url));
let passed = 0;
const failures = [];
for (const { ctor, methods } of testClasses(module)) {
for (const method of methods) {
const instance = new ctor();
try {
await instance[method]();
passed += 1;
} catch (error) {
failures.push(`${ctor.name}::${method}: ${error.message}`);
}
} }
} }
return { file, passed, failures, ms: Date.now() - started };
} }
console.log(""); // Worker mode: run one file, then exit. When spawned by the parent it prints a
if (failures.length === 0) { // machine-readable marker; run by hand it prints the usual summary. The write
console.log(`PASS: ${passed} test(s) passed.`); // callback guarantees output reaches the parent before the process ends.
process.exit(0); async function runWorker(file) {
const result = await runFile(file);
const code = result.failures.length === 0 ? 0 : 1;
if (process.env.TISMO_WORKER === "1") {
const line = `\n${RESULT_MARKER}${JSON.stringify(result)}\n`;
process.stdout.write(line, () => process.exit(code));
return;
}
console.log("");
if (result.failures.length === 0) console.log(`PASS: ${result.passed} test(s) passed.`);
else {
console.log(`FAIL: ${result.passed} passed, ${result.failures.length} failed.`);
for (const failure of result.failures) console.log(` - ${failure}`);
}
process.exit(code);
} }
console.log(`FAIL: ${passed} passed, ${failures.length} failed.`);
for (const failure of failures) console.log(` - ${failure}`); function runChild(file) {
process.exit(1); return new Promise((resolve) => {
const started = Date.now();
const child = spawn(process.execPath, [SELF, "--file", file], {
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, TISMO_WORKER: "1" },
});
let out = "";
let err = "";
child.stdout.on("data", (chunk) => (out += chunk));
child.stderr.on("data", (chunk) => (err += chunk));
child.on("close", (code) => {
const index = out.lastIndexOf(RESULT_MARKER);
if (index >= 0) {
const line = out.slice(index + RESULT_MARKER.length).split("\n")[0];
try {
resolve(JSON.parse(line));
return;
} catch {
// fall through to the failure record below
}
}
resolve({
file,
passed: 0,
failures: [`${file}: worker produced no result (exit ${code}) ${err.trim()}`],
ms: Date.now() - started,
});
});
});
}
// Runs `worker` over `items`, never more than `jobs` at a time. Results keep
// their input order so the summary is deterministic.
async function pool(items, jobs, worker) {
const results = new Array(items.length);
let next = 0;
const run = async () => {
while (next < items.length) {
const index = next;
next += 1;
results[index] = await worker(items[index]);
}
};
await Promise.all(Array.from({ length: Math.min(jobs, items.length) }, run));
return results;
}
async function main() {
const args = process.argv.slice(2);
const fileArg = flagValue(args, "--file");
if (fileArg) {
await runWorker(fileArg);
return;
}
const requested = Number(flagValue(args, "--jobs")) || cpus().length;
const jobs = Math.max(1, Math.min(requested, MAX_JOBS));
const files = discoverFiles();
const results = await pool(files, jobs, runChild);
let passed = 0;
const failures = [];
for (const result of results) {
passed += result.passed;
failures.push(...result.failures);
}
console.log("");
for (const result of results.slice().sort((a, b) => b.ms - a.ms)) {
const seconds = (result.ms / 1000).toFixed(1).padStart(5);
const status = result.failures.length === 0 ? "ok " : "FAIL";
console.log(`${status} ${seconds}s ${result.file}`);
}
console.log("");
if (failures.length === 0) {
console.log(`PASS: ${passed} test(s) passed.`);
process.exit(0);
}
console.log(`FAIL: ${passed} passed, ${failures.length} failed.`);
for (const failure of failures) console.log(` - ${failure}`);
process.exit(1);
}
main();
+2 -2
View File
@@ -1,6 +1,6 @@
import { TestCase } from "./framework/test_case.js"; import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.js"; import { setupDom, teardownDom } from "./framework/dom.js";
import { smallState, defaultState, mapLayers, SEED } from "./framework/helpers.js"; import { smallState, mapLayers, SEED } from "./framework/helpers.js";
import { parseKey } from "../shared/hex.js"; import { parseKey } from "../shared/hex.js";
import { CIVILISATIONS, PROTO_UNITS, statusById, STATUS_ENCIRCLED, STATUS_BESIEGED } from "../shared/data.js"; import { CIVILISATIONS, PROTO_UNITS, statusById, STATUS_ENCIRCLED, STATUS_BESIEGED } from "../shared/data.js";
import { MapView } from "../client/js/map_view.js"; import { MapView } from "../client/js/map_view.js";
@@ -168,7 +168,7 @@ export class SiegeTest extends TestCase {
} }
async test_the_client_draws_status_icons_over_units_and_cities() { async test_the_client_draws_status_icons_over_units_and_cities() {
const state = defaultState(); const state = smallState();
const city = findBesiegableCity(state, 0); const city = findBesiegableCity(state, 0);
this.assertNotNull(city, "found a city to besiege"); this.assertNotNull(city, "found a city to besiege");
const unit = state._spawnUnit(city.coords, 0, state.protoUnits[0]); const unit = state._spawnUnit(city.coords, 0, state.protoUnits[0]);