Files
Battle-for-Tismo/tests/map_view_test.js
T
2026-09-16 19:59:18 +02:00

387 lines
14 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, 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() {
const state = defaultState();
const { env, map, snapshot } = await this.build(state);
try {
this.assertSize(env.$("#layer-terrain .hex"), 64 * 40);
this.assertSize(env.$("#layer-territory .hex"), snapshot.territory.length);
this.assertSize(env.$("#layer-fog .hex"), 64 * 40 - snapshot.visible.length);
this.assertGreater(env.$("#layer-borders .border-seg").length, 0);
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_centers_on_the_capital() {
const state = defaultState();
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 = defaultState();
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 = defaultState();
const { env, map } = await this.build(state);
try {
const coords = map.tileAtScreen(100000, 300);
this.assert(coords.x >= -32 && coords.x <= 31, `x ${coords.x} wrapped`);
} finally {
teardownDom(env);
}
}
async test_terrain_wraps_across_the_seam() {
const state = defaultState();
const { env, map } = await this.build(state);
try {
this.assertGreater(map._period, 0, "the map has a wrap period");
const west = map._terrainWrap.find((item) => item.centerX < -1000);
this.assertNotNull(west, "found a west-edge tile");
const beforeShift = west.shift;
// Scrolling exactly one period must land on the same picture: every tile
// advances to its next wrapped copy, cancelling the camera movement.
map.panBy(map._period, 0);
this.assertApprox(west.shift, beforeShift + map._period, 1e-6);
this.assertTrue(
west.el.style.transform.includes(`translateX(${west.shift}px)`),
`expected translateX(${west.shift}px), got ${west.el.style.transform}`
);
} finally {
teardownDom(env);
}
}
async test_tiles_meet_seamlessly_at_the_wrap() {
const state = defaultState();
const { env, map } = await this.build(state);
try {
const eastX0 = Math.max(...map._terrainWrap.map((item) => item.centerX));
const westX0 = Math.min(...map._terrainWrap.map((item) => item.centerX));
const east = map._terrainWrap.find((item) => item.centerX === eastX0);
const west = map._terrainWrap.find((item) => item.centerX === westX0);
this.assertNotNull(east, "found an east-edge tile");
this.assertNotNull(west, "found a west-edge tile");
// Sit on the seam so the wrapped west-edge copy lands next to the east
// edge instead of leaving a gap.
map.camera.x = east.centerX;
map._applyCamera();
const eastX = east.centerX + east.shift;
const westX = west.centerX + west.shift;
this.assertApprox(westX - eastX, COL_STEP, 1e-6);
} finally {
teardownDom(env);
}
}
async test_dragging_wraps_with_transforms_not_layout() {
const state = defaultState();
const { env, map } = await this.build(state);
try {
const west = map._terrainWrap.find((item) => item.centerX < -1000);
const beforeLeft = west.el.style.left;
// The drag path updates the camera but defers per-element wrapping to the
// render loop; either way the element must not be laid out again.
map.camera.x += map._period;
map._applyCamera(false);
map.animate(0);
this.assertEqual(west.el.style.left, beforeLeft, "left is never rewritten");
this.assertTrue(west.el.style.transform.includes("translateX"));
} finally {
teardownDom(env);
}
}
async test_wrap_shift_picks_the_copy_nearest_the_camera() {
const state = defaultState();
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 = defaultState();
const { env, map } = await this.build(state);
try {
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.assertFalse(map.lod, "detailed at close zoom");
map.camera.zoom = 0.2;
map._applyCamera();
this.assertTrue(map.lod, "overview at far zoom");
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 = defaultState();
const { env, map } = await this.build(state);
try {
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 = defaultState();
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 = defaultState();
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 = 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_pan_moves_the_camera() {
const state = defaultState();
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 = defaultState();
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.
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 = (id, coords) => {
order = { id, 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.id, 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_left_click_selects_city_or_clears() {
const state = defaultState();
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 = defaultState();
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 = defaultState();
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);
}
}
}