This commit is contained in:
Alvicious 2026-02-27 19:43:59 +01:00
commit 669896757d
49 changed files with 7696 additions and 5770 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

221
tests/e2e/load-map.spec.ts Normal file
View file

@ -0,0 +1,221 @@
import { test, expect } from "@playwright/test";
import path from "path";
test.describe("Map loading", () => {
test.beforeEach(async ({ context, page }) => {
await context.clearCookies();
await page.goto("/");
await page.evaluate(() => {
localStorage.clear();
sessionStorage.clear();
});
// Wait for the hidden file input to be available
await page.waitForSelector("#mapToLoad", { state: "attached" });
});
test("should load a saved map file", async ({ page }) => {
// Track errors during map loading
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(`pageerror: ${error.message}`));
page.on("console", (msg) => {
if (msg.type() === "error") {
errors.push(`console.error: ${msg.text()}`);
}
});
// Get the file input element and upload the map file
const fileInput = page.locator("#mapToLoad");
const mapFilePath = path.join(__dirname, "../fixtures/demo.map");
await fileInput.setInputFiles(mapFilePath);
// Wait for map to be fully loaded
// mapId is set at the very end of map loading in showStatistics()
await page.waitForFunction(() => (window as any).mapId !== undefined, {
timeout: 120000,
});
// Additional wait for rendering to settle
await page.waitForTimeout(500);
// Verify map data is loaded
const mapData = await page.evaluate(() => {
const pack = (window as any).pack;
return {
hasStates: pack.states && pack.states.length > 1,
hasBurgs: pack.burgs && pack.burgs.length > 1,
hasCells: pack.cells && pack.cells.i && pack.cells.i.length > 0,
hasRivers: pack.rivers && pack.rivers.length > 0,
mapId: (window as any).mapId,
};
});
expect(mapData.hasStates).toBe(true);
expect(mapData.hasBurgs).toBe(true);
expect(mapData.hasCells).toBe(true);
expect(mapData.hasRivers).toBe(true);
expect(mapData.mapId).toBeDefined();
// Ensure no JavaScript errors occurred during loading
// Filter out expected errors (external resources like Google Analytics, fonts)
const criticalErrors = errors.filter(
(e) =>
!e.includes("fonts.googleapis.com") &&
!e.includes("google-analytics") &&
!e.includes("googletagmanager") &&
!e.includes("Failed to load resource")
);
expect(criticalErrors).toEqual([]);
});
test("loaded map should have correct SVG structure", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(`pageerror: ${error.message}`));
page.on("console", (msg) => {
if (msg.type() === "error") {
errors.push(`console.error: ${msg.text()}`);
}
});
const fileInput = page.locator("#mapToLoad");
const mapFilePath = path.join(__dirname, "../fixtures/demo.map");
await fileInput.setInputFiles(mapFilePath);
await page.waitForFunction(() => (window as any).mapId !== undefined, {
timeout: 120000,
});
await page.waitForTimeout(500);
// Check essential SVG layers exist
const layers = await page.evaluate(() => {
return {
ocean: !!document.getElementById("ocean"),
lakes: !!document.getElementById("lakes"),
coastline: !!document.getElementById("coastline"),
rivers: !!document.getElementById("rivers"),
borders: !!document.getElementById("borders"),
burgs: !!document.getElementById("burgIcons"),
labels: !!document.getElementById("labels"),
};
});
expect(layers.ocean).toBe(true);
expect(layers.lakes).toBe(true);
expect(layers.coastline).toBe(true);
expect(layers.rivers).toBe(true);
expect(layers.borders).toBe(true);
expect(layers.burgs).toBe(true);
expect(layers.labels).toBe(true);
const criticalErrors = errors.filter(
(e) =>
!e.includes("fonts.googleapis.com") &&
!e.includes("google-analytics") &&
!e.includes("googletagmanager") &&
!e.includes("Failed to load resource")
);
expect(criticalErrors).toEqual([]);
});
test("loaded map should preserve state data", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(`pageerror: ${error.message}`));
page.on("console", (msg) => {
if (msg.type() === "error") {
errors.push(`console.error: ${msg.text()}`);
}
});
const fileInput = page.locator("#mapToLoad");
const mapFilePath = path.join(__dirname, "../fixtures/demo.map");
await fileInput.setInputFiles(mapFilePath);
await page.waitForFunction(() => (window as any).mapId !== undefined, {
timeout: 120000,
});
await page.waitForTimeout(500);
// Verify states have proper structure
const statesData = await page.evaluate(() => {
const pack = (window as any).pack;
const states = pack.states.filter((s: any) => s.i !== 0); // exclude neutral
return {
count: states.length,
allHaveNames: states.every((s: any) => s.name && s.name.length > 0),
allHaveCells: states.every((s: any) => s.cells > 0),
allHaveArea: states.every((s: any) => s.area > 0),
};
});
expect(statesData.count).toBeGreaterThan(0);
expect(statesData.allHaveNames).toBe(true);
expect(statesData.allHaveCells).toBe(true);
expect(statesData.allHaveArea).toBe(true);
const criticalErrors = errors.filter(
(e) =>
!e.includes("fonts.googleapis.com") &&
!e.includes("google-analytics") &&
!e.includes("googletagmanager") &&
!e.includes("Failed to load resource")
);
expect(criticalErrors).toEqual([]);
});
test("loaded map should preserve burg data", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(`pageerror: ${error.message}`));
page.on("console", (msg) => {
if (msg.type() === "error") {
errors.push(`console.error: ${msg.text()}`);
}
});
const fileInput = page.locator("#mapToLoad");
const mapFilePath = path.join(__dirname, "../fixtures/demo.map");
await fileInput.setInputFiles(mapFilePath);
await page.waitForFunction(() => (window as any).mapId !== undefined, {
timeout: 120000,
});
await page.waitForTimeout(500);
// Verify burgs have proper structure
const burgsData = await page.evaluate(() => {
const pack = (window as any).pack;
// Filter out placeholder (i=0) and removed burgs (removed=true or no name)
const activeBurgs = pack.burgs.filter(
(b: any) => b.i !== 0 && !b.removed && b.name
);
return {
count: activeBurgs.length,
allHaveNames: activeBurgs.every(
(b: any) => b.name && b.name.length > 0
),
allHaveCoords: activeBurgs.every(
(b: any) => typeof b.x === "number" && typeof b.y === "number"
),
allHaveCells: activeBurgs.every(
(b: any) => typeof b.cell === "number"
),
};
});
expect(burgsData.count).toBeGreaterThan(0);
expect(burgsData.allHaveNames).toBe(true);
expect(burgsData.allHaveCoords).toBe(true);
expect(burgsData.allHaveCells).toBe(true);
const criticalErrors = errors.filter(
(e) =>
!e.includes("fonts.googleapis.com") &&
!e.includes("google-analytics") &&
!e.includes("googletagmanager") &&
!e.includes("Failed to load resource")
);
expect(criticalErrors).toEqual([]);
});
});

