import { teardownDom } from "./framework/dom.js"; import { MapViewFixture } from "./framework/map_view_fixture.js"; import { mapToLocal, COL_STEP, HEX_W, parseKey } from "../shared/hex.js"; import { CAMERA_TILT, HEIGHT_LIFT } from "../client/js/map_view/constants.js"; import { smallState, adjacentLand, seaTile } from "./framework/helpers.js"; import { TREATY_ALLIANCE } from "../shared/data.js"; export class MapViewInputTest extends MapViewFixture { 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_click_handling_selects_and_orders() { const state = smallState(); 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 * CAMERA_TILT; 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_selected_tile_is_outlined() { const state = smallState(); const { env, map } = await this.build(state); try { map.setSelectedTile({ x: 0, y: 0 }); const hex = env.$("#layer-highlight .tile-selection"); this.assertSize(hex, 1, "the outline is created"); this.assertTrue(hex.hasClass("active"), "the outline is shown"); const points = (hex.find("polygon").attr("points") || "").split(" "); this.assertSize(points, 6, "a hexagon outline"); const wrapped = map._wrappedPosition(mapToLocal(0, 0)); this.assertApprox(parseFloat(hex[0].style.left), wrapped.x - HEX_W / 2, 1e-6); map.clearSelectedTile(); this.assertFalse(hex.hasClass("active"), "the outline is hidden again"); } finally { teardownDom(env); } } async test_selected_tile_reports_its_region() { const state = smallState(); const { env, map } = await this.build(state); try { this.assertGreater(map.regions.size, 0, "regions are mirrored from the snapshot"); const [tileKey, cityId] = map.regions.entries().next().value; map.setSelectedTile(parseKey(tileKey)); this.assertEqual(map._selectedRegion, cityId, "the tile's region is selected as a whole"); map.clearSelectedTile(); this.assertNull(map._selectedRegion, "clearing drops the region"); } finally { teardownDom(env); } } async test_shift_click_toggles_one_unit_instead_of_the_whole_stack() { const state = smallState(); const { env, map, snapshot } = await this.build(state); try { const unit = snapshot.units.find((u) => u.civ === 0); let selected = -1; let toggled = -1; map.onUnitSelected = (id) => { selected = id; }; map.onUnitToggled = (id) => { toggled = id; }; const coords = { x: unit.coords[0], y: unit.coords[1] }; map._onLeftClick(coords, { shiftKey: true }); this.assertEqual(toggled, unit.id, "shift-click asks to toggle that one unit"); this.assertEqual(selected, -1, "and does not grab the whole stack"); map._onLeftClick(coords, {}); this.assertEqual(selected, unit.id, "a plain click still takes the stack"); } finally { teardownDom(env); } } async test_prediction_crosses_allied_and_at_war_land_but_not_a_neutral() { const state = smallState(["france", "britain", "poland"]); const { env, map } = await this.build(state); try { const proto = map.protoUnits[0]; const occupied = (c) => Array.from(map._unitData.values()).some((u) => u.coords[0] === c.x && u.coords[1] === c.y); const tile = Object.keys(map.tiles) .map((k) => parseKey(k)) .find( (c) => map.tiles[`${c.x},${c.y}`].terrainClass === "Land" && !map._cityCiv.has(`${c.x},${c.y}`) && !occupied(c) ); this.assertNotNull(tile, "the fixture has an empty land tile"); map.territory.set(`${tile.x},${tile.y}`, 2); this.assertFalse(map._canEnter(0, proto, tile), "a neutral third party's land is a wall"); state.requestDeclareWar(0, 2); map.applySnapshot(state.snapshot(0)); this.assertTrue(map._isAtWar(0, 2), "the war is mirrored for prediction"); this.assertTrue(map._canEnter(0, proto, tile), "a nation at war may be crossed"); const allied = smallState(["france", "britain", "poland"]); const { env: env2, map: map2 } = await this.build(allied); try { allied.requestProposeTreaty(0, 2, TREATY_ALLIANCE); allied.requestRespondTreaty(2, allied.treatyProposals[0].id, true); map2.applySnapshot(allied.snapshot(0)); map2.territory.set(`${tile.x},${tile.y}`, 2); this.assertTrue(map2._isAllied(0, 2), "the alliance is mirrored for prediction"); this.assertTrue(map2._canEnter(0, proto, tile), "an ally's land may be crossed"); } finally { teardownDom(env2); } } finally { teardownDom(env); } } async test_shift_mousedown_on_a_unit_icon_detaches_that_unit() { const state = smallState(); const { env, map, snapshot } = await this.build(state); try { const unit = snapshot.units.find((u) => u.civ === 0); let toggled = -1; let selected = -1; map.onUnitToggled = (id) => { toggled = id; }; map.onUnitSelected = (id) => { selected = id; }; const view = map._unitViews.get(unit.id); this.assertNotNull(view, "the unit has a view"); view.dispatchEvent( new env.window.MouseEvent("mousedown", { button: 0, shiftKey: true, bubbles: true }) ); this.assertEqual(toggled, unit.id, "shift-mousedown detaches that one unit"); this.assertEqual(selected, -1, "it does not grab the whole stack"); view.dispatchEvent(new env.window.MouseEvent("mousedown", { button: 0, bubbles: true })); this.assertEqual(selected, unit.id, "a plain mousedown still takes the stack"); } 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_is_a_plain_move() { 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 move = null; map.onMoveOrdered = (ids, coords) => { move = { ids, coords }; }; map.setSelectedUnit(unit.id); map._onRightClick(goal, {}); this.assertNotNull(move, "a right-click is a move, never a ranged attack"); this.assertEqual(move.coords, goal); } finally { teardownDom(env); } } async test_left_click_in_target_mode_chooses_the_target() { 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 chosen = null; let selected = null; map.onTargetChosen = (coords) => { chosen = coords; }; map.onTargetCancelled = () => map.clearTargeting(); map.onUnitSelected = () => { selected = true; }; map.beginTargeting({ unitIds: [unit.id], origin: { x: unit.coords.x, y: unit.coords.y }, range: 3, }); map._onLeftClick(goal); this.assertEqual(chosen, goal, "the left-click picks the target tile"); this.assertNull(selected, "the click does not also select a unit"); this.assertNotNull(map._targeting, "target mode stays on until the controller clears it"); map._onRightClick(goal, {}); this.assertNull(map._targeting, "a right-click cancels target mode"); } finally { teardownDom(env); } } async test_a_unit_icon_does_not_steal_a_target_click() { const state = smallState(); const attacker = state.units.find((u) => u.civ === 0); const goal = adjacentLand(state, attacker); const defender = state._spawnUnit(goal, 1, state.protoUnits[0]); const { env, map } = await this.build(state); try { let selected = -1; let reachedMap = false; map.onUnitSelected = (id) => { selected = id; }; map.$viewport.on("mousedown", () => { reachedMap = true; }); map.beginTargeting({ unitIds: [attacker.id], origin: attacker.coords, range: 3 }); const view = map._unitViews.get(defender.id); this.assertNotNull(view, "the defender has a view"); // Mousedown on the enemy icon while picking a target must fall through to // the map, not select the enemy (which would clear the attacker). view.dispatchEvent(new env.window.MouseEvent("mousedown", { button: 0, bubbles: true })); this.assertEqual(selected, -1, "the target icon does not steal the click"); this.assertTrue(reachedMap, "the click reaches the map so the target is chosen"); } 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); } } async test_a_moving_unit_splits_from_its_stack_at_once() { 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 tile = { x: unit.coords[0], y: unit.coords[1] }; map._unitMotion.get(unit.id).path = [tile, { x: tile.x + 1, y: tile.y }]; map._unitMotion.get(unit.id).segmentHours = [1]; map._unitMotion.get(unit.id).pathHours = 0.1; map._assignStackOffsets(); this.assertEqual( map._unitViews.get(unit.id).style.visibility, "", "the moving unit is drawn on its own" ); this.assertEqual( map._unitViews.get(second.id).style.visibility, "", "the unit left behind stays visible" ); this.assertEqual( map._unitViews.get(second.id).querySelector(".unit-count").style.display, "none", "the stack badge is gone the moment one member leaves" ); } finally { teardownDom(env); } } }