- Political map country names were invisible because the label SVG had a zero-sized viewport, which some browsers never paint; size it to the world and guard it with a test. - Added a Devlog button showing the ten latest commits, generated into client/js/devlog_data.js by scripts/generate-devlog.js and refreshed by the pre-commit hook. - Worked through FIXME.md: the city button and a unit double-click now open the city, and the stat tooltip escapes the HUD so the unit panel cannot cover it. - The server creates its game at startup and clients only join it; the Create game screen and the configure_game path are gone, and testing is a --testing server flag. - A page on a loopback host signs in as adrien, claims France and gets the free, instant testing buttons, while the server grants free orders only to a loopback socket whose Origin is also loopback.
253 lines
11 KiB
JavaScript
253 lines
11 KiB
JavaScript
import { TestCase } from "./framework/test_case.js";
|
|
import { setupDom, teardownDom } from "./framework/dom.js";
|
|
import { MapView } from "../client/js/map_view.js";
|
|
import { MapTopology, key, parseKey } from "../shared/hex.js";
|
|
import { CIVILISATIONS, PROTO_UNITS } from "../shared/data.js";
|
|
import { politicalMode, POLITICAL_ZOOM } from "../client/js/map_view/constants.js";
|
|
import {
|
|
countryLabels,
|
|
fitLabelBand,
|
|
fitTextWidth,
|
|
territoryComponents,
|
|
MAX_KERNING,
|
|
MAX_LABEL_ANGLE,
|
|
MIN_FONT,
|
|
} from "../client/js/map_view/political.js";
|
|
import { smallState, mapLayers } from "./framework/helpers.js";
|
|
|
|
const CIVS = [
|
|
{ id: "a", name: "France", primaryColour: "#123456" },
|
|
{ id: "b", name: "Britain", primaryColour: "#abcdef" },
|
|
];
|
|
|
|
function territoryOf(entries) {
|
|
return new Map(entries.map(([x, y, civ]) => [key(x, y), civ]));
|
|
}
|
|
|
|
export class PoliticalMapTest extends TestCase {
|
|
async test_components_split_by_owner_and_contiguity() {
|
|
const topo = new MapTopology({ x: 8, y: 8 }, false);
|
|
const territory = territoryOf([
|
|
[0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0],
|
|
[5, 5, 0],
|
|
[0, 3, 1], [1, 3, 1],
|
|
]);
|
|
const components = territoryComponents(territory, topo);
|
|
this.assertSize(components, 3, "one holding per contiguous run and owner");
|
|
const sizes = components.map((c) => c.points.length).sort((a, b) => a - b);
|
|
this.assertEqual(sizes, [1, 2, 4], "each holding keeps its own tiles");
|
|
for (const component of components) {
|
|
this.assertGreater(component.points.length, 0, "every holding has points");
|
|
}
|
|
}
|
|
|
|
async test_a_holding_across_the_wrap_is_unwrapped() {
|
|
const topo = new MapTopology({ x: 8, y: 4 }, true);
|
|
// On a width-8 cylindrical world the columns are -4..3, so 3 and -4 are the
|
|
// two sides of the seam and are neighbours.
|
|
const territory = territoryOf([[3, 0, 0], [-4, 0, 0], [-4, 1, 0]]);
|
|
const components = territoryComponents(territory, topo);
|
|
this.assertSize(components, 1, "the seam does not split the holding");
|
|
const points = components[0].points;
|
|
let span = 0;
|
|
for (const a of points) {
|
|
for (const b of points) span = Math.max(span, Math.hypot(a.x - b.x, a.y - b.y));
|
|
}
|
|
this.assertLess(span, 4 * 63, "the wrapped tiles are placed side by side");
|
|
}
|
|
|
|
async test_a_wide_holding_is_labelled_horizontally() {
|
|
const topo = new MapTopology({ x: 10, y: 8 }, false);
|
|
const entries = [];
|
|
for (let y = 0; y < 2; y++) {
|
|
for (let x = 0; x < 6; x++) entries.push([x, y, 0]);
|
|
}
|
|
const band = fitLabelBand(territoryComponents(territoryOf(entries), topo)[0].points);
|
|
this.assertNotNull(band, "a block gets a band");
|
|
this.assertLess(Math.abs(band.angle), 0.35, "the long axis is horizontal");
|
|
this.assertGreater(band.fontSize, 0, "the text has a size");
|
|
this.assertGreater(band.maxLength, 0, "the text has a length");
|
|
}
|
|
|
|
async test_a_tall_holding_is_never_rotated_far_from_horizontal() {
|
|
const topo = new MapTopology({ x: 8, y: 10 }, false);
|
|
const entries = [];
|
|
for (let y = 0; y < 6; y++) {
|
|
for (let x = 0; x < 2; x++) entries.push([x, y, 0]);
|
|
}
|
|
const band = fitLabelBand(territoryComponents(territoryOf(entries), topo)[0].points);
|
|
this.assertNotNull(band, "a tall block gets a band");
|
|
this.assertLess(Math.abs(band.angle), MAX_LABEL_ANGLE + 1e-6,
|
|
"the base angle stays inside the readable range");
|
|
}
|
|
|
|
async test_text_is_never_compressed_and_kerning_is_capped() {
|
|
// A run already wider than the band shrinks the font instead of squeezing
|
|
// the letters together.
|
|
const shrunk = fitTextWidth(300, 20, 200);
|
|
this.assertApprox(shrunk.fontSize, (20 * 200) / 300, 1e-9, "the font shrinks to fit");
|
|
this.assertNull(shrunk.textLength, "no spacing is forced when the font shrank");
|
|
|
|
// A short run is opened up, but never by more than MAX_KERNING times.
|
|
const widened = fitTextWidth(50, 20, 900);
|
|
this.assertApprox(widened.fontSize, 20, 1e-9, "the font is untouched");
|
|
this.assertApprox(widened.textLength, 50 * MAX_KERNING, 1e-9, "kerning is capped");
|
|
|
|
// A run that already fits and needs no help is left alone.
|
|
const natural = fitTextWidth(100, 20, 100);
|
|
this.assertNull(natural.textLength, "a fitting run keeps its natural spacing");
|
|
}
|
|
|
|
async test_the_label_keeps_a_margin_off_the_holding() {
|
|
const topo = new MapTopology({ x: 12, y: 10 }, false);
|
|
const entries = [];
|
|
for (let y = 0; y < 4; y++) {
|
|
for (let x = 0; x < 9; x++) entries.push([x, y, 0]);
|
|
}
|
|
const component = territoryComponents(territoryOf(entries), topo)[0];
|
|
const band = fitLabelBand(component.points);
|
|
this.assertNotNull(band, "the block gets a band");
|
|
const axis = Math.cos(band.angle);
|
|
const projected = component.points
|
|
.map((p) => p.x * axis + p.y * Math.sin(band.angle))
|
|
.sort((a, b) => a - b);
|
|
const span = projected[projected.length - 1] - projected[0];
|
|
this.assertLess(band.maxLength, span, "the text run stops short of the extremes");
|
|
this.assertGreater(band.fontSize, MIN_FONT, "the text is still readable");
|
|
}
|
|
|
|
async test_the_name_is_drawn_once_per_contiguous_holding() {
|
|
const topo = new MapTopology({ x: 10, y: 10 }, false);
|
|
const entries = [];
|
|
for (let y = 0; y < 3; y++) {
|
|
for (let x = 0; x < 4; x++) entries.push([x, y, 0]);
|
|
}
|
|
// A second, separate holding of the same nation.
|
|
for (let y = 6; y < 8; y++) {
|
|
for (let x = 6; x < 9; x++) entries.push([x, y, 0]);
|
|
}
|
|
// A holding of another nation, away from the first.
|
|
for (let y = 5; y < 8; y++) {
|
|
for (let x = 0; x < 4; x++) entries.push([x, y, 1]);
|
|
}
|
|
const labels = countryLabels(territoryOf(entries), CIVS, topo);
|
|
this.assertSize(labels, 3, "one label per contiguous holding");
|
|
const names = labels.map((label) => label.name).sort();
|
|
this.assertEqual(names, ["Britain", "France", "France"], "the owner names are used");
|
|
const france = labels.filter((label) => label.civ === 0);
|
|
this.assertEqual(france.length, 2, "the split holding is labelled twice");
|
|
}
|
|
|
|
async test_a_label_sits_inside_its_holding() {
|
|
const topo = new MapTopology({ x: 12, y: 10 }, false);
|
|
const entries = [];
|
|
for (let y = 1; y < 5; y++) {
|
|
for (let x = 2; x < 9; x++) entries.push([x, y, 0]);
|
|
}
|
|
const territory = territoryOf(entries);
|
|
const component = territoryComponents(territory, topo)[0];
|
|
const band = fitLabelBand(component.points);
|
|
this.assertNotNull(band, "the block gets a band");
|
|
const xs = component.points.map((p) => p.x);
|
|
const ys = component.points.map((p) => p.y);
|
|
this.assertTrue(band.x >= Math.min(...xs) - 63 && band.x <= Math.max(...xs) + 63,
|
|
"the label anchors over the holding");
|
|
this.assertTrue(band.y >= Math.min(...ys) - 56 && band.y <= Math.max(...ys) + 56,
|
|
"the label anchors over the holding");
|
|
// The band height is bounded by the room across the holding.
|
|
this.assertLess(band.fontSize, 4 * 56, "the text fits across the holding");
|
|
}
|
|
|
|
async test_tiny_holdings_get_no_name() {
|
|
const topo = new MapTopology({ x: 6, y: 6 }, false);
|
|
const labels = countryLabels(territoryOf([[0, 0, 0]]), CIVS, topo);
|
|
this.assertEmpty(labels, "a lone tile is not labelled");
|
|
}
|
|
|
|
async test_political_mode_matches_the_zoom_threshold() {
|
|
this.assertTrue(politicalMode(POLITICAL_ZOOM), "the threshold itself is political");
|
|
this.assertTrue(politicalMode(0.1), "far out is political");
|
|
this.assertFalse(politicalMode(POLITICAL_ZOOM + 0.01), "closer in is terrain");
|
|
}
|
|
|
|
// A zero-sized outer SVG is not painted at all in some browsers, however
|
|
// visible its overflow, so the viewport has to have a real size or the names
|
|
// are drawn but never seen.
|
|
async test_the_country_name_svg_has_a_non_zero_viewport() {
|
|
const state = smallState();
|
|
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);
|
|
try {
|
|
map.applySnapshot(state.snapshot(0));
|
|
const entries = [];
|
|
for (let y = 2; y < 5; y++) {
|
|
for (let x = 2; x < 7; x++) entries.push([x, y, 0]);
|
|
}
|
|
map._applyTerritory(entries);
|
|
map.setMapMode("political");
|
|
const svg = env.$("#layer-political-labels svg");
|
|
this.assertSize(svg, 1, "the country-name svg is built");
|
|
this.assertGreater(Number(svg.attr("width")), 0, "the viewport has a width");
|
|
this.assertGreater(Number(svg.attr("height")), 0, "the viewport has a height");
|
|
this.assertGreater(env.$("#layer-political-labels text").length, 0, "a name is drawn");
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
|
|
async test_the_dom_map_paints_country_colours_and_clears_them() {
|
|
const state = smallState();
|
|
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);
|
|
try {
|
|
map.applySnapshot(snapshot);
|
|
this.assertGreater(map.territory.size, 0, "the snapshot has territory");
|
|
map.camera.zoom = 0.2;
|
|
map._applyCamera();
|
|
this.assertTrue(map.political, "the far zoom switches to the political map");
|
|
this.assertTrue(map.$world.hasClass("political"), "the world is flagged political");
|
|
const owned = snapshottedColourTile(env);
|
|
this.assertNotNull(owned, "an owned tile is painted a flat country colour");
|
|
const style = owned.getAttribute("style") || "";
|
|
this.assertFalse(/terrain\.png|textures\//.test(style), "the terrain texture is gone");
|
|
this.assertTrue(/background-color/.test(style), "the tile keeps a country colour");
|
|
|
|
map.camera.zoom = 1.0;
|
|
map._applyCamera();
|
|
this.assertFalse(map.political, "zooming back restores the terrain map");
|
|
this.assertFalse(map.$world.hasClass("political"), "the political flag is cleared");
|
|
this.assertSize(env.$("#layer-political-labels text"), 0, "the names are removed");
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
}
|
|
|
|
// The first on-screen terrain hex whose colour is one of the catalogue's, i.e.
|
|
// a tile a nation owns. jsdom turns the colour into `rgb(...)`, so compare the
|
|
// numeric channels of every civ rather than the original hex string.
|
|
function snapshottedColourTile(env) {
|
|
const colours = new Set();
|
|
for (const civ of CIVILISATIONS) {
|
|
colours.add(civ.primaryColour.toLowerCase());
|
|
colours.add(cssColour(civ.primaryColour));
|
|
}
|
|
let found = null;
|
|
env.$("#layer-terrain .hex.terrain").each((_index, element) => {
|
|
if (!found && colours.has(element.style.backgroundColor)) found = element;
|
|
});
|
|
return found;
|
|
}
|
|
|
|
function cssColour(hex) {
|
|
const value = hex.replace("#", "");
|
|
return `rgb(${parseInt(value.slice(0, 2), 16)}, ${parseInt(value.slice(2, 4), 16)}, ${parseInt(value.slice(4, 6), 16)})`;
|
|
}
|