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(map.territory, 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_unit_views_show_their_owners_flag() { const state = defaultState(); 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 = 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_plan_move_computes_a_route_without_predicting() { 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 before = map._unitMotion.get(unit.id); const route = 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 = 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_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); } } }