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:
@@ -48,6 +48,10 @@ export class MapView {
|
||||
}
|
||||
|
||||
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.tiles = {};
|
||||
this.terrainStats = null;
|
||||
|
||||
@@ -13,7 +13,7 @@ export const entityMethods = {
|
||||
},
|
||||
|
||||
applySnapshot(state) {
|
||||
this.ensureTerrain(state.seed);
|
||||
this.ensureTerrain(state.seed, state.mapConfig || null);
|
||||
this.setRoads(state.roads || []);
|
||||
this.localCiv = state.viewer;
|
||||
this._applyTerritory(state.territory);
|
||||
|
||||
@@ -18,16 +18,23 @@ import { OVERVIEW_BLOCK, OVERVIEW_FOG, CHUNK_SIZE, CHUNK_MARGIN, CHUNK_CACHE_LIM
|
||||
import { overviewColour, blendColours } from "./utils.js";
|
||||
|
||||
export const terrainMethods = {
|
||||
ensureTerrain(seed) {
|
||||
if (seed === this.seed) return;
|
||||
ensureTerrain(seed, mapConfig = null) {
|
||||
// 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.mapConfig = config;
|
||||
// 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
|
||||
// new tiles.
|
||||
this.roads = new Set();
|
||||
this._roadsSignature = null;
|
||||
this.topology = new MapTopology(MAP_CONFIG.mapSize, MAP_CONFIG.topology === "cylindrical");
|
||||
const generator = new MapGenerator(MAP_CONFIG, this.topology);
|
||||
this.topology = new MapTopology(config.mapSize, config.topology === "cylindrical");
|
||||
const generator = new MapGenerator(config, this.topology);
|
||||
generator.generate(seed);
|
||||
this.tiles = generator.tiles;
|
||||
this.terrainStats = new TerrainStats(this.tiles);
|
||||
|
||||
+2
-14
@@ -9,9 +9,7 @@
|
||||
// methods can freely call each other through `this`; the split is purely about
|
||||
// keeping each concern in a file small enough to read.
|
||||
|
||||
import { MapTopology, key } from "./hex.js";
|
||||
import { MapGenerator } from "./map_generator.js";
|
||||
import { TerrainStats } from "./terrain_stats.js";
|
||||
import { key } from "./hex.js";
|
||||
import { HexPathfinder } from "./hex_pathfinder.js";
|
||||
import { randomSeed } from "./rng.js";
|
||||
import {
|
||||
@@ -158,17 +156,7 @@ export class GameState {
|
||||
configure(civs, worldSeed = 0) {
|
||||
this.civilisations = civs.slice();
|
||||
this.seed = worldSeed !== 0 ? worldSeed | 0 : randomSeed();
|
||||
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();
|
||||
this._buildTerrain();
|
||||
|
||||
this._resetCollections();
|
||||
for (let i = 0; i < this.civilisations.length; i++) {
|
||||
|
||||
@@ -12,6 +12,10 @@ export const serializationMethods = {
|
||||
serializeShared() {
|
||||
return {
|
||||
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,
|
||||
civs: this.civilisations.map((c) => ({ id: c.id, name: c.name })),
|
||||
protos: this.protoUnits.map((p) => ({ id: p.id, name: p.name })),
|
||||
|
||||
@@ -5,15 +5,90 @@
|
||||
import { ECONOMY } from "../data/economy.js";
|
||||
import { ROADS } from "../data/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 { 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 = {
|
||||
// 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() {
|
||||
const capitals = this._placeCapitals(this.civilisations.length);
|
||||
for (const site of this._placeCities(capitals)) this._spawnCity(site);
|
||||
// City sites and the road network depend only on the seed, the map config
|
||||
// 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._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++) {
|
||||
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
|
||||
// they are free nodes while the network is drawn; the chosen links are the
|
||||
// greedy spanner over every possible city-to-city route.
|
||||
_generateRoads() {
|
||||
_generateRoads(roadKeys = null) {
|
||||
for (const city of this.cities) {
|
||||
if (!city.improvements.includes(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) {
|
||||
|
||||
+23
-3
@@ -76,11 +76,17 @@ class MinHeap {
|
||||
// Dijkstra over the land tiles from `source`, costing each step through
|
||||
// `stepCost(coords, tile)`. Returns the distance to every reachable tile and
|
||||
// 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 distance = new Map([[sourceKey, 0]]);
|
||||
const parent = new Map();
|
||||
const settled = new Set();
|
||||
const remaining = targets ? new Set(targets) : null;
|
||||
if (remaining) remaining.delete(sourceKey);
|
||||
const heap = new MinHeap();
|
||||
heap.push(0, source);
|
||||
while (!heap.isEmpty()) {
|
||||
@@ -88,6 +94,10 @@ export function shortestPaths(source, topology, tiles, stepCost) {
|
||||
const currentKey = key(current.x, current.y);
|
||||
if (settled.has(currentKey)) continue;
|
||||
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)) {
|
||||
const nk = key(neighbour.x, neighbour.y);
|
||||
const tile = tiles[nk];
|
||||
@@ -154,8 +164,18 @@ export function buildRoadNetwork(cities, topology, tiles, options = {}) {
|
||||
for (const k of cityKeys) roads.add(k);
|
||||
if (cities.length < 2) return roads;
|
||||
|
||||
// Every possible link, with the cheapest route and its cost.
|
||||
const searches = cities.map((city) => shortestPaths(city.coords, topology, tiles, stepCost));
|
||||
// Every possible link, with the cheapest route and its cost. Each search may
|
||||
// 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 = [];
|
||||
for (let a = 0; a < cities.length; a++) {
|
||||
for (let b = a + 1; b < cities.length; b++) {
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { setupDom, teardownDom, FakeWebSocket } from "./framework/dom.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));
|
||||
|
||||
@@ -48,7 +48,7 @@ export class AppTest extends TestCase {
|
||||
|
||||
joinSocket.simulateMessage({ t: "civ_assigned", civ: 1 });
|
||||
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.assertFalse(env.$("#stat-pop").text() === "0");
|
||||
} finally {
|
||||
@@ -135,7 +135,7 @@ export class AppTest extends TestCase {
|
||||
this.assertEqual(setupMessage.setup.player_civ, 0);
|
||||
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.assertFalse(env.$("#stat-pop").text() === "0");
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TestCase } from "./framework/test_case.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 { PROTO_UNITS, CIVILISATIONS, STATUS_BATTLE, BATTLE } from "../shared/data.js";
|
||||
import { MapView } from "../client/js/map_view.js";
|
||||
@@ -488,7 +488,7 @@ export class CombatTest extends TestCase {
|
||||
}
|
||||
|
||||
async test_client_prediction_mirrors_occupancy() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const pair = findEmptyLandPair(state);
|
||||
const attacker = state._spawnUnit(pair.a, 0, 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() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const pair = findEmptyLandPair(state);
|
||||
const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]);
|
||||
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() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const pair = findEmptyLandPair(state);
|
||||
const attacker = state._spawnUnit(pair.a, 0, 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() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const { b } = engagement(state);
|
||||
|
||||
const env = await setupDom();
|
||||
|
||||
+25
-25
@@ -2,7 +2,7 @@ import { TestCase } from "./framework/test_case.js";
|
||||
import { setupDom, teardownDom } from "./framework/dom.js";
|
||||
import { GameScreen } from "../client/js/game_screen.js";
|
||||
import { BUILDINGS, CIVILISATIONS } from "../shared/data.js";
|
||||
import { defaultState, adjacentLand } from "./framework/helpers.js";
|
||||
import { smallState, adjacentLand } from "./framework/helpers.js";
|
||||
|
||||
function stubNetwork() {
|
||||
return {
|
||||
@@ -37,7 +37,7 @@ export class GameScreenTest extends TestCase {
|
||||
screen.enter();
|
||||
this.assertTrue(env.$("#screen-game").hasClass("active"));
|
||||
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
screen.onState(state.snapshot(0));
|
||||
this.assertEqual(env.$("#civ-name").text(), "France");
|
||||
this.assertEqual(env.$("#player-name").text(), "Carol");
|
||||
@@ -77,7 +77,7 @@ export class GameScreenTest extends TestCase {
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
screen.onState(defaultState().snapshot(0));
|
||||
screen.onState(smallState().snapshot(0));
|
||||
env.$("#civ-flag").click();
|
||||
this.assertFalse(env.$("#modal-nation").hasClass("hidden"));
|
||||
this.assertSize(env.$("#government-list .row-card"), 3);
|
||||
@@ -93,7 +93,7 @@ export class GameScreenTest extends TestCase {
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 1.0e15);
|
||||
screen.onState(state.snapshot(0));
|
||||
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
|
||||
@@ -120,7 +120,7 @@ export class GameScreenTest extends TestCase {
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
screen.onState(state.snapshot(0));
|
||||
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
|
||||
screen.map.onCitySelected(capital.id);
|
||||
@@ -143,7 +143,7 @@ export class GameScreenTest extends TestCase {
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 1.0e15);
|
||||
screen.onState(state.snapshot(0));
|
||||
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
|
||||
@@ -173,7 +173,7 @@ export class GameScreenTest extends TestCase {
|
||||
const network = stubNetwork();
|
||||
const screen = new GameScreen(network, makeConfig());
|
||||
screen.enter();
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
screen.onState(state.snapshot(0));
|
||||
const raw = state.units.find((u) => u.civ === 0);
|
||||
const unit = screen._unit(raw.id);
|
||||
@@ -204,7 +204,7 @@ export class GameScreenTest extends TestCase {
|
||||
const network = stubNetwork();
|
||||
const screen = new GameScreen(network, makeConfig());
|
||||
screen.enter();
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const home = state.units.find((u) => u.civ === 0);
|
||||
const artillery = state._spawnUnit(
|
||||
home.coords,
|
||||
@@ -268,7 +268,7 @@ export class GameScreenTest extends TestCase {
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
screen.onState(state.snapshot(0));
|
||||
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
|
||||
screen.map.onTileRequested(capital.coords);
|
||||
@@ -320,7 +320,7 @@ export class GameScreenTest extends TestCase {
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
screen.onState(defaultState().snapshot(0));
|
||||
screen.onState(smallState().snapshot(0));
|
||||
|
||||
const before = screen.map.camera.x;
|
||||
env.$(env.window).trigger(env.$.Event("keydown", { key: "d" }));
|
||||
@@ -353,7 +353,7 @@ export class GameScreenTest extends TestCase {
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
state.research.set(0, 1.0e6);
|
||||
state.budgets.set(0, 1.0e15);
|
||||
const method = state.technologies.findIndex((t) => t.id === "scientific_method");
|
||||
@@ -380,7 +380,7 @@ export class GameScreenTest extends TestCase {
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const mountain = state.landCells.find(
|
||||
(c) => state.tiles[`${c.x},${c.y}`].terrainType === "Mountain"
|
||||
);
|
||||
@@ -418,7 +418,7 @@ export class GameScreenTest extends TestCase {
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const plain = state.landCells.find(
|
||||
(c) => state.tiles[`${c.x},${c.y}`].terrainType === "Land" &&
|
||||
!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 screen = new GameScreen(network, makeConfig());
|
||||
screen.enter();
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
screen.onState(state.snapshot(0));
|
||||
const unit = state.units.find((u) => u.civ === 0);
|
||||
const goal = { x: 900, y: 900 };
|
||||
@@ -478,7 +478,7 @@ export class GameScreenTest extends TestCase {
|
||||
const network = stubNetwork();
|
||||
const screen = new GameScreen(network, makeConfig());
|
||||
screen.enter();
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
screen.onState(state.snapshot(0));
|
||||
const unit = state.units.find((u) => u.civ === 0);
|
||||
const goal = { x: 902, y: 902 };
|
||||
@@ -499,7 +499,7 @@ export class GameScreenTest extends TestCase {
|
||||
const network = stubNetwork();
|
||||
const screen = new GameScreen(network, makeConfig());
|
||||
screen.enter();
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
state.requestDeclareWar(0, 1);
|
||||
screen.onState(state.snapshot(0));
|
||||
const unit = state.units.find((u) => u.civ === 0);
|
||||
@@ -522,7 +522,7 @@ export class GameScreenTest extends TestCase {
|
||||
const network = stubNetwork();
|
||||
const screen = new GameScreen(network, makeConfig());
|
||||
screen.enter();
|
||||
screen.onState(defaultState().snapshot(0));
|
||||
screen.onState(smallState().snapshot(0));
|
||||
env.$("#civ-flag").click();
|
||||
env.$("#modal-nation .tab[data-tab=diplomacy]").click();
|
||||
const row = env.$("#diplomacy-list tbody tr").first();
|
||||
@@ -541,7 +541,7 @@ export class GameScreenTest extends TestCase {
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
state.requestDeclareWar(0, 1);
|
||||
screen.onState(state.snapshot(0));
|
||||
env.$("#news-button").click();
|
||||
@@ -558,7 +558,7 @@ export class GameScreenTest extends TestCase {
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
// The first snapshot is history: it must not flood the feed.
|
||||
screen.onState(state.snapshot(0));
|
||||
this.assertSize(env.$("#game-feed .feed-line"), 0);
|
||||
@@ -578,7 +578,7 @@ export class GameScreenTest extends TestCase {
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
screen.onState(state.snapshot(0));
|
||||
|
||||
state.requestDeclareWar(1, 0);
|
||||
@@ -598,7 +598,7 @@ export class GameScreenTest extends TestCase {
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const unit = state.units.find((u) => u.civ === 0);
|
||||
const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
|
||||
screen.onState(state.snapshot(0));
|
||||
@@ -627,7 +627,7 @@ export class GameScreenTest extends TestCase {
|
||||
const network = stubNetwork();
|
||||
const screen = new GameScreen(network, makeConfig());
|
||||
screen.enter();
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const unit = state.units.find((u) => u.civ === 0);
|
||||
const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
|
||||
screen.onState(state.snapshot(0));
|
||||
@@ -659,7 +659,7 @@ export class GameScreenTest extends TestCase {
|
||||
const network = stubNetwork();
|
||||
const screen = new GameScreen(network, makeConfig());
|
||||
screen.enter();
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const capital = state.units.find((u) => u.civ === 0);
|
||||
const infantry = state._spawnUnit(capital.coords, 0, state.protoUnits[0]);
|
||||
const artillery = state._spawnUnit(capital.coords, 0, state.protoUnits[2]);
|
||||
@@ -686,7 +686,7 @@ export class GameScreenTest extends TestCase {
|
||||
const network = stubNetwork();
|
||||
const screen = new GameScreen(network, makeConfig());
|
||||
screen.enter();
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const coords = findEnemyTile(state);
|
||||
this.assertNotNull(coords, "found an enemy tile");
|
||||
state.tilePopulation.set(`${coords.x},${coords.y}`, 100000);
|
||||
@@ -717,7 +717,7 @@ export class GameScreenTest extends TestCase {
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const unit = state.units.find((u) => u.civ === 0);
|
||||
screen.onState(state.snapshot(0));
|
||||
screen._selectUnits([unit.id]);
|
||||
|
||||
@@ -39,6 +39,22 @@ export class GameStateTest extends TestCase {
|
||||
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() {
|
||||
const state = smallState(["france", "britain", "poland"]);
|
||||
const mover = state.units.find((u) => u.civ === 0);
|
||||
|
||||
+30
-28
@@ -3,7 +3,7 @@ import { setupDom, teardownDom } from "./framework/dom.js";
|
||||
import { MapView, inwardOffset } from "../client/js/map_view.js";
|
||||
import { mapToLocal, COL_STEP } from "../shared/hex.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 {
|
||||
async build(state) {
|
||||
@@ -18,6 +18,8 @@ export class MapViewTest extends TestCase {
|
||||
}
|
||||
|
||||
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 { env, map, snapshot } = await this.build(state);
|
||||
try {
|
||||
@@ -39,7 +41,7 @@ export class MapViewTest extends TestCase {
|
||||
}
|
||||
|
||||
async test_renders_the_pre_generated_road_network() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const { env, map, snapshot } = await this.build(state);
|
||||
try {
|
||||
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() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const { env } = await this.build(state);
|
||||
try {
|
||||
const flags = env.$("#layer-entities .unit .unit-flag");
|
||||
@@ -68,7 +70,7 @@ export class MapViewTest extends TestCase {
|
||||
}
|
||||
|
||||
async test_centers_on_the_capital() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const { env, map } = await this.build(state);
|
||||
try {
|
||||
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() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const { env, map } = await this.build(state);
|
||||
try {
|
||||
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() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const { env, map } = await this.build(state);
|
||||
try {
|
||||
const coords = map.tileAtScreen(100000, 300);
|
||||
@@ -109,7 +111,7 @@ export class MapViewTest extends TestCase {
|
||||
}
|
||||
|
||||
async test_terrain_wraps_across_the_seam() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const { env, map } = await this.build(state);
|
||||
try {
|
||||
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() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const { env, map } = await this.build(state);
|
||||
try {
|
||||
// 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() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const { env, map } = await this.build(state);
|
||||
try {
|
||||
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() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const { env, map } = await this.build(state);
|
||||
try {
|
||||
const period = map._period;
|
||||
@@ -201,7 +203,7 @@ export class MapViewTest extends TestCase {
|
||||
}
|
||||
|
||||
async test_zooming_out_switches_to_the_overview() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const { env, map } = await this.build(state);
|
||||
try {
|
||||
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() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const { env, map } = await this.build(state);
|
||||
try {
|
||||
// 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() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const { env, map, snapshot } = await this.build(state);
|
||||
try {
|
||||
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() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const { env, map, snapshot } = await this.build(state);
|
||||
try {
|
||||
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() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const { env, map, snapshot } = await this.build(state);
|
||||
try {
|
||||
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() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const { env, map, snapshot } = await this.build(state);
|
||||
try {
|
||||
const unit = snapshot.units.find((u) => u.civ === 0);
|
||||
@@ -344,7 +346,7 @@ export class MapViewTest extends TestCase {
|
||||
}
|
||||
|
||||
async test_pan_moves_the_camera() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const { env, map } = await this.build(state);
|
||||
try {
|
||||
const beforeX = map.camera.x;
|
||||
@@ -358,7 +360,7 @@ export class MapViewTest extends TestCase {
|
||||
}
|
||||
|
||||
async test_syncs_and_draws_an_authoritative_path() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const unit = state.units.find((u) => u.civ === 0);
|
||||
const goal = adjacentLand(state, unit);
|
||||
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() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const unit = state.units.find((u) => u.civ === 0);
|
||||
const goal = adjacentLand(state, unit);
|
||||
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() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const { env, map, snapshot } = await this.build(state);
|
||||
try {
|
||||
let chosen = null;
|
||||
@@ -478,7 +480,7 @@ export class MapViewTest extends TestCase {
|
||||
}
|
||||
|
||||
async test_city_marker_click_reports_the_city() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const { env, map, snapshot } = await this.build(state);
|
||||
try {
|
||||
let chosen = null;
|
||||
@@ -495,7 +497,7 @@ export class MapViewTest extends TestCase {
|
||||
}
|
||||
|
||||
async test_clear_selection_notifies() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const { env, map, snapshot } = await this.build(state);
|
||||
try {
|
||||
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() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const unit = state.units.find((u) => u.civ === 0);
|
||||
const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
|
||||
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() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const unit = state.units.find((u) => u.civ === 0);
|
||||
const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
|
||||
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() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const unit = state.units.find((u) => u.civ === 0);
|
||||
const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
|
||||
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() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const unit = state.units.find((u) => u.civ === 0);
|
||||
const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
|
||||
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 goal = adjacentLand(state, unit);
|
||||
state._spawnUnit(goal, 1, state.protoUnits[0]);
|
||||
@@ -605,7 +607,7 @@ export class MapViewTest extends TestCase {
|
||||
}
|
||||
|
||||
async test_ctrl_right_click_schedules_a_destination() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const unit = state.units.find((u) => u.civ === 0);
|
||||
const goal = adjacentLand(state, unit);
|
||||
const { env, map } = await this.build(state);
|
||||
|
||||
+165
-116
@@ -1,127 +1,176 @@
|
||||
// Test runner. Instantiates every suite, calls its `test_*` methods and prints
|
||||
// a summary, exiting non-zero when anything fails. Run with:
|
||||
// Test runner. Every `tests/*_test.js` file is discovered automatically and run
|
||||
// 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 { NoiseTest } from "./noise_test.js";
|
||||
import { RngTest } from "./rng_test.js";
|
||||
import { DataTest } from "./data_test.js";
|
||||
import { MapGeneratorTest } from "./map_generator_test.js";
|
||||
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";
|
||||
import { readdirSync } from "node:fs";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname } from "node:path";
|
||||
import { cpus } from "node:os";
|
||||
|
||||
const SUITES = [
|
||||
HexTest,
|
||||
NoiseTest,
|
||||
RngTest,
|
||||
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,
|
||||
];
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const SELF = fileURLToPath(import.meta.url);
|
||||
const RESULT_MARKER = "__TEST_RESULT__";
|
||||
const MAX_JOBS = 8;
|
||||
|
||||
let passed = 0;
|
||||
const failures = [];
|
||||
function flagValue(args, flag) {
|
||||
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) {
|
||||
const instance = new Suite();
|
||||
const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(instance))
|
||||
.filter((name) => name.startsWith("test_") && name !== "constructor")
|
||||
// Every test file in this directory, in a stable order.
|
||||
function discoverFiles() {
|
||||
return readdirSync(HERE)
|
||||
.filter((name) => name.endsWith("_test.js"))
|
||||
.sort();
|
||||
for (const method of methods) {
|
||||
try {
|
||||
await instance[method]();
|
||||
passed += 1;
|
||||
} catch (error) {
|
||||
failures.push(`${Suite.name}::${method}: ${error.message}`);
|
||||
}
|
||||
|
||||
// The TestCase classes a module exports, with their sorted test methods. Using
|
||||
// the exports rather than a hand-written list means a new test file is picked
|
||||
// up with no runner edit.
|
||||
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("");
|
||||
if (failures.length === 0) {
|
||||
console.log(`PASS: ${passed} test(s) passed.`);
|
||||
process.exit(0);
|
||||
// Worker mode: run one file, then exit. When spawned by the parent it prints a
|
||||
// machine-readable marker; run by hand it prints the usual summary. The write
|
||||
// callback guarantees output reaches the parent before the process ends.
|
||||
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}`);
|
||||
process.exit(1);
|
||||
|
||||
function runChild(file) {
|
||||
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
@@ -1,6 +1,6 @@
|
||||
import { TestCase } from "./framework/test_case.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 { CIVILISATIONS, PROTO_UNITS, statusById, STATUS_ENCIRCLED, STATUS_BESIEGED } from "../shared/data.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() {
|
||||
const state = defaultState();
|
||||
const state = smallState();
|
||||
const city = findBesiegableCity(state, 0);
|
||||
this.assertNotNull(city, "found a city to besiege");
|
||||
const unit = state._spawnUnit(city.coords, 0, state.protoUnits[0]);
|
||||
|
||||
Reference in New Issue
Block a user