Files
Battle-for-Tismo/tests/map_view_test.js
T
adrien 7ed6fe065f 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
2026-09-18 21:25:02 +02:00

629 lines
24 KiB
JavaScript

import { TestCase } from "./framework/test_case.js";
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, smallState, mapLayers, adjacentLand, seaTile } from "./framework/helpers.js";
export class MapViewTest extends TestCase {
async build(state) {
const env = await setupDom();
const map = new MapView(env.$("#map-viewport"), env.$("#map-world"), mapLayers(env.$));
map.$viewport.width = () => 800;
map.$viewport.height = () => 600;
map.setCatalogue(CIVILISATIONS, PROTO_UNITS);
const snapshot = state.snapshot(0);
map.applySnapshot(snapshot);
return { env, map, snapshot };
}
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 {
const rendered = [];
for (const rec of map._chunks.values()) rendered.push(...rec.tileKeys);
this.assertGreater(rendered.length, 0, "chunks near the camera are built");
this.assertLess(rendered.length, Object.keys(map.tiles).length, "the whole map is never built");
this.assertSize(env.$("#layer-terrain .hex"), rendered.length);
const visibleRendered = rendered.filter((k) => map.visible.has(k)).length;
this.assertSize(env.$("#layer-fog .hex"), rendered.length - visibleRendered);
this.assertGreater(env.$("#layer-borders .border-seg").length, 0);
this.assertSize(map.territory, snapshot.territory.length);
this.assertSize(env.$("#layer-entities .city"), snapshot.cities.length);
this.assertSize(env.$("#layer-entities .unit"), snapshot.units.length);
this.assertEqual(map.localCiv, 0);
} finally {
teardownDom(env);
}
}
async test_renders_the_pre_generated_road_network() {
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");
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
const capitalKey = `${capital.coords.x},${capital.coords.y}`;
this.assertTrue(map.roads.has(capitalKey), "a city carries a road");
this.assertGreater(env.$("#layer-roads .road-seg").length, 0, "roads are drawn in the chunks");
} finally {
teardownDom(env);
}
}
async test_unit_views_show_their_owners_flag() {
const state = smallState();
const { env } = await this.build(state);
try {
const flags = env.$("#layer-entities .unit .unit-flag");
this.assertSize(flags, state.units.length, "every unit carries a flag");
const unit = state.units[0];
const civ = CIVILISATIONS.find((c) => c.id === state.civilisations[unit.civ].id);
const src = env.$("#layer-entities .unit").first().find(".unit-flag").attr("src") || "";
this.assertTrue(src.endsWith(civ.flag), `the flag is the owner's: ${src}`);
} finally {
teardownDom(env);
}
}
async test_centers_on_the_capital() {
const state = smallState();
const { env, map } = await this.build(state);
try {
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
const local = mapToLocal(capital.coords.x, capital.coords.y);
this.assertApprox(map.camera.x, local.x, 1e-6);
this.assertApprox(map.camera.y, local.y, 1e-6);
this.assertTrue(env.$("#map-world").css("transform").includes("scale(1)"));
} finally {
teardownDom(env);
}
}
async test_tile_at_screen_round_trips() {
const state = smallState();
const { env, map } = await this.build(state);
try {
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
const local = mapToLocal(capital.coords.x, capital.coords.y);
const screenX = 400 + (local.x - map.camera.x) * map.camera.zoom;
const screenY = 300 + (local.y - map.camera.y) * map.camera.zoom;
this.assertEqual(map.tileAtScreen(screenX, screenY), capital.coords);
} finally {
teardownDom(env);
}
}
async test_wrapped_tile_lookup_stays_in_range() {
const state = smallState();
const { env, map } = await this.build(state);
try {
const coords = map.tileAtScreen(100000, 300);
const minX = map.topology.originX;
const maxX = map.topology.originX + map.topology.width - 1;
this.assert(coords.x >= minX && coords.x <= maxX, `x ${coords.x} wrapped`);
} finally {
teardownDom(env);
}
}
async test_terrain_wraps_across_the_seam() {
const state = smallState();
const { env, map } = await this.build(state);
try {
this.assertGreater(map._period, 0, "the map has a wrap period");
const rec = map._chunks.values().next().value;
this.assertNotNull(rec, "a chunk is materialised");
const chunkKey = rec.cx + "," + rec.cy;
const beforeShift = rec.shift;
// Scrolling exactly one period must land on the same picture: every chunk
// advances to its next wrapped copy, cancelling the camera movement.
map.panBy(map._period, 0);
const after = map._chunks.get(chunkKey);
this.assertNotNull(after, "the same chunk stays materialised one period over");
this.assertApprox(after.shift, beforeShift + map._period, 1e-6);
this.assertTrue(
after.terrainEl.style.transform.includes(`translateX(${after.x + after.shift}px)`),
`expected translateX(${after.x + after.shift}px), got ${after.terrainEl.style.transform}`
);
} finally {
teardownDom(env);
}
}
async test_chunks_meet_seamlessly_at_the_wrap() {
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
// and the wrapped copy of the west-edge chunk are on screen together.
const t = map.topology;
map.camera.x = (t.originX + t.width) * COL_STEP;
map.camera.y = 0;
map._applyCamera();
const rows = new Map();
for (const rec of map._chunks.values()) {
if (!rows.has(rec.cy)) rows.set(rec.cy, []);
rows.get(rec.cy).push(rec);
}
let row = null;
for (const list of rows.values()) {
if (!row || list.length > row.length) row = list;
}
this.assertGreater(row.length, 1, "several chunks share the seam row");
row.sort((a, b) => (a.x + a.shift) - (b.x + b.shift));
for (let i = 1; i < row.length; i++) {
const previousRight = row[i - 1].x + row[i - 1].shift + row[i - 1].px;
const currentLeft = row[i].x + row[i].shift;
this.assertApprox(currentLeft - previousRight, COL_STEP, 1e-6);
}
} finally {
teardownDom(env);
}
}
async test_dragging_wraps_with_transforms_not_layout() {
const state = smallState();
const { env, map } = await this.build(state);
try {
const rec = map._chunks.values().next().value;
const beforeLeft = rec.terrainEl.style.left;
// The drag path updates the camera but defers the chunk sync to the render
// loop; either way the chunk must be moved with a transform, not layout.
map.camera.x += map._period;
map._applyCamera(false);
map.animate(0);
this.assertEqual(rec.terrainEl.style.left, beforeLeft, "left is never rewritten");
this.assertTrue(rec.terrainEl.style.transform.includes("translateX"));
} finally {
teardownDom(env);
}
}
async test_wrap_shift_picks_the_copy_nearest_the_camera() {
const state = smallState();
const { env, map } = await this.build(state);
try {
const period = map._period;
map.camera.x = period;
this.assertEqual(map._wrapShift(0), period);
this.assertEqual(map._wrapShift(period), 0);
// A point already beside the camera is never shifted.
this.assertEqual(map._wrapShift(period - 10), 0);
} finally {
teardownDom(env);
}
}
async test_zooming_out_switches_to_the_overview() {
const state = smallState();
const { env, map } = await this.build(state);
try {
this.assertFalse(map.lod, "detailed at close zoom");
this.assertSize(env.$("#layer-overview .overview-block"), 0, "no overview until it is needed");
map.camera.zoom = 0.01;
map._applyCamera();
this.assertTrue(map.lod, "overview at far zoom");
this.assertGreater(
env.$("#layer-overview .overview-block").length,
0,
"overview blocks built"
);
this.assertLess(
env.$("#layer-overview .overview-block").length,
Object.keys(map.tiles).length,
"overview is simpler than the full map"
);
this.assertTrue(env.$("#map-world").hasClass("lod"));
this.assertTrue(env.$("#layer-terrain").hasClass("detail-layer"));
map.camera.zoom = 1;
map._applyCamera();
this.assertFalse(map.lod, "detailed again when zoomed in");
this.assertFalse(env.$("#map-world").hasClass("lod"));
} finally {
teardownDom(env);
}
}
async test_border_offset_points_towards_the_owner() {
const a = { x: 0, y: 0 };
const b = { x: 0, y: 10 };
this.assertEqual(inwardOffset(a, b, { x: -5, y: 5 }, 3), 3);
this.assertEqual(inwardOffset(a, b, { x: 5, y: 5 }, 3), -3);
}
async test_neighbouring_borders_are_inset_not_overlaid() {
const state = smallState();
const { env, map } = await this.build(state);
try {
// Bring the two-tile territory into view so its chunk is materialised.
map.camera.x = 0;
map.camera.y = 0;
map._applyCamera();
map.civilisations = [{ primaryColour: "#111111" }, { primaryColour: "#eeeeee" }];
map.territory = new Map([["0,0", 0], ["1,0", 1]]);
map._renderBorders();
// Each segment is nudged towards its owner's tile, so the two sides of a
// shared edge sit next to each other instead of painting over each other.
const segments = env.$("#layer-borders .border-seg").toArray();
this.assertGreater(segments.length, 0);
for (const segment of segments) {
this.assert(
segment.style.transform.includes("translateY"),
`expected an inset border, got ${segment.style.transform}`
);
}
} finally {
teardownDom(env);
}
}
async test_selecting_a_unit_marks_its_view() {
const state = smallState();
const { env, map, snapshot } = await this.build(state);
try {
const unit = snapshot.units.find((u) => u.civ === 0);
map.setSelectedUnit(unit.id);
this.assertSize(env.$("#layer-entities .unit.selected"), 1);
this.assertEqual(map.unitAt({ x: unit.coords[0], y: unit.coords[1] }), unit.id);
} finally {
teardownDom(env);
}
}
async test_predict_move_starts_a_local_route() {
const state = smallState();
const { env, map, snapshot } = await this.build(state);
try {
const unit = snapshot.units.find((u) => u.civ === 0);
const stateUnit = state.units.find((u) => u.id === unit.id);
const goal = adjacentLand(state, stateUnit);
const path = await map.predictMove(unit, goal);
this.assertNotNull(path, "a route was predicted");
this.assertGreaterOrEqual(path.length, 2);
const motion = map._unitMotion.get(unit.id);
this.assertTrue(motion.predicting, "the route is marked as a prediction");
this.assertSize(motion.segmentHours, path.length - 1);
map.animate(0.25);
this.assertGreater(motion.pathHours, 0, "the predicted route advances");
} finally {
teardownDom(env);
}
}
async test_plan_move_computes_a_route_without_predicting() {
const state = smallState();
const { env, map, snapshot } = await this.build(state);
try {
const unit = snapshot.units.find((u) => u.civ === 0);
const stateUnit = state.units.find((u) => u.id === unit.id);
const goal = adjacentLand(state, stateUnit);
const before = map._unitMotion.get(unit.id);
const route = await map.planMove(unit, goal);
this.assertNotNull(route, "a route was planned");
this.assertGreaterOrEqual(route.length, 2);
this.assertEqual(map._unitMotion.get(unit.id), before, "planning does not start a prediction");
this.assertFalse(map._unitMotion.get(unit.id).predicting);
const applied = await map.predictMove(unit, goal);
this.assertEqual(applied, route, "predicting follows the planned route");
this.assertTrue(map._unitMotion.get(unit.id).predicting);
} finally {
teardownDom(env);
}
}
async test_plan_move_reuses_a_shared_route() {
const state = smallState();
const { env, map, snapshot } = await this.build(state);
try {
const unit = snapshot.units.find((u) => u.civ === 0);
const stateUnit = state.units.find((u) => u.id === unit.id);
const goal = adjacentLand(state, stateUnit);
const cache = new Map();
let searches = 0;
const original = map._search.bind(map);
map._search = (...args) => {
searches += 1;
return original(...args);
};
const first = await map.planMove(unit, goal, null, cache);
const second = await map.planMove(unit, goal, null, cache);
this.assertEqual(searches, 1, "the second plan reuses the cached search");
this.assertTrue(first === second, "the same route is handed back");
} finally {
teardownDom(env);
}
}
async test_pan_moves_the_camera() {
const state = smallState();
const { env, map } = await this.build(state);
try {
const beforeX = map.camera.x;
const beforeY = map.camera.y;
map.panBy(10, -5);
this.assertApprox(map.camera.x, beforeX + 10, 1e-9);
this.assertApprox(map.camera.y, beforeY - 5, 1e-9);
} finally {
teardownDom(env);
}
}
async test_syncs_and_draws_an_authoritative_path() {
const state = smallState();
const unit = state.units.find((u) => u.civ === 0);
const goal = adjacentLand(state, unit);
state.requestMove(unit.id, goal);
const { env, map, snapshot } = await this.build(state);
try {
map.setSelectedUnit(unit.id);
map.animate(0.5);
this.assertGreater(map._unitMotion.get(unit.id).pathHours, 0, "local time advances");
this.assertGreater(env.$("#layer-paths .border-seg").length, 0, "the route is drawn");
// Re-syncing the same path keeps the local clock (no visible yank).
map.applySnapshot(snapshot);
this.assertGreaterOrEqual(map._unitMotion.get(unit.id).path.length, 2);
// Predicting from a unit that already walks a path reads its position.
await map.predictMove(snapshot.units.find((u) => u.id === unit.id), adjacentLand(state, unit));
this.assertTrue(map._unitMotion.get(unit.id).predicting);
} finally {
teardownDom(env);
}
}
async test_click_handling_selects_and_orders() {
const state = defaultState();
const { env, map, snapshot } = await this.build(state);
try {
const unit = snapshot.units.find((u) => u.civ === 0);
let selected = -1;
map.onUnitSelected = (id) => {
selected = id;
};
map._onLeftClick({ x: unit.coords[0], y: unit.coords[1] });
this.assertEqual(selected, unit.id);
let order = null;
map.onMoveOrdered = (ids, coords) => {
order = { ids, coords };
};
map.setSelectedUnit(unit.id);
const stateUnit = state.units.find((u) => u.id === unit.id);
const goal = adjacentLand(state, stateUnit);
map._onRightClick(goal);
this.assertEqual(order.ids, [unit.id]);
this.assertEqual(order.coords, goal);
let tile = null;
map.onTileRequested = (coords) => {
tile = coords;
};
map.clearSelection();
map._onRightClick(goal);
this.assertEqual(tile, goal);
} finally {
teardownDom(env);
}
}
async test_right_click_on_a_unit_icon_orders_the_selected_stack() {
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]);
const { env, map } = await this.build(state);
try {
let moved = null;
map.onMoveOrdered = (ids, coords) => {
moved = { ids, coords };
};
map.setSelectedUnit(unit.id);
const local = mapToLocal(goal.x, goal.y);
const clientX = 400 + (local.x - map.camera.x) * map.camera.zoom;
const clientY = 300 + (local.y - map.camera.y) * map.camera.zoom;
const view = map._unitViews.get(target.id);
this.assertNotNull(view, "the target unit has a view");
// Real bubbling events, so the icon's own native handler is exercised:
// it must let right-clicks reach the viewport's mousedown listener.
const press = new env.window.MouseEvent("mousedown", { button: 2, bubbles: true, clientX, clientY });
view.dispatchEvent(press);
const release = new env.window.MouseEvent("mouseup", { button: 2, bubbles: true, clientX, clientY });
env.window.dispatchEvent(release);
this.assertNotNull(moved, "right-clicking an icon gives the order");
this.assertEqual(moved.ids, [unit.id]);
this.assertEqual(moved.coords, goal);
} finally {
teardownDom(env);
}
}
async test_left_click_selects_city_or_clears() {
const state = smallState();
const { env, map, snapshot } = await this.build(state);
try {
let chosen = null;
map.onCitySelected = (id) => {
chosen = id;
};
// A non-capital city tile has no unit on it.
const city = snapshot.cities.find((c) => c.civ === 0 && !c.isCapital);
const coords = { x: city.coords[0], y: city.coords[1] };
map._onLeftClick(coords);
this.assertEqual(chosen, city.id);
this.assertEqual(map.cityAt(coords).id, city.id);
let cleared = -1;
map.onUnitSelected = (id) => {
cleared = id;
};
map.setSelectedUnit(snapshot.units[0].id);
const sea = seaTile(state);
map._onLeftClick(sea);
this.assertEqual(cleared, 0, "clicking empty ground clears the selection");
this.assertEqual(map.unitAt(sea), 0);
this.assertNull(map.cityAt(sea));
} finally {
teardownDom(env);
}
}
async test_city_marker_click_reports_the_city() {
const state = smallState();
const { env, map, snapshot } = await this.build(state);
try {
let chosen = null;
map.onCitySelected = (id) => {
chosen = id;
};
const owned = env.$("#layer-entities .city[data-owned='1']");
this.assertGreater(owned.length, 0);
owned.first().find(".marker").click();
this.assertEqual(chosen, snapshot.cities.find((c) => c.civ === 0).id);
} finally {
teardownDom(env);
}
}
async test_clear_selection_notifies() {
const state = smallState();
const { env, map, snapshot } = await this.build(state);
try {
const unit = snapshot.units.find((u) => u.civ === 0);
map.setSelectedUnit(unit.id);
let selected = -1;
map.onUnitSelected = (id) => {
selected = id;
};
map.clearSelection();
this.assertEqual(selected, 0);
this.assertSize(env.$("#layer-entities .unit.selected"), 0);
} finally {
teardownDom(env);
}
}
async test_stacked_units_show_one_icon_with_a_count_badge() {
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);
try {
const first = map._unitViews.get(unit.id);
const other = map._unitViews.get(second.id);
this.assertEqual(first.querySelector(".unit-count").style.display, "block");
this.assertEqual(first.querySelector(".unit-count").textContent, "2");
this.assertEqual(other.style.visibility, "hidden", "the follower is not drawn");
const ids = map.unitIdsAt({ x: unit.coords.x, y: unit.coords.y }).sort((a, b) => a - b);
this.assertEqual(ids, [unit.id, second.id].sort((a, b) => a - b), "both are selectable");
} finally {
teardownDom(env);
}
}
async test_right_click_on_a_friendly_stack_opens_the_menu() {
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);
try {
let menu = null;
map.onStackMenu = (coords, ids, x, y) => {
menu = { coords, ids, x, y };
};
map._onRightClick({ x: unit.coords.x, y: unit.coords.y }, { clientX: 11, clientY: 22 });
this.assertNotNull(menu, "the stack menu opens");
this.assertEqual(menu.ids.sort((a, b) => a - b), [unit.id, second.id].sort((a, b) => a - b));
this.assertEqual(menu.x, 11);
this.assertEqual(menu.y, 22);
} finally {
teardownDom(env);
}
}
async test_stack_ids_group_units_sharing_a_tile() {
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);
try {
const ids = map.stackIds(unit.id).sort((a, b) => a - b);
this.assertEqual(ids, [unit.id, second.id].sort((a, b) => a - b));
} finally {
teardownDom(env);
}
}
async test_right_click_on_a_friendly_stack_while_selected_moves_there() {
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);
try {
let menu = null;
let moved = null;
map.onStackMenu = () => { menu = true; };
map.onMoveOrdered = (ids, coords) => { moved = { ids, coords }; };
map.setSelectedUnit(second.id);
map._onRightClick({ x: unit.coords.x, y: unit.coords.y }, {});
this.assertNull(menu, "the stack menu does not open over a selection");
this.assertNotNull(moved, "the selected stack is ordered onto the friendly tile");
this.assertEqual(moved.ids, [second.id]);
this.assertEqual(moved.coords, { x: unit.coords.x, y: unit.coords.y });
} finally {
teardownDom(env);
}
}
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]);
const { env, map } = await this.build(state);
try {
let attack = null;
let move = null;
map.onAttackOrdered = (ids, coords) => { attack = { ids, coords }; };
map.onMoveOrdered = (ids, coords) => { move = { ids, coords }; };
map.setSelectedUnit(unit.id);
map._onRightClick(goal, {});
this.assertNotNull(attack, "the enemy stack is attacked");
this.assertNull(move, "the enemy stack is not treated as a plain move");
this.assertEqual(attack.coords, goal);
} finally {
teardownDom(env);
}
}
async test_ctrl_right_click_schedules_a_destination() {
const state = smallState();
const unit = state.units.find((u) => u.civ === 0);
const goal = adjacentLand(state, unit);
const { env, map } = await this.build(state);
try {
let schedule = null;
let move = null;
map.onScheduleOrdered = (ids, coords) => { schedule = { ids, coords }; };
map.onMoveOrdered = (ids, coords) => { move = { ids, coords }; };
map.setSelectedUnit(unit.id);
map._onRightClick(goal, { ctrlKey: true });
this.assertNotNull(schedule, "Ctrl-right-click queues a destination");
this.assertNull(move, "a scheduled click is not a plain move");
this.assertEqual(schedule.coords, goal);
} finally {
teardownDom(env);
}
}
}