From 31fef04ce3539bcaeedf3cd0826f619021f8d1e6 Mon Sep 17 00:00:00 2001 From: Joe McMahon Date: Tue, 10 Feb 2026 19:56:16 -0500 Subject: [PATCH] Adding zones export to GeoJSON --- package-lock.json | 45 +- package.json | 1 + public/modules/io/export.js | 112 ++ src/index.html | 1 + src/modules/io/export.zones.property.test.ts | 1567 ++++++++++++++++++ src/modules/io/export.zones.ui.test.ts | 96 ++ src/modules/io/export.zones.unit.test.ts | 597 +++++++ 7 files changed, 2413 insertions(+), 6 deletions(-) create mode 100644 src/modules/io/export.zones.property.test.ts create mode 100644 src/modules/io/export.zones.ui.test.ts create mode 100644 src/modules/io/export.zones.unit.test.ts diff --git a/package-lock.json b/package-lock.json index 53616e24..f7db7069 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@types/polylabel": "^1.1.3", "@vitest/browser": "^4.0.18", "@vitest/browser-playwright": "^4.0.18", + "fast-check": "^4.5.3", "playwright": "^1.57.0", "typescript": "^5.9.3", "vite": "^7.3.1", @@ -1353,7 +1354,6 @@ "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1394,7 +1394,6 @@ "integrity": "sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/browser": "4.0.18", "@vitest/mocker": "4.0.18", @@ -1876,7 +1875,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -2039,6 +2037,28 @@ "node": ">=12.0.0" } }, + "node_modules/fast-check": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.5.3.tgz", + "integrity": "sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "dependencies": { + "pure-rand": "^7.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2163,7 +2183,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2279,6 +2298,22 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ] + }, "node_modules/robust-predicates": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", @@ -2475,7 +2510,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2551,7 +2585,6 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", diff --git a/package.json b/package.json index 37c2ba5a..160ee5e3 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@types/polylabel": "^1.1.3", "@vitest/browser": "^4.0.18", "@vitest/browser-playwright": "^4.0.18", + "fast-check": "^4.5.3", "playwright": "^1.57.0", "typescript": "^5.9.3", "vite": "^7.3.1", diff --git a/public/modules/io/export.js b/public/modules/io/export.js index 51164c73..f4166f96 100644 --- a/public/modules/io/export.js +++ b/public/modules/io/export.js @@ -574,3 +574,115 @@ function saveGeoJsonMarkers() { const fileName = getFileName("Markers") + ".geojson"; downloadFile(JSON.stringify(json), fileName, "application/json"); } + +function saveGeoJsonZones() { + const {zones, cells, vertices} = pack; + const json = {type: "FeatureCollection", features: []}; + + // Helper function to convert zone cells to polygon coordinates + function getZonePolygonCoordinates(zoneCells) { + // Create a set of cells in this zone for quick lookup + const cellsInZone = new Set(zoneCells); + const ofSameType = (cellId) => cellsInZone.has(cellId); + const ofDifferentType = (cellId) => !cellsInZone.has(cellId); + + const checkedCells = new Set(); + const coordinates = []; + + // Find boundary vertices by tracing the zone boundary + for (const cellId of zoneCells) { + if (checkedCells.has(cellId)) continue; + + // Check if this cell is on the boundary (has a neighbor outside the zone) + const neighbors = cells.c[cellId]; + const onBorder = neighbors.some(ofDifferentType); + if (!onBorder) continue; + + // Find a starting vertex that's on the boundary + const cellVertices = cells.v[cellId]; + let startingVertex = null; + + for (const vertexId of cellVertices) { + const vertexCells = vertices.c[vertexId]; + if (vertexCells.some(ofDifferentType)) { + startingVertex = vertexId; + break; + } + } + + if (startingVertex === null) continue; + + // Trace the boundary by connecting vertices + const vertexChain = []; + let current = startingVertex; + let previous = null; + const maxIterations = vertices.c.length; + + for (let i = 0; i < maxIterations; i++) { + vertexChain.push(current); + + // Mark cells adjacent to this vertex as checked + const adjacentCells = vertices.c[current]; + adjacentCells.filter(ofSameType).forEach(c => checkedCells.add(c)); + + // Find the next vertex along the boundary + const [c1, c2, c3] = adjacentCells.map(ofSameType); + const [v1, v2, v3] = vertices.v[current]; + + let next = null; + if (v1 !== previous && c1 !== c2) next = v1; + else if (v2 !== previous && c2 !== c3) next = v2; + else if (v3 !== previous && c1 !== c3) next = v3; + + if (next === null || next === current) break; + if (next === startingVertex) break; // Completed the ring + + previous = current; + current = next; + } + + // Convert vertex chain to coordinates + for (const vertexId of vertexChain) { + const [x, y] = vertices.p[vertexId]; + coordinates.push(getCoordinates(x, y, 4)); + } + } + + // Close the polygon ring (first coordinate = last coordinate) + if (coordinates.length > 0) { + coordinates.push(coordinates[0]); + } + + return [coordinates]; + } + + // Filter and process zones + zones.forEach(zone => { + // Exclude hidden zones and zones with no cells + if (zone.hidden || !zone.cells || zone.cells.length === 0) return; + + const coordinates = getZonePolygonCoordinates(zone.cells); + + // Only add feature if we have valid coordinates + if (coordinates[0].length > 1) { + const properties = { + id: zone.i, + name: zone.name, + type: zone.type, + color: zone.color, + cells: zone.cells + }; + + const feature = { + type: "Feature", + geometry: {type: "Polygon", coordinates}, + properties + }; + + json.features.push(feature); + } + }); + + const fileName = getFileName("Zones") + ".geojson"; + downloadFile(JSON.stringify(json), fileName, "application/json"); +} diff --git a/src/index.html b/src/index.html index f44cda5a..6af93062 100644 --- a/src/index.html +++ b/src/index.html @@ -6152,6 +6152,7 @@ +

GeoJSON format is used in GIS tools such as QGIS. Check out diff --git a/src/modules/io/export.zones.property.test.ts b/src/modules/io/export.zones.property.test.ts new file mode 100644 index 00000000..3a5b0de5 --- /dev/null +++ b/src/modules/io/export.zones.property.test.ts @@ -0,0 +1,1567 @@ +/** + * Property-based tests for zones GeoJSON export + * Feature: zones-geojson-export + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import * as fc from "fast-check"; + +// Mock global functions and objects +declare global { + var pack: any; + var getCoordinates: (x: number, y: number, decimals: number) => [number, number]; + var getFileName: (dataType: string) => string; + var downloadFile: (data: string, fileName: string, mimeType: string) => void; +} + +describe("zones GeoJSON export - Property-Based Tests", () => { + beforeEach(() => { + // Mock getCoordinates function + globalThis.getCoordinates = vi.fn((x: number, y: number, decimals: number) => { + const lon = Number((x / 10).toFixed(decimals)); + const lat = Number((y / 10).toFixed(decimals)); + return [lon, lat]; + }); + + // Mock getFileName function + globalThis.getFileName = vi.fn((dataType: string) => { + return `TestMap_${dataType}_20240101`; + }); + + // Mock downloadFile function + globalThis.downloadFile = vi.fn(); + }); + + /** + * Property 1: Valid GeoJSON Structure + * Feature: zones-geojson-export, Property 1: Valid GeoJSON Structure + * Validates: Requirements 1.1, 5.4 + * + * For any exported zones data, the output SHALL be a valid GeoJSON FeatureCollection + * with a "type" field equal to "FeatureCollection" and a "features" array. + */ + it("Property 1: exported data is a valid GeoJSON FeatureCollection with type and features array", () => { + fc.assert( + fc.property( + // Generate random zones with varying properties + fc.array( + fc.record({ + i: fc.integer({ min: 0, max: 1000 }), + name: fc.string({ minLength: 1, maxLength: 50 }), + type: fc.oneof(fc.constant("Unknown"), fc.constant("Territory"), fc.constant("Climate")), + color: fc.oneof( + fc.constant("#ff0000"), + fc.constant("#00ff00"), + fc.constant("url(#hatch1)") + ), + cells: fc.array(fc.integer({ min: 0, max: 100 }), { minLength: 0, maxLength: 10 }), + hidden: fc.boolean(), + }), + { minLength: 0, maxLength: 20 } + ).map(zones => { + // Ensure unique zone IDs + return zones.map((zone, index) => ({ ...zone, i: index })); + }), + (zones) => { + // Setup mock pack data + const mockCells = { + v: Array(101).fill(null).map(() => [0, 1, 2]), // Simple triangular cells + c: Array(101).fill(null).map(() => [0, 1, 2]), // Neighbors + }; + + const mockVertices = { + p: Array(3).fill(null).map((_, i) => [i * 10, i * 10]), + c: Array(3).fill(null).map(() => [0, 1, 2]), + v: Array(3).fill(null).map(() => [0, 1, 2]), + }; + + globalThis.pack = { + zones, + cells: mockCells, + vertices: mockVertices, + }; + + // Execute the function that generates GeoJSON + const saveGeoJsonZones = new Function(` + const {zones, cells, vertices} = pack; + const json = {type: "FeatureCollection", features: []}; + + function getZonePolygonCoordinates(zoneCells) { + const cellsInZone = new Set(zoneCells); + const ofSameType = (cellId) => cellsInZone.has(cellId); + const ofDifferentType = (cellId) => !cellsInZone.has(cellId); + + const checkedCells = new Set(); + const coordinates = []; + + for (const cellId of zoneCells) { + if (checkedCells.has(cellId)) continue; + + const neighbors = cells.c[cellId]; + const onBorder = neighbors.some(ofDifferentType); + if (!onBorder) continue; + + const cellVertices = cells.v[cellId]; + let startingVertex = null; + + for (const vertexId of cellVertices) { + const vertexCells = vertices.c[vertexId]; + if (vertexCells.some(ofDifferentType)) { + startingVertex = vertexId; + break; + } + } + + if (startingVertex === null) continue; + + const vertexChain = []; + let current = startingVertex; + let previous = null; + const maxIterations = vertices.c.length; + + for (let i = 0; i < maxIterations; i++) { + vertexChain.push(current); + + const adjacentCells = vertices.c[current]; + adjacentCells.filter(ofSameType).forEach(c => checkedCells.add(c)); + + const [c1, c2, c3] = adjacentCells.map(ofSameType); + const [v1, v2, v3] = vertices.v[current]; + + let next = null; + if (v1 !== previous && c1 !== c2) next = v1; + else if (v2 !== previous && c2 !== c3) next = v2; + else if (v3 !== previous && c1 !== c3) next = v3; + + if (next === null || next === current) break; + if (next === startingVertex) break; + + previous = current; + current = next; + } + + for (const vertexId of vertexChain) { + const [x, y] = vertices.p[vertexId]; + coordinates.push(getCoordinates(x, y, 4)); + } + } + + if (coordinates.length > 0) { + coordinates.push(coordinates[0]); + } + + return [coordinates]; + } + + zones.forEach(zone => { + if (zone.hidden || !zone.cells || zone.cells.length === 0) return; + + const coordinates = getZonePolygonCoordinates(zone.cells); + + if (coordinates[0].length > 1) { + const properties = { + id: zone.i, + name: zone.name, + type: zone.type, + color: zone.color, + cells: zone.cells + }; + + const feature = { + type: "Feature", + geometry: {type: "Polygon", coordinates}, + properties + }; + + json.features.push(feature); + } + }); + + return json; + `); + + const result = saveGeoJsonZones(); + + // Verify valid GeoJSON FeatureCollection structure + expect(result).toBeDefined(); + expect(result).toHaveProperty("type"); + expect(result.type).toBe("FeatureCollection"); + expect(result).toHaveProperty("features"); + expect(Array.isArray(result.features)).toBe(true); + + // Verify each feature has the correct structure + for (const feature of result.features) { + expect(feature).toHaveProperty("type", "Feature"); + expect(feature).toHaveProperty("geometry"); + expect(feature).toHaveProperty("properties"); + expect(feature.geometry).toHaveProperty("type"); + expect(feature.geometry).toHaveProperty("coordinates"); + } + } + ), + { numRuns: 100 } + ); + }); + + /** + * Property 2: Visible Zones Only + * Feature: zones-geojson-export, Property 2: Visible Zones Only + * Validates: Requirements 1.3, 1.4 + * + * For any zone in the exported GeoJSON, that zone SHALL NOT be marked as hidden + * in pack.zones and SHALL have at least one cell. + */ + it("Property 2: only visible zones with cells are exported (no hidden zones, no empty zones)", () => { + fc.assert( + fc.property( + // Generate random zones with mixed visibility and cell counts + fc.array( + fc.record({ + i: fc.integer({ min: 0, max: 1000 }), + name: fc.string({ minLength: 1, maxLength: 50 }), + type: fc.oneof(fc.constant("Unknown"), fc.constant("Territory"), fc.constant("Climate")), + color: fc.oneof( + fc.constant("#ff0000"), + fc.constant("#00ff00"), + fc.constant("url(#hatch1)") + ), + cells: fc.array(fc.integer({ min: 0, max: 100 }), { minLength: 0, maxLength: 10 }), + hidden: fc.boolean(), // Mix of hidden and visible zones + }), + { minLength: 0, maxLength: 20 } + ).map(zones => { + // Ensure unique zone IDs + return zones.map((zone, index) => ({ ...zone, i: index })); + }), + (zones) => { + // Setup mock pack data + const mockCells = { + v: Array(101).fill(null).map(() => [0, 1, 2]), // Simple triangular cells + c: Array(101).fill(null).map(() => [0, 1, 2]), // Neighbors + }; + + const mockVertices = { + p: Array(3).fill(null).map((_, i) => [i * 10, i * 10]), + c: Array(3).fill(null).map(() => [0, 1, 2]), + v: Array(3).fill(null).map(() => [0, 1, 2]), + }; + + globalThis.pack = { + zones, + cells: mockCells, + vertices: mockVertices, + }; + + // Execute the function that generates GeoJSON + const saveGeoJsonZones = new Function(` + const {zones, cells, vertices} = pack; + const json = {type: "FeatureCollection", features: []}; + + function getZonePolygonCoordinates(zoneCells) { + const cellsInZone = new Set(zoneCells); + const ofSameType = (cellId) => cellsInZone.has(cellId); + const ofDifferentType = (cellId) => !cellsInZone.has(cellId); + + const checkedCells = new Set(); + const coordinates = []; + + for (const cellId of zoneCells) { + if (checkedCells.has(cellId)) continue; + + const neighbors = cells.c[cellId]; + const onBorder = neighbors.some(ofDifferentType); + if (!onBorder) continue; + + const cellVertices = cells.v[cellId]; + let startingVertex = null; + + for (const vertexId of cellVertices) { + const vertexCells = vertices.c[vertexId]; + if (vertexCells.some(ofDifferentType)) { + startingVertex = vertexId; + break; + } + } + + if (startingVertex === null) continue; + + const vertexChain = []; + let current = startingVertex; + let previous = null; + const maxIterations = vertices.c.length; + + for (let i = 0; i < maxIterations; i++) { + vertexChain.push(current); + + const adjacentCells = vertices.c[current]; + adjacentCells.filter(ofSameType).forEach(c => checkedCells.add(c)); + + const [c1, c2, c3] = adjacentCells.map(ofSameType); + const [v1, v2, v3] = vertices.v[current]; + + let next = null; + if (v1 !== previous && c1 !== c2) next = v1; + else if (v2 !== previous && c2 !== c3) next = v2; + else if (v3 !== previous && c1 !== c3) next = v3; + + if (next === null || next === current) break; + if (next === startingVertex) break; + + previous = current; + current = next; + } + + for (const vertexId of vertexChain) { + const [x, y] = vertices.p[vertexId]; + coordinates.push(getCoordinates(x, y, 4)); + } + } + + if (coordinates.length > 0) { + coordinates.push(coordinates[0]); + } + + return [coordinates]; + } + + zones.forEach(zone => { + if (zone.hidden || !zone.cells || zone.cells.length === 0) return; + + const coordinates = getZonePolygonCoordinates(zone.cells); + + if (coordinates[0].length > 1) { + const properties = { + id: zone.i, + name: zone.name, + type: zone.type, + color: zone.color, + cells: zone.cells + }; + + const feature = { + type: "Feature", + geometry: {type: "Polygon", coordinates}, + properties + }; + + json.features.push(feature); + } + }); + + return json; + `); + + const result = saveGeoJsonZones(); + + // Calculate expected visible zones (not hidden AND has cells) + const expectedVisibleZones = zones.filter( + zone => !zone.hidden && zone.cells && zone.cells.length > 0 + ); + + // Verify that all exported features correspond to visible zones only + for (const feature of result.features) { + const zoneId = feature.properties.id; + const originalZone = zones.find(z => z.i === zoneId); + + // Verify the zone exists + expect(originalZone).toBeDefined(); + + if (originalZone) { + // Verify the zone is not hidden + expect(originalZone.hidden).not.toBe(true); + + // Verify the zone has cells + expect(originalZone.cells).toBeDefined(); + expect(originalZone.cells.length).toBeGreaterThan(0); + } + } + + // Verify no hidden zones are in the export + const exportedZoneIds = new Set(result.features.map(f => f.properties.id)); + const hiddenZones = zones.filter(z => z.hidden === true); + + for (const hiddenZone of hiddenZones) { + expect(exportedZoneIds.has(hiddenZone.i)).toBe(false); + } + + // Verify no zones with empty cells are in the export + const emptyZones = zones.filter(z => !z.cells || z.cells.length === 0); + + for (const emptyZone of emptyZones) { + expect(exportedZoneIds.has(emptyZone.i)).toBe(false); + } + } + ), + { numRuns: 100 } + ); + }); + + /** + * Property 3: Polygon Geometry Type + * Feature: zones-geojson-export, Property 3: Polygon Geometry Type + * Validates: Requirements 1.2 + * + * For any exported zone feature, the geometry SHALL have type "Polygon" with a coordinates + * array containing at least one coordinate ring. + */ + it("Property 3: all exported zone features have Polygon geometry type with coordinate rings", () => { + fc.assert( + fc.property( + // Generate random zones with varying properties + fc.array( + fc.record({ + i: fc.integer({ min: 0, max: 1000 }), + name: fc.string({ minLength: 1, maxLength: 50 }), + type: fc.oneof(fc.constant("Unknown"), fc.constant("Territory"), fc.constant("Climate")), + color: fc.oneof( + fc.constant("#ff0000"), + fc.constant("#00ff00"), + fc.constant("url(#hatch1)") + ), + cells: fc.array(fc.integer({ min: 0, max: 100 }), { minLength: 1, maxLength: 10 }), + hidden: fc.constant(false), // Only visible zones + }), + { minLength: 1, maxLength: 20 } + ).map(zones => { + // Ensure unique zone IDs + return zones.map((zone, index) => ({ ...zone, i: index })); + }), + (zones) => { + // Setup mock pack data + const mockCells = { + v: Array(101).fill(null).map(() => [0, 1, 2]), // Simple triangular cells + c: Array(101).fill(null).map(() => [0, 1, 2]), // Neighbors + }; + + const mockVertices = { + p: Array(3).fill(null).map((_, i) => [i * 10, i * 10]), + c: Array(3).fill(null).map(() => [0, 1, 2]), + v: Array(3).fill(null).map(() => [0, 1, 2]), + }; + + globalThis.pack = { + zones, + cells: mockCells, + vertices: mockVertices, + }; + + // Execute the function that generates GeoJSON + const saveGeoJsonZones = new Function(` + const {zones, cells, vertices} = pack; + const json = {type: "FeatureCollection", features: []}; + + function getZonePolygonCoordinates(zoneCells) { + const cellsInZone = new Set(zoneCells); + const ofSameType = (cellId) => cellsInZone.has(cellId); + const ofDifferentType = (cellId) => !cellsInZone.has(cellId); + + const checkedCells = new Set(); + const coordinates = []; + + for (const cellId of zoneCells) { + if (checkedCells.has(cellId)) continue; + + const neighbors = cells.c[cellId]; + const onBorder = neighbors.some(ofDifferentType); + if (!onBorder) continue; + + const cellVertices = cells.v[cellId]; + let startingVertex = null; + + for (const vertexId of cellVertices) { + const vertexCells = vertices.c[vertexId]; + if (vertexCells.some(ofDifferentType)) { + startingVertex = vertexId; + break; + } + } + + if (startingVertex === null) continue; + + const vertexChain = []; + let current = startingVertex; + let previous = null; + const maxIterations = vertices.c.length; + + for (let i = 0; i < maxIterations; i++) { + vertexChain.push(current); + + const adjacentCells = vertices.c[current]; + adjacentCells.filter(ofSameType).forEach(c => checkedCells.add(c)); + + const [c1, c2, c3] = adjacentCells.map(ofSameType); + const [v1, v2, v3] = vertices.v[current]; + + let next = null; + if (v1 !== previous && c1 !== c2) next = v1; + else if (v2 !== previous && c2 !== c3) next = v2; + else if (v3 !== previous && c1 !== c3) next = v3; + + if (next === null || next === current) break; + if (next === startingVertex) break; + + previous = current; + current = next; + } + + for (const vertexId of vertexChain) { + const [x, y] = vertices.p[vertexId]; + coordinates.push(getCoordinates(x, y, 4)); + } + } + + if (coordinates.length > 0) { + coordinates.push(coordinates[0]); + } + + return [coordinates]; + } + + zones.forEach(zone => { + if (zone.hidden || !zone.cells || zone.cells.length === 0) return; + + const coordinates = getZonePolygonCoordinates(zone.cells); + + if (coordinates[0].length > 1) { + const properties = { + id: zone.i, + name: zone.name, + type: zone.type, + color: zone.color, + cells: zone.cells + }; + + const feature = { + type: "Feature", + geometry: {type: "Polygon", coordinates}, + properties + }; + + json.features.push(feature); + } + }); + + return json; + `); + + const result = saveGeoJsonZones(); + + // Verify all features have Polygon geometry type + expect(result.features.length).toBeGreaterThan(0); + + for (const feature of result.features) { + // Verify geometry exists + expect(feature.geometry).toBeDefined(); + + // Verify geometry type is "Polygon" + expect(feature.geometry.type).toBe("Polygon"); + + // Verify coordinates array exists + expect(feature.geometry.coordinates).toBeDefined(); + expect(Array.isArray(feature.geometry.coordinates)).toBe(true); + + // Verify at least one coordinate ring exists + expect(feature.geometry.coordinates.length).toBeGreaterThanOrEqual(1); + + // Verify the first element is a coordinate ring (array of coordinates) + const firstRing = feature.geometry.coordinates[0]; + expect(Array.isArray(firstRing)).toBe(true); + expect(firstRing.length).toBeGreaterThan(0); + + // Verify each coordinate in the ring is a [lon, lat] pair + for (const coord of firstRing) { + expect(Array.isArray(coord)).toBe(true); + expect(coord.length).toBe(2); + expect(typeof coord[0]).toBe("number"); // longitude + expect(typeof coord[1]).toBe("number"); // latitude + } + } + } + ), + { numRuns: 100 } + ); + }); + + /** + * Property 4: Closed Polygon Rings + * Feature: zones-geojson-export, Property 4: Closed Polygon Rings + * Validates: Requirements 3.2 + * + * For any zone feature's polygon coordinates, the first coordinate SHALL equal the last + * coordinate (closed ring requirement). + */ + it("Property 4: all polygon coordinate rings are closed (first coordinate equals last coordinate)", () => { + fc.assert( + fc.property( + // Generate random zones with varying properties + fc.array( + fc.record({ + i: fc.integer({ min: 0, max: 1000 }), + name: fc.string({ minLength: 1, maxLength: 50 }), + type: fc.oneof(fc.constant("Unknown"), fc.constant("Territory"), fc.constant("Climate")), + color: fc.oneof( + fc.constant("#ff0000"), + fc.constant("#00ff00"), + fc.constant("url(#hatch1)") + ), + cells: fc.array(fc.integer({ min: 0, max: 100 }), { minLength: 1, maxLength: 10 }), + hidden: fc.constant(false), // Only visible zones + }), + { minLength: 1, maxLength: 20 } + ).map(zones => { + // Ensure unique zone IDs + return zones.map((zone, index) => ({ ...zone, i: index })); + }), + (zones) => { + // Setup mock pack data + const mockCells = { + v: Array(101).fill(null).map(() => [0, 1, 2]), // Simple triangular cells + c: Array(101).fill(null).map(() => [0, 1, 2]), // Neighbors + }; + + const mockVertices = { + p: Array(3).fill(null).map((_, i) => [i * 10, i * 10]), + c: Array(3).fill(null).map(() => [0, 1, 2]), + v: Array(3).fill(null).map(() => [0, 1, 2]), + }; + + globalThis.pack = { + zones, + cells: mockCells, + vertices: mockVertices, + }; + + // Execute the function that generates GeoJSON + const saveGeoJsonZones = new Function(` + const {zones, cells, vertices} = pack; + const json = {type: "FeatureCollection", features: []}; + + function getZonePolygonCoordinates(zoneCells) { + const cellsInZone = new Set(zoneCells); + const ofSameType = (cellId) => cellsInZone.has(cellId); + const ofDifferentType = (cellId) => !cellsInZone.has(cellId); + + const checkedCells = new Set(); + const coordinates = []; + + for (const cellId of zoneCells) { + if (checkedCells.has(cellId)) continue; + + const neighbors = cells.c[cellId]; + const onBorder = neighbors.some(ofDifferentType); + if (!onBorder) continue; + + const cellVertices = cells.v[cellId]; + let startingVertex = null; + + for (const vertexId of cellVertices) { + const vertexCells = vertices.c[vertexId]; + if (vertexCells.some(ofDifferentType)) { + startingVertex = vertexId; + break; + } + } + + if (startingVertex === null) continue; + + const vertexChain = []; + let current = startingVertex; + let previous = null; + const maxIterations = vertices.c.length; + + for (let i = 0; i < maxIterations; i++) { + vertexChain.push(current); + + const adjacentCells = vertices.c[current]; + adjacentCells.filter(ofSameType).forEach(c => checkedCells.add(c)); + + const [c1, c2, c3] = adjacentCells.map(ofSameType); + const [v1, v2, v3] = vertices.v[current]; + + let next = null; + if (v1 !== previous && c1 !== c2) next = v1; + else if (v2 !== previous && c2 !== c3) next = v2; + else if (v3 !== previous && c1 !== c3) next = v3; + + if (next === null || next === current) break; + if (next === startingVertex) break; + + previous = current; + current = next; + } + + for (const vertexId of vertexChain) { + const [x, y] = vertices.p[vertexId]; + coordinates.push(getCoordinates(x, y, 4)); + } + } + + if (coordinates.length > 0) { + coordinates.push(coordinates[0]); + } + + return [coordinates]; + } + + zones.forEach(zone => { + if (zone.hidden || !zone.cells || zone.cells.length === 0) return; + + const coordinates = getZonePolygonCoordinates(zone.cells); + + if (coordinates[0].length > 1) { + const properties = { + id: zone.i, + name: zone.name, + type: zone.type, + color: zone.color, + cells: zone.cells + }; + + const feature = { + type: "Feature", + geometry: {type: "Polygon", coordinates}, + properties + }; + + json.features.push(feature); + } + }); + + return json; + `); + + const result = saveGeoJsonZones(); + + // Verify all polygon rings are closed + expect(result.features.length).toBeGreaterThan(0); + + for (const feature of result.features) { + expect(feature.geometry.type).toBe("Polygon"); + expect(feature.geometry.coordinates).toBeDefined(); + expect(Array.isArray(feature.geometry.coordinates)).toBe(true); + + // Check each coordinate ring in the polygon + for (const ring of feature.geometry.coordinates) { + expect(Array.isArray(ring)).toBe(true); + expect(ring.length).toBeGreaterThanOrEqual(2); + + // Verify the ring is closed: first coordinate equals last coordinate + const firstCoord = ring[0]; + const lastCoord = ring[ring.length - 1]; + + expect(Array.isArray(firstCoord)).toBe(true); + expect(Array.isArray(lastCoord)).toBe(true); + expect(firstCoord.length).toBe(2); + expect(lastCoord.length).toBe(2); + + // Check that first and last coordinates are equal + expect(firstCoord[0]).toBe(lastCoord[0]); // longitude + expect(firstCoord[1]).toBe(lastCoord[1]); // latitude + } + } + } + ), + { numRuns: 100 } + ); + }); + + /** + * Property 5: Complete Zone Properties + * Feature: zones-geojson-export, Property 5: Complete Zone Properties + * Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5 + * + * For any exported zone feature, the properties object SHALL contain all required fields: + * id, name, type, color, and cells array. + */ + it("Property 5: all exported zone features contain complete properties (id, name, type, color, cells)", () => { + fc.assert( + fc.property( + // Generate random zones with all required properties + fc.array( + fc.record({ + i: fc.integer({ min: 0, max: 1000 }), + name: fc.string({ minLength: 1, maxLength: 50 }), + type: fc.oneof(fc.constant("Unknown"), fc.constant("Territory"), fc.constant("Climate")), + color: fc.oneof( + fc.constant("#ff0000"), + fc.constant("#00ff00"), + fc.constant("url(#hatch1)") + ), + cells: fc.array(fc.integer({ min: 0, max: 100 }), { minLength: 1, maxLength: 10 }), + hidden: fc.constant(false), // Only visible zones + }), + { minLength: 1, maxLength: 20 } + ).map(zones => { + // Ensure unique zone IDs + return zones.map((zone, index) => ({ ...zone, i: index })); + }), + (zones) => { + // Setup mock pack data + const mockCells = { + v: Array(101).fill(null).map(() => [0, 1, 2]), // Simple triangular cells + c: Array(101).fill(null).map(() => [0, 1, 2]), // Neighbors + }; + + const mockVertices = { + p: Array(3).fill(null).map((_, i) => [i * 10, i * 10]), + c: Array(3).fill(null).map(() => [0, 1, 2]), + v: Array(3).fill(null).map(() => [0, 1, 2]), + }; + + globalThis.pack = { + zones, + cells: mockCells, + vertices: mockVertices, + }; + + // Import and execute the function + const saveGeoJsonZones = new Function(` + const {zones, cells, vertices} = pack; + const json = {type: "FeatureCollection", features: []}; + + function getZonePolygonCoordinates(zoneCells) { + const cellsInZone = new Set(zoneCells); + const ofSameType = (cellId) => cellsInZone.has(cellId); + const ofDifferentType = (cellId) => !cellsInZone.has(cellId); + + const checkedCells = new Set(); + const coordinates = []; + + for (const cellId of zoneCells) { + if (checkedCells.has(cellId)) continue; + + const neighbors = cells.c[cellId]; + const onBorder = neighbors.some(ofDifferentType); + if (!onBorder) continue; + + const cellVertices = cells.v[cellId]; + let startingVertex = null; + + for (const vertexId of cellVertices) { + const vertexCells = vertices.c[vertexId]; + if (vertexCells.some(ofDifferentType)) { + startingVertex = vertexId; + break; + } + } + + if (startingVertex === null) continue; + + const vertexChain = []; + let current = startingVertex; + let previous = null; + const maxIterations = vertices.c.length; + + for (let i = 0; i < maxIterations; i++) { + vertexChain.push(current); + + const adjacentCells = vertices.c[current]; + adjacentCells.filter(ofSameType).forEach(c => checkedCells.add(c)); + + const [c1, c2, c3] = adjacentCells.map(ofSameType); + const [v1, v2, v3] = vertices.v[current]; + + let next = null; + if (v1 !== previous && c1 !== c2) next = v1; + else if (v2 !== previous && c2 !== c3) next = v2; + else if (v3 !== previous && c1 !== c3) next = v3; + + if (next === null || next === current) break; + if (next === startingVertex) break; + + previous = current; + current = next; + } + + for (const vertexId of vertexChain) { + const [x, y] = vertices.p[vertexId]; + coordinates.push(getCoordinates(x, y, 4)); + } + } + + if (coordinates.length > 0) { + coordinates.push(coordinates[0]); + } + + return [coordinates]; + } + + zones.forEach(zone => { + if (zone.hidden || !zone.cells || zone.cells.length === 0) return; + + const coordinates = getZonePolygonCoordinates(zone.cells); + + if (coordinates[0].length > 1) { + const properties = { + id: zone.i, + name: zone.name, + type: zone.type, + color: zone.color, + cells: zone.cells + }; + + const feature = { + type: "Feature", + geometry: {type: "Polygon", coordinates}, + properties + }; + + json.features.push(feature); + } + }); + + return json; + `); + + const result = saveGeoJsonZones(); + + // Verify all features have complete properties + expect(result.features.length).toBeGreaterThan(0); + + for (const feature of result.features) { + expect(feature.properties).toBeDefined(); + expect(feature.properties).toHaveProperty("id"); + expect(feature.properties).toHaveProperty("name"); + expect(feature.properties).toHaveProperty("type"); + expect(feature.properties).toHaveProperty("color"); + expect(feature.properties).toHaveProperty("cells"); + + // Verify types + expect(typeof feature.properties.id).toBe("number"); + expect(typeof feature.properties.name).toBe("string"); + expect(typeof feature.properties.type).toBe("string"); + expect(typeof feature.properties.color).toBe("string"); + expect(Array.isArray(feature.properties.cells)).toBe(true); + + // Verify values match input zones + const matchingZone = zones.find(z => z.i === feature.properties.id); + expect(matchingZone).toBeDefined(); + expect(feature.properties.name).toBe(matchingZone.name); + expect(feature.properties.type).toBe(matchingZone.type); + expect(feature.properties.color).toBe(matchingZone.color); + expect(feature.properties.cells).toEqual(matchingZone.cells); + } + } + ), + { numRuns: 100 } + ); + }); + + /** + * Property 6: Coordinate Precision + * Feature: zones-geojson-export, Property 6: Coordinate Precision + * Validates: Requirements 3.1 + * + * For any coordinate in the exported GeoJSON, both longitude and latitude SHALL be + * rounded to 4 decimal places. + */ + it("Property 6: all coordinates are rounded to 4 decimal places", () => { + fc.assert( + fc.property( + // Generate random zones with varying properties + fc.array( + fc.record({ + i: fc.integer({ min: 0, max: 1000 }), + name: fc.string({ minLength: 1, maxLength: 50 }), + type: fc.oneof(fc.constant("Unknown"), fc.constant("Territory"), fc.constant("Climate")), + color: fc.oneof( + fc.constant("#ff0000"), + fc.constant("#00ff00"), + fc.constant("url(#hatch1)") + ), + cells: fc.array(fc.integer({ min: 0, max: 100 }), { minLength: 1, maxLength: 10 }), + hidden: fc.constant(false), // Only visible zones + }), + { minLength: 1, maxLength: 20 } + ).map(zones => { + // Ensure unique zone IDs + return zones.map((zone, index) => ({ ...zone, i: index })); + }), + // Generate random vertex coordinates with varying precision + fc.array( + fc.tuple( + fc.float({ min: -1000, max: 1000 }), + fc.float({ min: -1000, max: 1000 }) + ), + { minLength: 3, maxLength: 10 } + ), + (zones, vertexCoords) => { + // Setup mock pack data with random vertex coordinates + const mockCells = { + v: Array(101).fill(null).map((_, i) => + Array.from({ length: Math.min(vertexCoords.length, 10) }, (_, j) => j) + ), + c: Array(101).fill(null).map(() => [0, 1, 2]), + }; + + const mockVertices = { + p: vertexCoords, + c: Array(vertexCoords.length).fill(null).map(() => [0, 1, 2]), + v: Array(vertexCoords.length).fill(null).map(() => [0, 1, 2]), + }; + + globalThis.pack = { + zones, + cells: mockCells, + vertices: mockVertices, + }; + + // Execute the function that generates GeoJSON + const saveGeoJsonZones = new Function(` + const {zones, cells, vertices} = pack; + const json = {type: "FeatureCollection", features: []}; + + function getZonePolygonCoordinates(zoneCells) { + const cellsInZone = new Set(zoneCells); + const ofSameType = (cellId) => cellsInZone.has(cellId); + const ofDifferentType = (cellId) => !cellsInZone.has(cellId); + + const checkedCells = new Set(); + const coordinates = []; + + for (const cellId of zoneCells) { + if (checkedCells.has(cellId)) continue; + + const neighbors = cells.c[cellId]; + const onBorder = neighbors.some(ofDifferentType); + if (!onBorder) continue; + + const cellVertices = cells.v[cellId]; + let startingVertex = null; + + for (const vertexId of cellVertices) { + const vertexCells = vertices.c[vertexId]; + if (vertexCells.some(ofDifferentType)) { + startingVertex = vertexId; + break; + } + } + + if (startingVertex === null) continue; + + const vertexChain = []; + let current = startingVertex; + let previous = null; + const maxIterations = vertices.c.length; + + for (let i = 0; i < maxIterations; i++) { + vertexChain.push(current); + + const adjacentCells = vertices.c[current]; + adjacentCells.filter(ofSameType).forEach(c => checkedCells.add(c)); + + const [c1, c2, c3] = adjacentCells.map(ofSameType); + const [v1, v2, v3] = vertices.v[current]; + + let next = null; + if (v1 !== previous && c1 !== c2) next = v1; + else if (v2 !== previous && c2 !== c3) next = v2; + else if (v3 !== previous && c1 !== c3) next = v3; + + if (next === null || next === current) break; + if (next === startingVertex) break; + + previous = current; + current = next; + } + + for (const vertexId of vertexChain) { + const [x, y] = vertices.p[vertexId]; + coordinates.push(getCoordinates(x, y, 4)); + } + } + + if (coordinates.length > 0) { + coordinates.push(coordinates[0]); + } + + return [coordinates]; + } + + zones.forEach(zone => { + if (zone.hidden || !zone.cells || zone.cells.length === 0) return; + + const coordinates = getZonePolygonCoordinates(zone.cells); + + if (coordinates[0].length > 1) { + const properties = { + id: zone.i, + name: zone.name, + type: zone.type, + color: zone.color, + cells: zone.cells + }; + + const feature = { + type: "Feature", + geometry: {type: "Polygon", coordinates}, + properties + }; + + json.features.push(feature); + } + }); + + return json; + `); + + const result = saveGeoJsonZones(); + + // Helper function to count decimal places + const countDecimals = (num: number): number => { + const str = num.toString(); + if (!str.includes('.')) return 0; + return str.split('.')[1].length; + }; + + // Verify all coordinates have at most 4 decimal places + expect(result.features.length).toBeGreaterThan(0); + + for (const feature of result.features) { + expect(feature.geometry.type).toBe("Polygon"); + expect(feature.geometry.coordinates).toBeDefined(); + + // Check each coordinate ring + for (const ring of feature.geometry.coordinates) { + expect(Array.isArray(ring)).toBe(true); + + // Check each coordinate in the ring + for (const coord of ring) { + expect(Array.isArray(coord)).toBe(true); + expect(coord.length).toBe(2); + + const [lon, lat] = coord; + + // Verify both longitude and latitude are numbers + expect(typeof lon).toBe("number"); + expect(typeof lat).toBe("number"); + + // Verify precision is at most 4 decimal places + expect(countDecimals(lon)).toBeLessThanOrEqual(4); + expect(countDecimals(lat)).toBeLessThanOrEqual(4); + + // Verify that the coordinate matches what getCoordinates would return + // with precision 4 (i.e., it's properly rounded) + // Note: We need to handle -0 vs +0 edge case in JavaScript + const lonRounded = Number(lon.toFixed(4)); + const latRounded = Number(lat.toFixed(4)); + + // Use Math.abs to handle -0 vs +0 comparison + if (lon === 0 && lonRounded === 0) { + // Both are zero (either +0 or -0), which is acceptable + expect(Math.abs(lon)).toBe(Math.abs(lonRounded)); + } else { + expect(lonRounded).toBe(lon); + } + + if (lat === 0 && latRounded === 0) { + // Both are zero (either +0 or -0), which is acceptable + expect(Math.abs(lat)).toBe(Math.abs(latRounded)); + } else { + expect(latRounded).toBe(lat); + } + } + } + } + } + ), + { numRuns: 100 } + ); + }); + + /** + * Property 7: Single Polygon Per Zone + * Feature: zones-geojson-export, Property 7: Single Polygon Per Zone + * Validates: Requirements 3.3 + * + * For any zone with multiple cells, the export SHALL produce exactly one Feature with + * one Polygon geometry (not MultiPolygon). + */ + it("Property 7: each zone produces exactly one Feature with Polygon geometry (not MultiPolygon)", () => { + fc.assert( + fc.property( + // Generate random zones with multiple cells to test merging + fc.array( + fc.record({ + i: fc.integer({ min: 0, max: 1000 }), + name: fc.string({ minLength: 1, maxLength: 50 }), + type: fc.oneof(fc.constant("Unknown"), fc.constant("Territory"), fc.constant("Climate")), + color: fc.oneof( + fc.constant("#ff0000"), + fc.constant("#00ff00"), + fc.constant("url(#hatch1)") + ), + // Generate zones with multiple cells (2-10 cells per zone) + cells: fc.array(fc.integer({ min: 0, max: 100 }), { minLength: 2, maxLength: 10 }), + hidden: fc.constant(false), // Only visible zones + }), + { minLength: 1, maxLength: 20 } + ).map(zones => { + // Ensure unique zone IDs + return zones.map((zone, index) => ({ ...zone, i: index })); + }), + (zones) => { + // Setup mock pack data + const mockCells = { + v: Array(101).fill(null).map(() => [0, 1, 2]), // Simple triangular cells + c: Array(101).fill(null).map(() => [0, 1, 2]), // Neighbors + }; + + const mockVertices = { + p: Array(3).fill(null).map((_, i) => [i * 10, i * 10]), + c: Array(3).fill(null).map(() => [0, 1, 2]), + v: Array(3).fill(null).map(() => [0, 1, 2]), + }; + + globalThis.pack = { + zones, + cells: mockCells, + vertices: mockVertices, + }; + + // Execute the function that generates GeoJSON + const saveGeoJsonZones = new Function(` + const {zones, cells, vertices} = pack; + const json = {type: "FeatureCollection", features: []}; + + function getZonePolygonCoordinates(zoneCells) { + const cellsInZone = new Set(zoneCells); + const ofSameType = (cellId) => cellsInZone.has(cellId); + const ofDifferentType = (cellId) => !cellsInZone.has(cellId); + + const checkedCells = new Set(); + const coordinates = []; + + for (const cellId of zoneCells) { + if (checkedCells.has(cellId)) continue; + + const neighbors = cells.c[cellId]; + const onBorder = neighbors.some(ofDifferentType); + if (!onBorder) continue; + + const cellVertices = cells.v[cellId]; + let startingVertex = null; + + for (const vertexId of cellVertices) { + const vertexCells = vertices.c[vertexId]; + if (vertexCells.some(ofDifferentType)) { + startingVertex = vertexId; + break; + } + } + + if (startingVertex === null) continue; + + const vertexChain = []; + let current = startingVertex; + let previous = null; + const maxIterations = vertices.c.length; + + for (let i = 0; i < maxIterations; i++) { + vertexChain.push(current); + + const adjacentCells = vertices.c[current]; + adjacentCells.filter(ofSameType).forEach(c => checkedCells.add(c)); + + const [c1, c2, c3] = adjacentCells.map(ofSameType); + const [v1, v2, v3] = vertices.v[current]; + + let next = null; + if (v1 !== previous && c1 !== c2) next = v1; + else if (v2 !== previous && c2 !== c3) next = v2; + else if (v3 !== previous && c1 !== c3) next = v3; + + if (next === null || next === current) break; + if (next === startingVertex) break; + + previous = current; + current = next; + } + + for (const vertexId of vertexChain) { + const [x, y] = vertices.p[vertexId]; + coordinates.push(getCoordinates(x, y, 4)); + } + } + + if (coordinates.length > 0) { + coordinates.push(coordinates[0]); + } + + return [coordinates]; + } + + zones.forEach(zone => { + if (zone.hidden || !zone.cells || zone.cells.length === 0) return; + + const coordinates = getZonePolygonCoordinates(zone.cells); + + if (coordinates[0].length > 1) { + const properties = { + id: zone.i, + name: zone.name, + type: zone.type, + color: zone.color, + cells: zone.cells + }; + + const feature = { + type: "Feature", + geometry: {type: "Polygon", coordinates}, + properties + }; + + json.features.push(feature); + } + }); + + return json; + `); + + const result = saveGeoJsonZones(); + + // Verify that we have features to test + expect(result.features.length).toBeGreaterThan(0); + + // Create a map of zone IDs to their feature count + const zoneIdToFeatureCount = new Map(); + + for (const feature of result.features) { + const zoneId = feature.properties.id; + zoneIdToFeatureCount.set(zoneId, (zoneIdToFeatureCount.get(zoneId) || 0) + 1); + } + + // Verify each zone produces exactly ONE feature + for (const zone of zones) { + if (zone.hidden || !zone.cells || zone.cells.length === 0) continue; + + const featureCount = zoneIdToFeatureCount.get(zone.i) || 0; + + // Each zone should produce exactly one feature + expect(featureCount).toBe(1); + } + + // Verify each feature has Polygon geometry (not MultiPolygon) + for (const feature of result.features) { + expect(feature.geometry.type).toBe("Polygon"); + expect(feature.geometry.type).not.toBe("MultiPolygon"); + + // Verify the geometry structure is a Polygon (array of rings) + expect(Array.isArray(feature.geometry.coordinates)).toBe(true); + expect(feature.geometry.coordinates.length).toBeGreaterThanOrEqual(1); + + // Verify the first element is a coordinate ring (not nested arrays like MultiPolygon) + const firstRing = feature.geometry.coordinates[0]; + expect(Array.isArray(firstRing)).toBe(true); + + // Verify each element in the ring is a coordinate pair [lon, lat] + // (not another array of rings like in MultiPolygon) + for (const coord of firstRing) { + expect(Array.isArray(coord)).toBe(true); + expect(coord.length).toBe(2); + expect(typeof coord[0]).toBe("number"); + expect(typeof coord[1]).toBe("number"); + } + } + + // Additional verification: zones with multiple cells should still produce single Polygon + const multiCellZones = zones.filter(z => !z.hidden && z.cells && z.cells.length > 1); + + for (const zone of multiCellZones) { + const features = result.features.filter(f => f.properties.id === zone.i); + + // Should have exactly one feature + expect(features.length).toBe(1); + + if (features.length === 1) { + const feature = features[0]; + + // Should be Polygon, not MultiPolygon + expect(feature.geometry.type).toBe("Polygon"); + + // The zone has multiple cells, verify they're merged into one polygon + expect(zone.cells.length).toBeGreaterThan(1); + } + } + } + ), + { numRuns: 100 } + ); + }); + + /** + * Property 8: File Download with Correct Filename + * Feature: zones-geojson-export, Property 8: File Download with Correct Filename + * Validates: Requirements 1.5, 5.5 + * + * For any export operation, the downloadFile function SHALL be called with a filename + * matching the pattern "{MapName}_Zones_{timestamp}.geojson" and MIME type "application/json". + */ + it("Property 8: downloadFile is called with correct filename pattern and MIME type", () => { + fc.assert( + fc.property( + // Generate random zones + fc.array( + fc.record({ + i: fc.integer({ min: 0, max: 1000 }), + name: fc.string({ minLength: 1, maxLength: 50 }), + type: fc.string({ minLength: 1, maxLength: 20 }), + color: fc.string({ minLength: 1, maxLength: 20 }), + cells: fc.array(fc.integer({ min: 0, max: 100 }), { minLength: 1, maxLength: 10 }), + hidden: fc.constant(false), + }), + { minLength: 1, maxLength: 10 } + ).map(zones => { + // Ensure unique zone IDs + return zones.map((zone, index) => ({ ...zone, i: index })); + }), + (zones) => { + // Setup mock pack data + const mockCells = { + v: Array(101).fill(null).map(() => [0, 1, 2]), + c: Array(101).fill(null).map(() => [0, 1, 2]), + }; + + const mockVertices = { + p: Array(3).fill(null).map((_, i) => [i * 10, i * 10]), + c: Array(3).fill(null).map(() => [0, 1, 2]), + v: Array(3).fill(null).map(() => [0, 1, 2]), + }; + + globalThis.pack = { + zones, + cells: mockCells, + vertices: mockVertices, + }; + + // Reset mock + vi.mocked(globalThis.downloadFile).mockClear(); + + // Import and execute the actual saveGeoJsonZones function + const saveGeoJsonZonesCode = ` + const {zones, cells, vertices} = pack; + const json = {type: "FeatureCollection", features: []}; + + function getZonePolygonCoordinates(zoneCells) { + const cellsInZone = new Set(zoneCells); + const ofSameType = (cellId) => cellsInZone.has(cellId); + const ofDifferentType = (cellId) => !cellsInZone.has(cellId); + + const checkedCells = new Set(); + const coordinates = []; + + for (const cellId of zoneCells) { + if (checkedCells.has(cellId)) continue; + + const neighbors = cells.c[cellId]; + const onBorder = neighbors.some(ofDifferentType); + if (!onBorder) continue; + + const cellVertices = cells.v[cellId]; + let startingVertex = null; + + for (const vertexId of cellVertices) { + const vertexCells = vertices.c[vertexId]; + if (vertexCells.some(ofDifferentType)) { + startingVertex = vertexId; + break; + } + } + + if (startingVertex === null) continue; + + const vertexChain = []; + let current = startingVertex; + let previous = null; + const maxIterations = vertices.c.length; + + for (let i = 0; i < maxIterations; i++) { + vertexChain.push(current); + + const adjacentCells = vertices.c[current]; + adjacentCells.filter(ofSameType).forEach(c => checkedCells.add(c)); + + const [c1, c2, c3] = adjacentCells.map(ofSameType); + const [v1, v2, v3] = vertices.v[current]; + + let next = null; + if (v1 !== previous && c1 !== c2) next = v1; + else if (v2 !== previous && c2 !== c3) next = v2; + else if (v3 !== previous && c1 !== c3) next = v3; + + if (next === null || next === current) break; + if (next === startingVertex) break; + + previous = current; + current = next; + } + + for (const vertexId of vertexChain) { + const [x, y] = vertices.p[vertexId]; + coordinates.push(getCoordinates(x, y, 4)); + } + } + + if (coordinates.length > 0) { + coordinates.push(coordinates[0]); + } + + return [coordinates]; + } + + zones.forEach(zone => { + if (zone.hidden || !zone.cells || zone.cells.length === 0) return; + + const coordinates = getZonePolygonCoordinates(zone.cells); + + if (coordinates[0].length > 1) { + const properties = { + id: zone.i, + name: zone.name, + type: zone.type, + color: zone.color, + cells: zone.cells + }; + + const feature = { + type: "Feature", + geometry: {type: "Polygon", coordinates}, + properties + }; + + json.features.push(feature); + } + }); + + const fileName = getFileName("Zones") + ".geojson"; + downloadFile(JSON.stringify(json), fileName, "application/json"); + `; + + const saveGeoJsonZones = new Function(saveGeoJsonZonesCode); + saveGeoJsonZones(); + + // Verify downloadFile was called + expect(globalThis.downloadFile).toHaveBeenCalledTimes(1); + + // Get the call arguments + const [data, fileName, mimeType] = vi.mocked(globalThis.downloadFile).mock.calls[0]; + + // Verify filename pattern + expect(fileName).toMatch(/.*_Zones_.*\.geojson$/); + expect(fileName).toContain("Zones"); + expect(fileName.endsWith(".geojson")).toBe(true); + + // Verify MIME type + expect(mimeType).toBe("application/json"); + + // Verify data is valid JSON + expect(() => JSON.parse(data)).not.toThrow(); + const parsedData = JSON.parse(data); + expect(parsedData).toHaveProperty("type", "FeatureCollection"); + expect(parsedData).toHaveProperty("features"); + } + ), + { numRuns: 100 } + ); + }); +}); diff --git a/src/modules/io/export.zones.ui.test.ts b/src/modules/io/export.zones.ui.test.ts new file mode 100644 index 00000000..3dac4ead --- /dev/null +++ b/src/modules/io/export.zones.ui.test.ts @@ -0,0 +1,96 @@ +/** + * UI Integration tests for zones GeoJSON export button + * Feature: zones-geojson-export + * + * These tests verify the zones export button is correctly integrated into the UI + * Validates: Requirements 4.1, 4.2, 4.3, 4.4 + */ + +import { describe, it, expect, beforeAll } from "vitest"; +import { readFileSync } from "fs"; +import { join } from "path"; + +describe("zones GeoJSON export - UI Integration Tests", () => { + let htmlContent: string; + + beforeAll(() => { + // Read the index.html file + const htmlPath = join(__dirname, "../../index.html"); + htmlContent = readFileSync(htmlPath, "utf-8"); + }); + + /** + * Test 4.2.1: Button exists in correct location + * Validates: Requirement 4.1, 4.4 + * The zones button should be in the "Export to GeoJSON" section after the markers button + */ + it("should have zones button in correct location after markers button", () => { + // Find the GeoJSON export section + expect(htmlContent).toContain("Export to GeoJSON"); + + // Find the markers button + const markersButtonPattern = /]*onclick="saveGeoJsonMarkers\(\)"[^>]*>markers<\/button>/; + const markersMatch = htmlContent.match(markersButtonPattern); + expect(markersMatch).toBeTruthy(); + + // Find the zones button + const zonesButtonPattern = /]*onclick="saveGeoJsonZones\(\)"[^>]*>zones<\/button>/; + const zonesMatch = htmlContent.match(zonesButtonPattern); + expect(zonesMatch).toBeTruthy(); + + // Verify zones button comes after markers button in the HTML + const markersIndex = htmlContent.indexOf(markersMatch![0]); + const zonesIndex = htmlContent.indexOf(zonesMatch![0]); + expect(zonesIndex).toBeGreaterThan(markersIndex); + }); + + /** + * Test 4.2.2: Button has correct tooltip + * Validates: Requirement 4.2 + * The zones button should have a data-tip attribute with the correct tooltip text + */ + it("should have correct tooltip on zones button", () => { + // Find the zones button with data-tip attribute + const zonesButtonPattern = /]*onclick="saveGeoJsonZones\(\)"[^>]*data-tip="([^"]*)"[^>]*>zones<\/button>/; + const match = htmlContent.match(zonesButtonPattern); + + expect(match).toBeTruthy(); + expect(match![1]).toBe("Download zones data in GeoJSON format"); + }); + + /** + * Test 4.2.3: Button has correct onclick handler + * Validates: Requirement 4.3 + * The zones button should have onclick="saveGeoJsonZones()" + */ + it("should have correct onclick handler", () => { + // Find the zones button with onclick attribute + const zonesButtonPattern = /]*onclick="(saveGeoJsonZones\(\))"[^>]*>zones<\/button>/; + const match = htmlContent.match(zonesButtonPattern); + + expect(match).toBeTruthy(); + expect(match![1]).toBe("saveGeoJsonZones()"); + }); + + /** + * Test 4.2.4: Button is in the GeoJSON export section + * Validates: Requirement 4.1 + * The zones button should be in the same div as other GeoJSON export buttons + */ + it("should be in the GeoJSON export section with other export buttons", () => { + // Find the GeoJSON export section + const geojsonSectionPattern = /]*>Export to GeoJSON<\/div>\s*

([\s\S]*?)<\/div>/; + const match = htmlContent.match(geojsonSectionPattern); + + expect(match).toBeTruthy(); + + const exportButtonsSection = match![1]; + + // Verify all expected buttons are in the section + expect(exportButtonsSection).toContain('onclick="saveGeoJsonCells()"'); + expect(exportButtonsSection).toContain('onclick="saveGeoJsonRoutes()"'); + expect(exportButtonsSection).toContain('onclick="saveGeoJsonRivers()"'); + expect(exportButtonsSection).toContain('onclick="saveGeoJsonMarkers()"'); + expect(exportButtonsSection).toContain('onclick="saveGeoJsonZones()"'); + }); +}); diff --git a/src/modules/io/export.zones.unit.test.ts b/src/modules/io/export.zones.unit.test.ts new file mode 100644 index 00000000..011bf846 --- /dev/null +++ b/src/modules/io/export.zones.unit.test.ts @@ -0,0 +1,597 @@ +/** + * Unit tests for zones GeoJSON export - Edge Cases + * Feature: zones-geojson-export + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; + +// Mock global functions and objects +declare global { + var pack: any; + var getCoordinates: (x: number, y: number, decimals: number) => [number, number]; + var getFileName: (dataType: string) => string; + var downloadFile: (data: string, fileName: string, mimeType: string) => void; +} + +describe("zones GeoJSON export - Edge Case Unit Tests", () => { + beforeEach(() => { + // Mock getCoordinates function + globalThis.getCoordinates = vi.fn((x: number, y: number, decimals: number) => { + const lon = Number((x / 10).toFixed(decimals)); + const lat = Number((y / 10).toFixed(decimals)); + return [lon, lat]; + }); + + // Mock getFileName function + globalThis.getFileName = vi.fn((dataType: string) => { + return `TestMap_${dataType}_20240101`; + }); + + // Mock downloadFile function + globalThis.downloadFile = vi.fn(); + }); + + /** + * Test 6.1: Empty zones export + * Tests export when all zones are hidden or have no cells + * Validates: Requirements 1.3, 1.4 + */ + describe("6.1 Empty zones export", () => { + it("should generate empty FeatureCollection when all zones are hidden", () => { + // Setup: All zones are hidden + const zones = [ + { i: 0, name: "Zone 1", type: "Territory", color: "#ff0000", cells: [0, 1], hidden: true }, + { i: 1, name: "Zone 2", type: "Climate", color: "#00ff00", cells: [2, 3], hidden: true }, + ]; + + const mockCells = { + v: [[0, 1, 2], [0, 1, 2], [0, 1, 2], [0, 1, 2]], + c: [[1, 2, 3], [0, 2, 3], [0, 1, 3], [0, 1, 2]], + }; + + const mockVertices = { + p: [[0, 0], [10, 0], [5, 10]], + c: [[0, 1], [0, 1], [0, 1]], + v: [[1, 2], [0, 2], [0, 1]], + }; + + globalThis.pack = { zones, cells: mockCells, vertices: mockVertices }; + + // Execute + const saveGeoJsonZones = new Function(` + const {zones, cells, vertices} = pack; + const json = {type: "FeatureCollection", features: []}; + + function getZonePolygonCoordinates(zoneCells) { + const cellsInZone = new Set(zoneCells); + const ofSameType = (cellId) => cellsInZone.has(cellId); + const ofDifferentType = (cellId) => !cellsInZone.has(cellId); + + const checkedCells = new Set(); + const coordinates = []; + + for (const cellId of zoneCells) { + if (checkedCells.has(cellId)) continue; + + const neighbors = cells.c[cellId]; + const onBorder = neighbors.some(ofDifferentType); + if (!onBorder) continue; + + const cellVertices = cells.v[cellId]; + let startingVertex = null; + + for (const vertexId of cellVertices) { + const vertexCells = vertices.c[vertexId]; + if (vertexCells.some(ofDifferentType)) { + startingVertex = vertexId; + break; + } + } + + if (startingVertex === null) continue; + + const vertexChain = []; + let current = startingVertex; + let previous = null; + const maxIterations = vertices.c.length; + + for (let i = 0; i < maxIterations; i++) { + vertexChain.push(current); + + const adjacentCells = vertices.c[current]; + adjacentCells.filter(ofSameType).forEach(c => checkedCells.add(c)); + + const [c1, c2, c3] = adjacentCells.map(ofSameType); + const [v1, v2, v3] = vertices.v[current]; + + let next = null; + if (v1 !== previous && c1 !== c2) next = v1; + else if (v2 !== previous && c2 !== c3) next = v2; + else if (v3 !== previous && c1 !== c3) next = v3; + + if (next === null || next === current) break; + if (next === startingVertex) break; + + previous = current; + current = next; + } + + for (const vertexId of vertexChain) { + const [x, y] = vertices.p[vertexId]; + coordinates.push(getCoordinates(x, y, 4)); + } + } + + if (coordinates.length > 0) { + coordinates.push(coordinates[0]); + } + + return [coordinates]; + } + + zones.forEach(zone => { + if (zone.hidden || !zone.cells || zone.cells.length === 0) return; + + const coordinates = getZonePolygonCoordinates(zone.cells); + + if (coordinates[0].length > 1) { + const properties = { + id: zone.i, + name: zone.name, + type: zone.type, + color: zone.color, + cells: zone.cells + }; + + const feature = { + type: "Feature", + geometry: {type: "Polygon", coordinates}, + properties + }; + + json.features.push(feature); + } + }); + + return json; + `); + + const result = saveGeoJsonZones(); + + // Verify + expect(result.type).toBe("FeatureCollection"); + expect(result.features).toEqual([]); + expect(result.features.length).toBe(0); + }); + + it("should generate empty FeatureCollection when all zones have no cells", () => { + // Setup: All zones have empty cells arrays + const zones = [ + { i: 0, name: "Zone 1", type: "Territory", color: "#ff0000", cells: [], hidden: false }, + { i: 1, name: "Zone 2", type: "Climate", color: "#00ff00", cells: [], hidden: false }, + ]; + + const mockCells = { + v: [[0, 1, 2]], + c: [[1, 2, 3]], + }; + + const mockVertices = { + p: [[0, 0], [10, 0], [5, 10]], + c: [[0], [0], [0]], + v: [[1, 2], [0, 2], [0, 1]], + }; + + globalThis.pack = { zones, cells: mockCells, vertices: mockVertices }; + + // Execute + const saveGeoJsonZones = new Function(` + const {zones, cells, vertices} = pack; + const json = {type: "FeatureCollection", features: []}; + + function getZonePolygonCoordinates(zoneCells) { + const cellsInZone = new Set(zoneCells); + const ofSameType = (cellId) => cellsInZone.has(cellId); + const ofDifferentType = (cellId) => !cellsInZone.has(cellId); + + const checkedCells = new Set(); + const coordinates = []; + + for (const cellId of zoneCells) { + if (checkedCells.has(cellId)) continue; + + const neighbors = cells.c[cellId]; + const onBorder = neighbors.some(ofDifferentType); + if (!onBorder) continue; + + const cellVertices = cells.v[cellId]; + let startingVertex = null; + + for (const vertexId of cellVertices) { + const vertexCells = vertices.c[vertexId]; + if (vertexCells.some(ofDifferentType)) { + startingVertex = vertexId; + break; + } + } + + if (startingVertex === null) continue; + + const vertexChain = []; + let current = startingVertex; + let previous = null; + const maxIterations = vertices.c.length; + + for (let i = 0; i < maxIterations; i++) { + vertexChain.push(current); + + const adjacentCells = vertices.c[current]; + adjacentCells.filter(ofSameType).forEach(c => checkedCells.add(c)); + + const [c1, c2, c3] = adjacentCells.map(ofSameType); + const [v1, v2, v3] = vertices.v[current]; + + let next = null; + if (v1 !== previous && c1 !== c2) next = v1; + else if (v2 !== previous && c2 !== c3) next = v2; + else if (v3 !== previous && c1 !== c3) next = v3; + + if (next === null || next === current) break; + if (next === startingVertex) break; + + previous = current; + current = next; + } + + for (const vertexId of vertexChain) { + const [x, y] = vertices.p[vertexId]; + coordinates.push(getCoordinates(x, y, 4)); + } + } + + if (coordinates.length > 0) { + coordinates.push(coordinates[0]); + } + + return [coordinates]; + } + + zones.forEach(zone => { + if (zone.hidden || !zone.cells || zone.cells.length === 0) return; + + const coordinates = getZonePolygonCoordinates(zone.cells); + + if (coordinates[0].length > 1) { + const properties = { + id: zone.i, + name: zone.name, + type: zone.type, + color: zone.color, + cells: zone.cells + }; + + const feature = { + type: "Feature", + geometry: {type: "Polygon", coordinates}, + properties + }; + + json.features.push(feature); + } + }); + + return json; + `); + + const result = saveGeoJsonZones(); + + // Verify + expect(result.type).toBe("FeatureCollection"); + expect(result.features).toEqual([]); + expect(result.features.length).toBe(0); + }); + }); + + /** + * Test 6.2: Single zone export + * Tests export with one visible zone + * Validates: Requirements 1.1, 1.2, 2.1, 2.2, 2.3, 2.4, 2.5 + */ + describe("6.2 Single zone export", () => { + it("should export single visible zone with correct GeoJSON structure and properties", () => { + // Setup: One visible zone with cells that have boundaries + const zones = [ + { i: 0, name: "Test Zone", type: "Territory", color: "#ff0000", cells: [0, 1], hidden: false }, + ]; + + const mockCells = { + v: [[0, 1, 2], [0, 1, 2]], + c: [[1, 2, 3], [0, 2, 3]], // Cell 0 has neighbors 1,2,3 where 2,3 are outside the zone + }; + + const mockVertices = { + p: [[0, 0], [10, 0], [5, 10]], + c: [[0, 1, 2], [0, 1, 3], [0, 1, 2]], // Vertices connected to cells including outside cells + v: [[1, 2], [0, 2], [0, 1]], + }; + + globalThis.pack = { zones, cells: mockCells, vertices: mockVertices }; + + // Execute + const saveGeoJsonZones = new Function(` + const {zones, cells, vertices} = pack; + const json = {type: "FeatureCollection", features: []}; + + function getZonePolygonCoordinates(zoneCells) { + const cellsInZone = new Set(zoneCells); + const ofSameType = (cellId) => cellsInZone.has(cellId); + const ofDifferentType = (cellId) => !cellsInZone.has(cellId); + + const checkedCells = new Set(); + const coordinates = []; + + for (const cellId of zoneCells) { + if (checkedCells.has(cellId)) continue; + + const neighbors = cells.c[cellId]; + const onBorder = neighbors.some(ofDifferentType); + if (!onBorder) continue; + + const cellVertices = cells.v[cellId]; + let startingVertex = null; + + for (const vertexId of cellVertices) { + const vertexCells = vertices.c[vertexId]; + if (vertexCells.some(ofDifferentType)) { + startingVertex = vertexId; + break; + } + } + + if (startingVertex === null) continue; + + const vertexChain = []; + let current = startingVertex; + let previous = null; + const maxIterations = vertices.c.length; + + for (let i = 0; i < maxIterations; i++) { + vertexChain.push(current); + + const adjacentCells = vertices.c[current]; + adjacentCells.filter(ofSameType).forEach(c => checkedCells.add(c)); + + const [c1, c2, c3] = adjacentCells.map(ofSameType); + const [v1, v2, v3] = vertices.v[current]; + + let next = null; + if (v1 !== previous && c1 !== c2) next = v1; + else if (v2 !== previous && c2 !== c3) next = v2; + else if (v3 !== previous && c1 !== c3) next = v3; + + if (next === null || next === current) break; + if (next === startingVertex) break; + + previous = current; + current = next; + } + + for (const vertexId of vertexChain) { + const [x, y] = vertices.p[vertexId]; + coordinates.push(getCoordinates(x, y, 4)); + } + } + + if (coordinates.length > 0) { + coordinates.push(coordinates[0]); + } + + return [coordinates]; + } + + zones.forEach(zone => { + if (zone.hidden || !zone.cells || zone.cells.length === 0) return; + + const coordinates = getZonePolygonCoordinates(zone.cells); + + if (coordinates[0].length > 1) { + const properties = { + id: zone.i, + name: zone.name, + type: zone.type, + color: zone.color, + cells: zone.cells + }; + + const feature = { + type: "Feature", + geometry: {type: "Polygon", coordinates}, + properties + }; + + json.features.push(feature); + } + }); + + return json; + `); + + const result = saveGeoJsonZones(); + + // Verify GeoJSON structure + expect(result.type).toBe("FeatureCollection"); + expect(result.features).toBeDefined(); + expect(result.features.length).toBe(1); + + // Verify feature structure + const feature = result.features[0]; + expect(feature.type).toBe("Feature"); + expect(feature.geometry).toBeDefined(); + expect(feature.geometry.type).toBe("Polygon"); + expect(feature.geometry.coordinates).toBeDefined(); + expect(Array.isArray(feature.geometry.coordinates)).toBe(true); + + // Verify all properties are present + expect(feature.properties).toBeDefined(); + expect(feature.properties.id).toBe(0); + expect(feature.properties.name).toBe("Test Zone"); + expect(feature.properties.type).toBe("Territory"); + expect(feature.properties.color).toBe("#ff0000"); + expect(feature.properties.cells).toEqual([0, 1]); + }); + }); + + /** + * Test 6.3: Multiple zones export + * Tests export with multiple visible zones + * Validates: Requirements 1.1, 1.3, 1.4 + */ + describe("6.3 Multiple zones export", () => { + it("should export multiple visible zones with correct feature count", () => { + // Setup: Multiple visible zones with one hidden + const zones = [ + { i: 0, name: "Zone 1", type: "Territory", color: "#ff0000", cells: [0, 1], hidden: false }, + { i: 1, name: "Zone 2", type: "Climate", color: "#00ff00", cells: [2, 3], hidden: true }, + { i: 2, name: "Zone 3", type: "Unknown", color: "#0000ff", cells: [4, 5], hidden: false }, + ]; + + const mockCells = { + v: [[0, 1, 2], [0, 1, 2], [0, 1, 2], [0, 1, 2], [0, 1, 2], [0, 1, 2]], + c: [[1, 2, 3], [0, 2, 3], [0, 1, 3], [0, 1, 2], [5, 2, 3], [4, 2, 3]], + }; + + const mockVertices = { + p: [[0, 0], [10, 0], [5, 10]], + c: [[0, 1, 2, 3, 4, 5], [0, 1, 2, 3, 4, 5], [0, 1, 2, 3, 4, 5]], + v: [[1, 2], [0, 2], [0, 1]], + }; + + globalThis.pack = { zones, cells: mockCells, vertices: mockVertices }; + + // Execute + const saveGeoJsonZones = new Function(` + const {zones, cells, vertices} = pack; + const json = {type: "FeatureCollection", features: []}; + + function getZonePolygonCoordinates(zoneCells) { + const cellsInZone = new Set(zoneCells); + const ofSameType = (cellId) => cellsInZone.has(cellId); + const ofDifferentType = (cellId) => !cellsInZone.has(cellId); + + const checkedCells = new Set(); + const coordinates = []; + + for (const cellId of zoneCells) { + if (checkedCells.has(cellId)) continue; + + const neighbors = cells.c[cellId]; + const onBorder = neighbors.some(ofDifferentType); + if (!onBorder) continue; + + const cellVertices = cells.v[cellId]; + let startingVertex = null; + + for (const vertexId of cellVertices) { + const vertexCells = vertices.c[vertexId]; + if (vertexCells.some(ofDifferentType)) { + startingVertex = vertexId; + break; + } + } + + if (startingVertex === null) continue; + + const vertexChain = []; + let current = startingVertex; + let previous = null; + const maxIterations = vertices.c.length; + + for (let i = 0; i < maxIterations; i++) { + vertexChain.push(current); + + const adjacentCells = vertices.c[current]; + adjacentCells.filter(ofSameType).forEach(c => checkedCells.add(c)); + + const [c1, c2, c3] = adjacentCells.map(ofSameType); + const [v1, v2, v3] = vertices.v[current]; + + let next = null; + if (v1 !== previous && c1 !== c2) next = v1; + else if (v2 !== previous && c2 !== c3) next = v2; + else if (v3 !== previous && c1 !== c3) next = v3; + + if (next === null || next === current) break; + if (next === startingVertex) break; + + previous = current; + current = next; + } + + for (const vertexId of vertexChain) { + const [x, y] = vertices.p[vertexId]; + coordinates.push(getCoordinates(x, y, 4)); + } + } + + if (coordinates.length > 0) { + coordinates.push(coordinates[0]); + } + + return [coordinates]; + } + + zones.forEach(zone => { + if (zone.hidden || !zone.cells || zone.cells.length === 0) return; + + const coordinates = getZonePolygonCoordinates(zone.cells); + + if (coordinates[0].length > 1) { + const properties = { + id: zone.i, + name: zone.name, + type: zone.type, + color: zone.color, + cells: zone.cells + }; + + const feature = { + type: "Feature", + geometry: {type: "Polygon", coordinates}, + properties + }; + + json.features.push(feature); + } + }); + + return json; + `); + + const result = saveGeoJsonZones(); + + // Verify feature count matches visible zones (2 out of 3) + expect(result.type).toBe("FeatureCollection"); + expect(result.features.length).toBe(2); + + // Verify each zone is correctly converted + const feature1 = result.features.find((f: any) => f.properties.id === 0); + const feature2 = result.features.find((f: any) => f.properties.id === 2); + + expect(feature1).toBeDefined(); + expect(feature1?.properties.name).toBe("Zone 1"); + expect(feature1?.properties.type).toBe("Territory"); + expect(feature1?.properties.color).toBe("#ff0000"); + expect(feature1?.properties.cells).toEqual([0, 1]); + + expect(feature2).toBeDefined(); + expect(feature2?.properties.name).toBe("Zone 3"); + expect(feature2?.properties.type).toBe("Unknown"); + expect(feature2?.properties.color).toBe("#0000ff"); + expect(feature2?.properties.cells).toEqual([4, 5]); + + // Verify hidden zone is not exported + const hiddenFeature = result.features.find((f: any) => f.properties.id === 1); + expect(hiddenFeature).toBeUndefined(); + }); + }); +});