View file

@ -0,0 +1,349 @@
import { test, expect } from "@playwright/test";
test.describe("Zone Export", () => {
test.beforeEach(async ({ context, page }) => {
await context.clearCookies();
await page.goto("/");
await page.evaluate(() => {
localStorage.clear();
sessionStorage.clear();
});
// Navigate with seed parameter and wait for full load
await page.goto("/?seed=test-zones-export&width=1280&height=720");
// Wait for map generation to complete
await page.waitForFunction(
() => (window as any).mapId !== undefined,
{ timeout: 60000 }
);
// Additional wait for any rendering/animations to settle
await page.waitForTimeout(500);
});
// Helper function to create a test zone programmatically
// Uses BFS to select a contiguous set of land cells for stable, representative testing
async function createTestZone(page: any): Promise<number> {
return await page.evaluate(() => {
const { cells, zones } = (window as any).pack;
// Find a starting land cell (height >= 20)
const totalCells = cells.i.length;
let startCell = -1;
for (let i = 1; i < totalCells; i++) {
if (cells.h[i] >= 20) {
startCell = i;
break;
}
}
if (startCell === -1) {
throw new Error("No land cells found to create a test zone");
}
// Use BFS to select a contiguous set of 10-20 land cells
const zoneCells: number[] = [];
const visited = new Set<number>();
const queue: number[] = [];
visited.add(startCell);
queue.push(startCell);
while (queue.length > 0 && zoneCells.length < 20) {
const current = queue.shift() as number;
// Only include land cells in the zone
if (cells.h[current] >= 20) {
zoneCells.push(current);
}
// Explore neighbors
const neighbors: number[] = cells.c[current] || [];
for (const neighbor of neighbors) {
if (neighbor && !visited.has(neighbor)) {
visited.add(neighbor);
queue.push(neighbor);
}
}
}
if (zoneCells.length < 10) {
throw new Error(`Not enough contiguous land cells found: ${zoneCells.length}`);
}
// Generate unique zone ID
const zoneId = zones.length;
// Create zone object
const zone = {
i: zoneId,
name: "Test Export Zone",
type: "Test",
color: "#FF0000",
cells: zoneCells,
};
// Add zone to pack.zones array
zones.push(zone);
return zoneId;
});
}
// Helper function to export zones to GeoJSON without file download
// This calls the production code from public/modules/io/export.js
async function exportZonesToGeoJson(page: any): Promise<any> {
return await page.evaluate(() => {
// Mock downloadFile to capture the JSON instead of downloading
const originalDownloadFile = (window as any).downloadFile;
let capturedJson: any = null;
(window as any).downloadFile = (data: string) => {
capturedJson = JSON.parse(data);
};
// Call the production code
(window as any).saveGeoJsonZones();
// Restore original downloadFile
(window as any).downloadFile = originalDownloadFile;
return capturedJson;
});
}
test("should export zone with valid GeoJSON root structure", async ({ page }) => {
// Create a test zone
const zoneId = await createTestZone(page);
expect(zoneId).toBeGreaterThanOrEqual(0);
// Export zones to GeoJSON
const geoJson = await exportZonesToGeoJson(page);
// Validate root GeoJSON structure (Task 5.1)
expect(geoJson).toBeDefined();
expect(geoJson).toHaveProperty("type");
expect(geoJson.type).toBe("FeatureCollection");
expect(geoJson).toHaveProperty("features");
expect(Array.isArray(geoJson.features)).toBe(true);
expect(geoJson.features.length).toBeGreaterThan(0);
// Verify the test zone is in the export
const testZoneFeature = geoJson.features.find((f: any) => f.properties.id === zoneId);
expect(testZoneFeature).toBeDefined();
expect(testZoneFeature.properties.name).toBe("Test Export Zone");
// Validate Feature structure (Task 5.2)
expect(testZoneFeature).toHaveProperty("type");
expect(testZoneFeature.type).toBe("Feature");
expect(testZoneFeature).toHaveProperty("geometry");
expect(testZoneFeature.geometry).toBeDefined();
expect(typeof testZoneFeature.geometry).toBe("object");
expect(testZoneFeature.geometry).toHaveProperty("type");
// Note: Geometry type can be "Polygon" (single component) or "MultiPolygon" (multiple disconnected components)
// For this test with contiguous BFS-selected cells, we expect "Polygon"
expect(testZoneFeature.geometry.type).toBe("Polygon");
expect(testZoneFeature.geometry).toHaveProperty("coordinates");
expect(Array.isArray(testZoneFeature.geometry.coordinates)).toBe(true);
expect(testZoneFeature).toHaveProperty("properties");
expect(testZoneFeature.properties).toBeDefined();
expect(typeof testZoneFeature.properties).toBe("object");
// Task 6.1: Validate zone property mapping
// Get the test zone from pack.zones in browser context
const testZone = await page.evaluate((id: number) => {
const { zones } = (window as any).pack;
return zones.find((z: any) => z.i === id);
}, zoneId);
expect(testZone).toBeDefined();
// Assert feature.properties match zone properties
expect(testZoneFeature.properties.id).toBe(testZone.i);
expect(testZoneFeature.properties.name).toBe(testZone.name);
expect(testZoneFeature.properties.type).toBe(testZone.type);
expect(testZoneFeature.properties.color).toBe(testZone.color);
expect(testZoneFeature.properties.cells).toEqual(testZone.cells);
// Task 7.1: Validate coordinate array structure
const { coordinates } = testZoneFeature.geometry;
// Assert geometry.coordinates is an array
expect(Array.isArray(coordinates)).toBe(true);
// Assert coordinates array is not empty
expect(coordinates.length).toBeGreaterThan(0);
// Validate each LinearRing in the coordinates array
// Note: Zones can have multiple rings (holes) or be MultiPolygon (disconnected components)
for (const linearRing of coordinates) {
// Assert LinearRing is an array
expect(Array.isArray(linearRing)).toBe(true);
// Task 7.2: Validate LinearRing validity
// Assert LinearRing has at least 4 positions
expect(linearRing.length).toBeGreaterThanOrEqual(4);
// Assert first position equals last position (closed ring)
const firstPosition = linearRing[0];
const lastPosition = linearRing[linearRing.length - 1];
expect(firstPosition[0]).toBe(lastPosition[0]);
expect(firstPosition[1]).toBe(lastPosition[1]);
// Assert each position in LinearRing is an array of 2 numbers
for (const position of linearRing) {
expect(Array.isArray(position)).toBe(true);
expect(position.length).toBe(2);
expect(typeof position[0]).toBe("number");
expect(typeof position[1]).toBe("number");
// Assert all positions are valid [longitude, latitude] pairs
// Longitude should be between -180 and 180
expect(position[0]).toBeGreaterThanOrEqual(-180);
expect(position[0]).toBeLessThanOrEqual(180);
// Latitude should be between -90 and 90
expect(position[1]).toBeGreaterThanOrEqual(-90);
expect(position[1]).toBeLessThanOrEqual(90);
}
}
});
test("should exclude hidden zones from GeoJSON export", async ({ page }) => {
// Create a regular test zone
const regularZoneId = await createTestZone(page);
expect(regularZoneId).toBeGreaterThanOrEqual(0);
// Create a hidden zone
const hiddenZoneId = await page.evaluate(() => {
const { cells, zones } = (window as any).pack;
// Find a starting land cell that's not already in a zone
const totalCells = cells.i.length;
let startCell = -1;
for (let i = 1; i < totalCells; i++) {
const isLand = cells.h[i] >= 20;
const notInZone = !zones.some((z: any) => z.cells && z.cells.includes(i));
if (isLand && notInZone) {
startCell = i;
break;
}
}
if (startCell === -1) {
throw new Error("No available land cells found for hidden zone");
}
// Use BFS to select a contiguous set of 10-20 land cells
const zoneCells: number[] = [];
const visited = new Set<number>();
const queue: number[] = [];
visited.add(startCell);
queue.push(startCell);
while (queue.length > 0 && zoneCells.length < 20) {
const current = queue.shift() as number;
// Only include land cells not already in a zone
const isLand = cells.h[current] >= 20;
const notInZone = !zones.some((z: any) => z.cells && z.cells.includes(current));
if (isLand && notInZone) {
zoneCells.push(current);
}
// Explore neighbors
const neighbors: number[] = cells.c[current] || [];
for (const neighbor of neighbors) {
if (neighbor && !visited.has(neighbor)) {
visited.add(neighbor);
queue.push(neighbor);
}
}
}
if (zoneCells.length < 10) {
throw new Error(`Not enough contiguous land cells found: ${zoneCells.length}`);
}
// Generate unique zone ID
const zoneId = zones.length;
// Create hidden zone object
const zone = {
i: zoneId,
name: "Hidden Test Zone",
type: "Test",
color: "#00FF00",
cells: zoneCells,
hidden: true, // Mark as hidden
};
// Add zone to pack.zones array
zones.push(zone);
return zoneId;
});
expect(hiddenZoneId).toBeGreaterThanOrEqual(0);
// Export zones to GeoJSON
const geoJson = await exportZonesToGeoJson(page);
// Validate that the regular zone is in the export
const regularZoneFeature = geoJson.features.find((f: any) => f.properties.id === regularZoneId);
expect(regularZoneFeature).toBeDefined();
expect(regularZoneFeature.properties.name).toBe("Test Export Zone");
// Validate that the hidden zone is NOT in the export
const hiddenZoneFeature = geoJson.features.find((f: any) => f.properties.id === hiddenZoneId);
expect(hiddenZoneFeature).toBeUndefined();
});
test("should exclude zones with empty cells array from GeoJSON export", async ({ page }) => {
// Create a regular test zone
const regularZoneId = await createTestZone(page);
expect(regularZoneId).toBeGreaterThanOrEqual(0);
// Create a zone with empty cells array
const emptyZoneId = await page.evaluate(() => {
const { zones } = (window as any).pack;
// Generate unique zone ID
const zoneId = zones.length;
// Create zone object with empty cells array
const zone = {
i: zoneId,
name: "Empty Test Zone",
type: "Test",
color: "#0000FF",
cells: [], // Empty cells array
};
// Add zone to pack.zones array
zones.push(zone);
return zoneId;
});
expect(emptyZoneId).toBeGreaterThanOrEqual(0);
// Export zones to GeoJSON
const geoJson = await exportZonesToGeoJson(page);
// Validate that the regular zone is in the export
const regularZoneFeature = geoJson.features.find((f: any) => f.properties.id === regularZoneId);
expect(regularZoneFeature).toBeDefined();
expect(regularZoneFeature.properties.name).toBe("Test Export Zone");
// Validate that the empty zone is NOT in the export
const emptyZoneFeature = geoJson.features.find((f: any) => f.properties.id === emptyZoneId);
expect(emptyZoneFeature).toBeUndefined();
});
});

174
tests/fixtures/demo.map vendored Normal file

File diff suppressed because one or more lines are too long