From 779c1392de566f501e8940e50f60fcb1d15cb408 Mon Sep 17 00:00:00 2001 From: ITBlacksheep Date: Mon, 12 Oct 2026 12:55:45 +0000 Subject: [PATCH 1/3] Fixing requested issues --- .github/workflows/deploy.yml | 2 + .gitignore | 6 +- docs/region-def.md | 288 ++++++++++ public/app.js | 10 +- public/index.html | 3 + public/region-def-core.js | 452 +++++++++++++++ public/region-def.css | 80 +++ public/region-def.js | 543 ++++++++++++++++++ .../region-layers/known-points.json.example | 158 +++++ public/region-layers/layers.json.example | 61 ++ scripts/check-region-data.js | 265 +++++++++ scripts/non-unit-tests.json | 3 +- scripts/simplify-region-layers.js | 149 +++++ test-all.sh | 5 + tests/e2e/test-region-def-e2e.js | 456 +++++++++++++++ .../fixtures/region-layers/known-points.json | 158 +++++ tests/fixtures/region-layers/layers.json | 60 ++ .../region-layers/macro-midwest.geojson | 6 + .../region-layers/macro-southeast.geojson | 4 + tests/fixtures/region-layers/metros.geojson | 5 + tests/fixtures/region-layers/states.geojson | 9 + tests/unit/test-check-region-data.js | 182 ++++++ tests/unit/test-frontend-helpers.js | 27 - tests/unit/test-region-def-known-points.js | 124 ++++ tests/unit/test-region-def.js | 455 +++++++++++++++ tests/unit/test-region-layers-manifest.js | 68 +++ tests/unit/test-simplify-region-layers.js | 100 ++++ 27 files changed, 3647 insertions(+), 32 deletions(-) create mode 100644 docs/region-def.md create mode 100644 public/region-def-core.js create mode 100644 public/region-def.css create mode 100644 public/region-def.js create mode 100644 public/region-layers/known-points.json.example create mode 100644 public/region-layers/layers.json.example create mode 100644 scripts/check-region-data.js create mode 100644 scripts/simplify-region-layers.js create mode 100644 tests/e2e/test-region-def-e2e.js create mode 100644 tests/fixtures/region-layers/known-points.json create mode 100644 tests/fixtures/region-layers/layers.json create mode 100644 tests/fixtures/region-layers/macro-midwest.geojson create mode 100644 tests/fixtures/region-layers/macro-southeast.geojson create mode 100644 tests/fixtures/region-layers/metros.geojson create mode 100644 tests/fixtures/region-layers/states.geojson create mode 100644 tests/unit/test-check-region-data.js create mode 100644 tests/unit/test-region-def-known-points.js create mode 100644 tests/unit/test-region-def.js create mode 100644 tests/unit/test-region-layers-manifest.js create mode 100644 tests/unit/test-simplify-region-layers.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 125abd9b..2f1dba82 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -550,6 +550,8 @@ jobs: CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node tests/e2e/test-home-coverage-e2e.js 2>&1 | tee -a e2e-output.txt echo "=== E2E SUITE: test-path-inspector-coverage-e2e.js ===" CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node tests/e2e/test-path-inspector-coverage-e2e.js 2>&1 | tee -a e2e-output.txt + echo "=== E2E SUITE: test-region-def-e2e.js ===" + CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node tests/e2e/test-region-def-e2e.js 2>&1 | tee -a e2e-output.txt echo "=== E2E SUITE: test-issue-1206-resize-observer-leak-e2e.js ===" CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node tests/e2e/test-issue-1206-resize-observer-leak-e2e.js 2>&1 | tee -a e2e-output.txt echo "=== E2E SUITE: test-nav-drawer-1064-e2e.js ===" diff --git a/.gitignore b/.gitignore index 1d2decac..853f9542 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,8 @@ docs/superpowers/ deploy-live.sh deploy-staging.sh -.DS_Store \ No newline at end of file +.DS_Store + +#geojson files. Only allow the example ones. +public/region-layers/* +!public/region-layers/*.example diff --git a/docs/region-def.md b/docs/region-def.md new file mode 100644 index 00000000..a990e92d --- /dev/null +++ b/docs/region-def.md @@ -0,0 +1,288 @@ +# Region Def tool + +`#/tools/region-def` turns an address (or `lat, lon`, or a map click) into the repeater +commands that set its regions: + +``` +region def us-midwest oki us-oh cvg +region allowf * +region save +``` + +Each configured layer contributes at most one code, in manifest order (top to bottom). + +`region save` is deliberately last. In the MeshCore firmware only `region save` writes the region +table to flash; `region def` and `region allowf` change it in memory. A flood permission set after +`region save` would be lost on the next restart. + +## Before you apply the commands + +**The commands add to what the repeater already has; they do not replace it.** `region def` never +clears the existing region table. Regions left over from an earlier configuration stay unless you +remove them, so check first and clean up as needed: + +1. `region` prints the current region tree (long trees are cut off at the repeater's 160-character + reply). `region list allowed` and `region list denied` show which regions may flood. +2. Remove obsolete entries with `region remove `. A region that still has children cannot be + removed ("Err - not empty"), so remove the leaves first. +3. Paste the generated block. Every region named in `region def` is created if missing and set to + allow flooding; an existing one is moved under the new parent. +4. Run `region` again and check the tree matches the block, and `region get ` for any single + entry (an `F` at the end means flooding is allowed). + +**`region allowf *` is a policy choice.** `*` is the wildcard region: traffic that carries no region +scope. Allowing flood on it means the repeater forwards unscoped flood packets as well as scoped +ones. A fresh repeater already allows this, so the line mostly matters when `region denyf *` was set +earlier: the block turns unscoped flooding back on. If a repeater should forward only region-scoped +floods, delete that line before pasting (or run `region denyf *` afterwards, before `region save`). + +A repeater holds at most 32 regions in total (the firmware default), counting entries that already exist. + +## Rollout scope + +The tool is deliberately regional. It only knows the areas whose boundary files you publish in +`public/region-layers/`; today that is the Ohio/Kentucky/Indiana (OKI) part of the US Midwest and +its neighbours. A point in a state you have not mapped returns the plain message "not inside any +configured region" and no command. That is expected, not a bug. To extend coverage, add the new +boundaries to the existing files (or new files) and add known points for them (see +[Known-point tests](#known-point-tests)). Where a layer is meant to tile the whole mapped area, +mark it `"coverage": "full"` so a hole in the data cannot pass as "no region". + +## Operating it + +### When a layer is missing or failed + +| Situation | What the page does | What you do | +|---|---|---| +| A required layer 404s, times out, or is not valid JSON/GeoJSON | Shows an alert naming the layer and file, and **withholds** the command | Fix or restore the file; reload | +| A required layer loaded but has no polygons | Same as above | Check the file is real GeoJSON with Polygon/MultiPolygon features | +| A polygon matched but has no valid code | Withholds the command and names the layer | Fix the property in the data (see canonical codes) | +| `coverage: "full"` layer misses a point that another layer covers | Withholds the command, warns of a data gap | Add the missing boundary, or drop `full` if the exclusion is real | +| An `optional: true` layer fails | Shows the command flagged "(incomplete)" and names the missing layer | Fine for metros and other nice-to-haves; fix when convenient | +| `layers.json` itself is missing, invalid, unsafe, or newer than the page | Stops with a message saying which | Fix the manifest | + +Warnings that do not block: two regions in one layer overlap (the first wins, both are named), or +the same code is produced by two layers. + +### Troubleshooting + +- **"not inside any configured region" for a place you expected.** Open the "Details" section: each + layer shows INSIDE/OUTSIDE. If every layer says OUTSIDE the area is not mapped, or the + coordinates are swapped. Run + `node scripts/check-region-data.js --point LAT,LON` to see what the data says without a browser. +- **A boundary is missing on the map.** Layers over 50,000 vertices start unticked; tick them in the + toggles under the map. Otherwise the layer failed to load; the page lists it. +- **Command withheld / "incomplete".** See the table above. The message names the layer and + the file. +- **"Could not load ".** Open `/region-layers/` in the browser. 404: wrong name or not + deployed. Not JSON: the server returned an HTML error page or a truncated file. Timeout: the file + is large; simplify it. +- **A code looks wrong (`us-oh` vs `ohio`).** Codes come from the property named in + `nameProperty` (slugged) or `codeProperty` (verbatim). See below. +- **Geocoder messages.** "rate limited" means Nominatim returned 429; wait a minute. Network or + timeout errors do not affect coordinate or map-click lookups, which work offline of the geocoder. +- **Swapped coordinates.** Typing `lon, lat` is only caught automatically when a value falls + outside its valid range (|latitude| above 90). A swap that stays in range, such as `-85, 39` + for a Midwest point, is a valid coordinate somewhere else, so the page accepts it and finds + nothing; check the "where" line under the map. Known-point tests catch a code-level swap. + +## Layer files + +Put static files in `public/region-layers/` (served at `/region-layers/`): + +``` +public/region-layers/ + layers.json + macro-midwest.geojson + mesh-regions.geojson + states.geojson + metros.geojson +``` + +Only publish boundary data you are happy to make public; the repo and the site are public. + +### layers.json + +```json +{ + "version": 1, + "layers": [ + { "id": "macro", "label": "Macro region", + "url": [ { "url": "macro-midwest.geojson", "name": "US-Midwest", "code": "us-midwest" }, + { "url": "macro-southeast.geojson", "name": "US-Southeast", "code": "us-southeast" } ], + "codes": ["us-midwest", "us-southeast"], "coverage": "full" }, + { "id": "mesh", "label": "Mesh region", "url": "states.geojson", + "nameProperty": "shapeName", "groupBy": "meshregion", "codeProperty": "meshregion", + "codes": ["oki", "swoh"] }, + { "id": "state", "label": "State", "url": "states.geojson", + "nameProperty": "shapeName", "codeProperty": "stateCode" }, + { "id": "metro", "label": "Metro", "url": "metros.geojson", + "nameProperty": "iata", "colorBy": "feature", "optional": true } + ] +} +``` + +`version` is the manifest schema version (currently `1`). A missing version is accepted with a +warning; a version newer than the page understands is refused. Unknown layer fields produce a +warning (to catch typos such as `optionnal`); structural errors refuse the manifest and the page +says why. + +| Field | Meaning | +|---|---| +| `id`, `label` | identifier (unique, letters/digits/`_`/`-`) and the name shown in the results list | +| `url` | file path relative to `layers.json`, or an array of paths / `{url, name, code}` objects. Strict allowlist: letters, digits, `_ - .`, `/` between folders, must end `.geojson` or `.json`; no `..`, no leading `/`, no URLs. Each distinct file is fetched once even if several layers use it | +| `{url, name, code}` | all polygons in that file are merged into one region. `name` is the display name; `code` is its canonical code (defaults to the slug of `name`) | +| `nameProperty` | feature property holding the display name; an array means "first non-empty" | +| `codeProperty` | feature property holding the canonical code. Used verbatim, never altered | +| `groupBy` | merge features sharing this property value into one region; features lacking it are dropped | +| `codes` | the canonical codes this layer may produce. A polygon whose code is not in the list can never produce a code | +| `ignoreCodes` | codes of regions deliberately left out of this layer, for example states that are in the boundary file but in no macro region. They are dropped before anything else: not drawn, never matched, and not reported as problems. A code may not be in both `codes` and `ignoreCodes` | +| `coverage` | `"partial"` (default) or `"full"`; see below | +| `color` | fixed layer colour (otherwise the theme palette `--region-layer-1..8`) | +| `colorBy: "feature"` | use each feature's own `properties.color` | +| `highlight` | `false` stops the matched polygon being emphasised on the map | +| `optional` | `true` lets the page still show a command when this layer is unavailable (flagged as incomplete). Default: a layer that fails to load, has no valid polygons, hits a polygon with no usable code, or breaks its coverage promise makes the page withhold the command, because pasting a command with a missing code would set the wrong regions | + +### Canonical codes + +The codes go straight into `region def`, so they are validated rather than corrected: +lowercase letters, digits, `_` and `-`, starting with a letter or digit, at most **30 characters**. +A code comes from, in order: a file's `code`, the layer's `codeProperty`, else the slug of the +display name (trimmed, lowercased, spaces to `-`, other characters removed). An explicit code that +is not valid is not slugged for you: that polygon simply cannot produce a code, and the page says so. +The canonical identifier format is therefore `^[a-z0-9][a-z0-9_-]{0,29}$`, for example +`us-midwest`, `oki`, `us-oh`, `cvg`. The tool never emits anything else. + +These limits come from the MeshCore firmware (`RegionMap.h`: `char name[31]`; `RegionMap::is_name_char`; +the 160-byte serial command buffer). A longer name would be silently truncated by the repeater, so +the tool rejects it. The allowed characters are a subset of what the firmware accepts, and `$` (private +region) and `#` are never produced. The generated `region def` line must also fit the repeater's command +buffer: 158 characters including `region def `. A result over that limit, or one that would define +more than 32 regions, is withheld with the reason instead of being shown as a valid command. + +Which property to use matters. With a geoBoundaries-style dataset the state has +`shapeName: "Ohio"` and `shapeISO: "US-OH"`: + +| Config | Code | +|---|---| +| `"nameProperty": "shapeName"` | `ohio` | +| `"nameProperty": "shapeISO"` | `us-oh` (the slug lowercases `US-OH`) | +| `"codeProperty": "shapeISO"` | **invalid**: `US-OH` is used verbatim and uppercase is not allowed, so every polygon is flagged | + +Pick the one that matches what your repeaters expect and keep `layers.json.example`, your shipped +`layers.json` and this documentation in agreement (the example uses `shapeISO` so the commands +read `us-oh`). Declaring `codes` gives you a canonical list to check against; a typo in the data (`"OKl"`, +`"swoh "`) then shows up instead of silently becoming a new region. + +### Missing regions: exclusion or gap? + +By default a layer that does not contain the point just contributes no code. That is correct for +layers that only cover part of the map (a metro layer, say). For a layer that is supposed to tile +the whole area (macro regions, mesh regions) it hides data holes, so declare `"coverage": "full"`: +if the point is inside any other layer but outside this one, the page treats it as a data gap, +shows a warning, and withholds the command (unless the layer is also `optional`). A point outside +every layer still gets the plain "not inside any configured region" message. + +## Performance + +Polygons are drawn on a canvas, one object per layer. A layer with more than 50,000 vertices +starts unticked and is only built when ticked, and the page warns you at load. For big +boundary sets, simplify the files before publishing. The lookup uses the same geometry as the +drawing, so simplification moves the lookup edge as well; keep the tolerance small and re-run the +known-point tests afterwards. + +``` +node scripts/simplify-region-layers.js --in raw/ --out public/region-layers/ --tolerance 0.0005 --precision 5 +node scripts/check-region-data.js --strict +``` + +`--tolerance` is in degrees (0.0005 is roughly 55 m) and `--precision` is decimal places. Rings +are simplified independently, so neighbours can drift apart by up to the tolerance along a shared +border; points within that sliver may resolve to either side. The script refuses to write output +that fails the integrity check or changes any known-point command. Serve the output as `.geojson` or `.json`; if your server +compresses by content type, make sure `application/geo+json` is on its list (or name the files +`.json`). + +## Privacy and limits + +- The typed address is sent from the visitor's browser to OpenStreetMap Nominatim. The page + spaces requests at least 1.1 s apart, including across navigation within the session + (their usage policy asks for at most 1 request/second; check their current policy). + That spacing is per browser: it cannot coordinate between visitors. Visitors have separate + IPs, which is how a browser-side tool is normally used, but if traffic grows, put an + application-controlled geocoding endpoint (with caching) in front of it instead. + Coordinates and map clicks are never sent to a geocoder or to CoreScope; but the map itself + still requests tiles for the area on screen from the configured tile provider, which can infer + roughly where the visitor is looking. +- Basemap tiles use the existing tile configuration (`map.tiles` in `config.json`, + `map-tile-providers.js`), so the Carto key is handled exactly as on the Map page. + +## Dataset sources and updates + +Record where each file came from in a `SOURCES.md` next to `layers.json` (name, publisher, URL, +licence, retrieval date, any processing). The `shapeName`/`shapeISO` property names used in the +examples follow the geoBoundaries convention, but confirm the source of your own files and its +licence before publishing. To update: + +1. Download the new release and put it in a working folder (not in `public/`). +2. Simplify (see Performance) and write the result to `public/region-layers/`. +3. Run `node scripts/check-region-data.js --strict` and `node tests/unit/test-region-def-known-points.js`. +4. Review the map visually at `#/tools/region-def`, spot-check border points, and commit data, + `SOURCES.md` and any new known points together. + +## Verified example + +Produced with `node scripts/check-region-data.js --dir tests/fixtures/region-layers --point 39.1031,-84.5120` +against the **synthetic test fixture** (Cincinnati). Real boundary data should give the same +string; if it does not, one of them is wrong: + +``` +region def us-midwest oki us-oh cvg +region allowf * +region save +``` + +For your own deployment, generate the block with `--point`, then paste it into +`known-points.json` so it is checked on every CI run. + +## Region data integrity in CI + +`node scripts/check-region-data.js --strict` runs as its own step in `deploy.yml` right after the +unit tests (it passes with a note when no region data is deployed). It validates the manifest, +every referenced file, canonical codes, polygon validity, shared-edge overlap between regions in +a layer, and `known-points.json`. The browser tests (`tests/e2e/test-region-def-e2e.js`) mock +degraded states with fixtures and also run a smoke test against the deployed +`/region-layers/` when present. + +## Tests + +- `tests/unit/test-region-def.js`: geometry, codes, coverage, manifest validation, wiring. +- `tests/unit/test-region-layers-manifest.js`: `layers.json.example` must always validate. If you + commit a real `public/region-layers/layers.json` it is checked too: every file exists and + parses, every layer has polygons, every polygon has a canonical code (in the layer's `codes` + list if one is declared), every declared code is produced by some polygon, and no code repeats + inside a layer. Run it in CI so boundary-data edits cannot break region defs unnoticed. + +- `tests/unit/test-check-region-data.js`, `tests/unit/test-simplify-region-layers.js`: the integrity + script and the simplifier, including failure cases. +- `tests/e2e/test-region-def-e2e.js`: exact commands and messages, geocoder and layer failure modes, + timeouts, overlap, coverage gaps, escaping of hostile text, mobile (320/375/768) and small-laptop + layouts, and a production-data smoke test. CI fails if this script exits non-zero (the suite runs + under `bash -o pipefail`, so `| tee` does not hide failures). + +### Known-point tests + +`tests/unit/test-region-def-known-points.js` always runs a table of real cities (Ohio, Indiana, +Kentucky, and states outside the mesh region such as Michigan and Pennsylvania) against a coarse +built-in fixture, which guards the lookup, grouping and ordering logic. To guard your actual +boundary data, copy `public/region-layers/known-points.json.example` to +`public/region-layers/known-points.json`, edit the expected strings to what your data should +produce, and commit it next to `layers.json`; the same test then evaluates every row against your +real files and fails on any difference or unexpected warning (`"allowWarnings": true` per row +opts out, e.g. for a point deliberately on a shared border). Include points just either side of +borders you care about, and at least one point that should produce no mesh code. + +## Follow-up + +The page re-implements theme-synced tile switching that `map.js` (`_syncDarkTiles`) also +does. Extracting that into a shared helper is the cleaner end state. diff --git a/public/app.js b/public/app.js index 28b97b39..06380824 100644 --- a/public/app.js +++ b/public/app.js @@ -1106,7 +1106,8 @@ registerPage('tools-landing', { '' + + '

Region Def

Look up an address and get the repeater region def commands for its regions.

' + + '' + ''; }, destroy: function () {} @@ -1182,7 +1183,7 @@ function navigate() { basePage = 'observer-detail'; } - // Tools sub-routing (issue #944): tools/trace/, tools/path-inspector + // Tools sub-routing (issue #944): tools/trace/, tools/path-inspector, tools/region-def (issue #187) if (basePage === 'tools') { if (routeParam && routeParam.startsWith('trace/')) { basePage = 'traces'; @@ -1190,6 +1191,9 @@ function navigate() { } else if (routeParam === 'path-inspector' || (routeParam && routeParam.startsWith('path-inspector'))) { basePage = 'path-inspector'; routeParam = null; + } else if (routeParam === 'region-def') { + basePage = 'region-def'; + routeParam = null; } else if (!routeParam) { // Default tools landing shows menu with both entries. basePage = 'tools-landing'; @@ -1202,7 +1206,7 @@ function navigate() { // Update nav active state document.querySelectorAll('.nav-link[data-route]').forEach(el => { - el.classList.toggle('active', el.dataset.route === basePage || (el.dataset.route === 'tools' && (basePage === 'traces' || basePage === 'path-inspector' || basePage === 'tools-landing'))); + el.classList.toggle('active', el.dataset.route === basePage || (el.dataset.route === 'tools' && (basePage === 'traces' || basePage === 'path-inspector' || basePage === 'region-def' || basePage === 'tools-landing'))); }); // Update "More" button to show active state if a low-priority page is selected var moreBtn = document.getElementById('navMoreBtn'); diff --git a/public/index.html b/public/index.html index 715c74c4..235b9bfd 100644 --- a/public/index.html +++ b/public/index.html @@ -48,6 +48,7 @@ + @@ -215,6 +216,8 @@ + + diff --git a/public/region-def-core.js b/public/region-def-core.js new file mode 100644 index 00000000..742cf42b --- /dev/null +++ b/public/region-def-core.js @@ -0,0 +1,452 @@ +/* region-def-core.js — pure logic for the Region Def tool (#/tools/region-def). + * + * Given a set of GeoJSON "layers" (macro region, mesh region, state, metro, ...) + * and a lat/lon, work out which polygon of each layer contains the point and + * build the repeater command `region def ...` (layer order = output + * order, top to bottom). + * + * No DOM, no network, no globals read. Loaded in the browser as window.RegionDef + * and in Node via require() for unit tests. + * + * GeoJSON coordinate order is [lon, lat]. A point exactly on a polygon edge counts + * as inside; a point inside a hole is outside. + */ +(function (root) { + 'use strict'; + + // ---- GeoJSON normalisation ------------------------------------------------- + + // Accepts FeatureCollection | Feature | bare Polygon/MultiPolygon geometry and + // returns a flat array of Polygon/MultiPolygon features. Anything else is dropped. + function normalizeGeoJSON(gj) { + var out = []; + if (!gj || typeof gj !== 'object') return out; + function push(f) { + if (!f || !f.geometry) return; + var t = f.geometry.type; + if (t === 'Polygon' || t === 'MultiPolygon') { + out.push({ type: 'Feature', properties: f.properties || {}, geometry: f.geometry }); + } else if (t === 'GeometryCollection' && Array.isArray(f.geometry.geometries)) { + f.geometry.geometries.forEach(function (g) { push({ properties: f.properties, geometry: g }); }); + } + } + if (gj.type === 'FeatureCollection' && Array.isArray(gj.features)) gj.features.forEach(push); + else if (gj.type === 'Feature') push(gj); + else if (gj.type === 'Polygon' || gj.type === 'MultiPolygon') push({ properties: {}, geometry: gj }); + return out; + } + + function polysOf(geom) { + return geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates; + } + + function geometryBBox(geom) { + var b = [Infinity, Infinity, -Infinity, -Infinity]; // minLon, minLat, maxLon, maxLat + polysOf(geom).forEach(function (poly) { + var ring = poly[0] || []; + for (var i = 0; i < ring.length; i++) { + var p = ring[i]; + if (p[0] < b[0]) b[0] = p[0]; + if (p[1] < b[1]) b[1] = p[1]; + if (p[0] > b[2]) b[2] = p[0]; + if (p[1] > b[3]) b[3] = p[1]; + } + }); + return b; + } + + // ---- point in polygon ------------------------------------------------------ + + var EPS = 1e-12; + + function onSegment(px, py, ax, ay, bx, by) { + var cross = (px - ax) * (by - ay) - (py - ay) * (bx - ax); + if (Math.abs(cross) > EPS * Math.max(1, Math.abs(bx - ax) + Math.abs(by - ay))) return false; + return px >= Math.min(ax, bx) - EPS && px <= Math.max(ax, bx) + EPS && + py >= Math.min(ay, by) - EPS && py <= Math.max(ay, by) + EPS; + } + + // 0 = outside, 1 = inside, 2 = on boundary + function ringLocate(x, y, ring) { + var inside = false; + for (var i = 0, j = ring.length - 1; i < ring.length; j = i++) { + var xi = ring[i][0], yi = ring[i][1], xj = ring[j][0], yj = ring[j][1]; + if (onSegment(x, y, xi, yi, xj, yj)) return 2; + if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) inside = !inside; + } + return inside ? 1 : 0; + } + + // poly = [outerRing, hole, hole, ...] + function polygonContains(poly, x, y) { + if (!poly || !poly.length) return false; + var outer = ringLocate(x, y, poly[0]); + if (outer === 0) return false; + if (outer === 2) return true; // on the outer edge + for (var h = 1; h < poly.length; h++) { + if (ringLocate(x, y, poly[h]) !== 0) return false; // in hole or on hole edge + } + return true; + } + + function geometryContains(geom, lon, lat) { + var polys = polysOf(geom); + for (var i = 0; i < polys.length; i++) { + if (polygonContains(polys[i], lon, lat)) return true; + } + return false; + } + + function countVertices(geom) { + var n = 0; + polysOf(geom).forEach(function (poly) { poly.forEach(function (ring) { n += ring.length; }); }); + return n; + } + + // Strict allowlist for layer file paths from layers.json: relative, [A-Za-z0-9_.-] segments + // joined by single '/', no '.'/'..' segments, must end in .geojson or .json. Rejects schemes, + // protocol-relative URLs, backslashes, %-escapes, queries and fragments. + function isSafeLayerPath(p) { + if (typeof p !== 'string' || p.length === 0 || p.length > 200) return false; + if (!/^[A-Za-z0-9_-][A-Za-z0-9_.\/-]*\.(?:geojson|json)$/.test(p)) return false; + var segs = p.split('/'); + for (var i = 0; i < segs.length; i++) { + if (segs[i] === '' || segs[i] === '.' || segs[i] === '..') return false; + } + return true; + } + + // ---- naming / grouping ----------------------------------------------------- + + // Region code: trim, lowercase, whitespace -> hyphen, strip anything outside [a-z0-9_-]. + function slug(s) { + if (s === null || s === undefined) return ''; + return String(s).trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9_-]/g, ''); + } + + function pick(props, nameProperty) { + var names = Array.isArray(nameProperty) ? nameProperty : [nameProperty]; + for (var i = 0; i < names.length; i++) { + var v = props && props[names[i]]; + if (v !== undefined && v !== null && String(v).trim() !== '') return v; + } + return null; + } + + // Name of a feature (merged features carry __name). null when it has no usable name. + function featureName(feature, nameProperty) { + var props = feature.properties || {}; + if (props.__name) return String(props.__name); + return pick(props, nameProperty || 'name'); + } + + // Canonical region codes are written to repeaters, so they are validated, never "fixed up": + // lowercase letters, digits, '_' and '-', starting with a letter or digit. + // + // Firmware limits (MeshCore src/helpers/RegionMap.h/.cpp, CommonCLI.cpp, examples/simple_repeater/main.cpp): + // - RegionEntry.name is `char name[31]`: at most 30 characters. A longer name is silently truncated + // by the repeater, so it would not match what the operator meant. FW_MAX_NAME_LEN + // - RegionMap::is_name_char accepts '-', '$', '#', digits and every byte >= 'A'. The characters + // allowed here are a strict subset; '$' (private region) and '#' are deliberately never emitted. + // - The serial CLI line buffer is `char command[160]`, one byte of which is the terminating CR, so a + // `region def ...` line longer than 158 characters is cut off. FW_MAX_LINE_LEN + // - A repeater holds at most MAX_REGION_ENTRIES (32 by default) regions in total. FW_MAX_REGIONS + var FW_MAX_NAME_LEN = 30; + var FW_MAX_LINE_LEN = 158; + var FW_MAX_REGIONS = 32; + var CODE_RE = /^[a-z0-9][a-z0-9_-]{0,29}$/; + + // The code a feature contributes to `region def`, and why not if it has none. + // explicit : feature.properties.__code (file/group level) or def.codeProperty -> used verbatim + // otherwise : slug(name) + // Either way the result must match CODE_RE and, when def.codes is set, be one of those codes. + // returns { code: string|null, reason: null|'missing'|'invalid'|'not-allowed' } + function featureCode(feature, def) { + var props = feature.properties || {}; + var raw = null, explicit = false; + if (props.__code !== undefined && props.__code !== null) { raw = String(props.__code); explicit = true; } + else if (def && def.codeProperty) { + var v = pick(props, def.codeProperty); + if (v !== null) { raw = String(v).trim(); explicit = true; } + } + var code; + if (explicit) { + code = raw; + if (!CODE_RE.test(code)) return { code: null, reason: 'invalid' }; + } else { + code = slug(featureName(feature, (def && def.nameProperty) || 'name')); + if (!code) return { code: null, reason: 'missing' }; + if (!CODE_RE.test(code)) return { code: null, reason: 'invalid' }; + } + if (def && Array.isArray(def.codes) && def.codes.indexOf(code) < 0) return { code: null, reason: 'not-allowed' }; + return { code: code, reason: null }; + } + + // Merge many features into ONE MultiPolygon feature named `name` (optionally with explicit `code`). + function mergeFeatures(feats, name, code) { + var coords = []; + var color = null; + feats.forEach(function (f) { + polysOf(f.geometry).forEach(function (p) { coords.push(p); }); + if (!color && f.properties && f.properties.color) color = f.properties.color; + }); + var props = { __name: name }; + if (code !== undefined && code !== null) props.__code = code; + if (color) props.color = color; + return { type: 'Feature', properties: props, geometry: { type: 'MultiPolygon', coordinates: coords } }; + } + + // Merge features that share the value of `prop`. Features without the property are dropped. + function groupFeatures(features, prop, codeProp) { + var order = [], buckets = Object.create(null); + features.forEach(function (f) { + var v = f.properties && f.properties[prop]; + if (v === undefined || v === null || String(v).trim() === '') return; + var k = String(v); + if (!buckets[k]) { buckets[k] = []; order.push(k); } + buckets[k].push(f); + }); + return order.map(function (k) { + var code = null; + if (codeProp) { var cv = pick(buckets[k][0].properties || {}, codeProp); if (cv !== null) code = String(cv).trim(); } + return mergeFeatures(buckets[k], k, code); + }); + } + + // ---- layers ---------------------------------------------------------------- + + // def : one entry of layers.json { id, label, url, nameProperty, groupBy, colorBy, color, highlight } + // files : array of { name?: string, data: } in the same order as the def's url list + // returns { id, label, nameProperty, color, colorBy, highlight, features:[{feature,bbox,name}] } + function buildLayer(def, files) { + // groupBy merges features that share a property value across ALL the layer's files + // (a mesh region can straddle two state/macro files). Grouped features take the place + // of the first file that contributed to the pool, so output order stays predictable. + var features = [], pool = [], poolSlot = -1; + (files || []).forEach(function (file) { + var feats = normalizeGeoJSON(file.data); + if (file.name && feats.length) { features.push(mergeFeatures(feats, file.name, file.code)); return; } + if (def.groupBy) { + if (poolSlot < 0) { poolSlot = features.length; features.push(null); } + pool = pool.concat(feats); + return; + } + features = features.concat(feats); + }); + if (poolSlot >= 0) { + var grouped = groupFeatures(pool, def.groupBy, def.codeProperty); + features = features.slice(0, poolSlot).concat(grouped, features.slice(poolSlot + 1)); + } + // ignoreCodes: regions deliberately left out of this layer (for example states that + // exist in the boundary file but are not part of any macro region). They are dropped + // before anything else, so they are neither drawn, matched, nor reported as problems. + if (Array.isArray(def.ignoreCodes) && def.ignoreCodes.length) { + var open = {}; Object.keys(def).forEach(function (k) { if (k !== 'codes') open[k] = def[k]; }); + features = features.filter(function (f) { + var c = featureCode(f, open).code; + return !(c && def.ignoreCodes.indexOf(c) >= 0); + }); + } + var built = features.map(function (f) { + var fc = featureCode(f, def); + return { feature: f, bbox: geometryBBox(f.geometry), name: featureName(f, def.nameProperty || 'name'), code: fc.code, codeProblem: fc.reason }; + }); + var vertices = 0, unnamed = 0, problems = { missing: 0, invalid: 0, 'not-allowed': 0 }; + features.forEach(function (f) { vertices += countVertices(f.geometry); }); + built.forEach(function (e) { if (!e.code) { unnamed++; problems[e.codeProblem]++; } }); + return { + id: def.id, + vertices: vertices, + unnamed: unnamed, + codeProblems: problems, + coverage: def.coverage === 'full' ? 'full' : 'partial', + label: def.label || def.id, + nameProperty: def.nameProperty || 'name', + color: def.color || null, + colorBy: def.colorBy || null, + highlight: def.highlight !== false, + features: built + }; + } + + // Layer-level match for a point: { matches:[{feature,name,code}], unnamed:n } + function matchLayer(layer, lat, lon) { + var matches = [], unnamed = 0; + layer.features.forEach(function (e) { + var b = e.bbox; + if (lon < b[0] || lon > b[2] || lat < b[1] || lat > b[3]) return; + if (!geometryContains(e.feature.geometry, lon, lat)) return; + if (!e.code) { unnamed++; return; } + matches.push({ feature: e.feature, name: String(e.name || e.code), code: e.code }); + }); + return { matches: matches, unnamed: unnamed }; + } + + // layers: result of buildLayer() in output order. + // returns { tokens:[code...], value:'a b c', perLayer:[{id,label,match,matches}], warnings:[string] } + function buildRegionDef(layers, lat, lon) { + var tokens = [], perLayer = [], warnings = []; + layers.forEach(function (layer) { + var r = matchLayer(layer, lat, lon); + var first = r.matches.length ? r.matches[0] : null; + if (r.matches.length > 1) { + warnings.push(layer.label + ': point is in ' + r.matches.length + ' overlapping regions (' + + r.matches.map(function (m) { return m.code; }).join(', ') + '); using ' + first.code + '.'); + } + if (r.unnamed) { + warnings.push(layer.label + ': point is in ' + r.unnamed + ' polygon(s) with no usable code; skipped.'); + } + if (first) { + if (tokens.indexOf(first.code) >= 0) { + warnings.push(layer.label + ': code "' + first.code + '" is already used by a higher layer; region def would repeat it.'); + } + tokens.push(first.code); + } + perLayer.push({ id: layer.id, label: layer.label, match: first, matches: r.matches, unnamed: r.unnamed, gap: false }); + }); + // coverage:"full" layers promise to cover every place the other layers cover. A miss there + // while some other layer matched is a hole in the data, not a legitimate exclusion. + layers.forEach(function (layer, i) { + if (layer.coverage !== 'full' || perLayer[i].match) return; + var other = perLayer.some(function (p, k) { return k !== i && p.match; }); + if (other) { + perLayer[i].gap = true; + warnings.push(layer.label + ': no region covers this point, but the layer declares full coverage (data gap).'); + } + }); + var value = tokens.join(' '); + // A command the repeater would truncate or reject is withheld, not shown as if it were valid. + var blocked = commandProblems(value); + if (blocked.length) value = ''; + return { tokens: tokens, value: value, blocked: blocked, perLayer: perLayer, warnings: warnings }; + } + + // Reasons `region def ` cannot be pasted into a repeater as-is (empty list = fine). + function commandProblems(value) { + var problems = []; + if (!value) return problems; + var tokens = value.split(' '); + tokens.forEach(function (t) { + if (!CODE_RE.test(t)) problems.push('"' + t + '" is not a valid region name for the repeater (lowercase a-z 0-9 _ -, at most ' + FW_MAX_NAME_LEN + ' characters).'); + }); + var line = 'region def ' + value; + if (line.length > FW_MAX_LINE_LEN) problems.push('The command line is ' + line.length + ' characters; the repeater accepts at most ' + FW_MAX_LINE_LEN + ' and would cut it off.'); + if (tokens.length > FW_MAX_REGIONS) problems.push('The command defines ' + tokens.length + ' regions; a repeater holds at most ' + FW_MAX_REGIONS + ' in total.'); + return problems; + } + + // The block the operator pastes into the repeater console. Order matters: `region save` is the + // only step that persists, and `region allowf` does not save by itself, so it must come first. + // Returns '' for a value that would not be a valid repeater configuration. + function repeaterCommands(value) { + if (!value || commandProblems(value).length) return ''; + return 'region def ' + value + '\nregion allowf *\nregion save'; + } + + // "39.1, -84.5" | "39.1 -84.5" -> {lat, lon} or null (range validated). + function parseLatLon(s) { + if (typeof s !== 'string') return null; + var m = /^\s*(-?\d+(?:\.\d+)?)\s*[,\s]\s*(-?\d+(?:\.\d+)?)\s*$/.exec(s); + if (!m) return null; + var lat = parseFloat(m[1]), lon = parseFloat(m[2]); + if (!(lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180)) return null; + return { lat: lat, lon: lon }; + } + + // Per-feature colour override (only meaningful when layer.colorBy === 'feature'). + function featureColorOverride(layer, feature) { + if (layer.colorBy !== 'feature') return null; + var c = feature && feature.properties && feature.properties.color; + return (typeof c === 'string' && /^#[0-9a-fA-F]{3,8}$|^[a-zA-Z]+$/.test(c)) ? c : null; + } + + // ---- manifest validation ----------------------------------------------------- + + var MANIFEST_VERSION = 1; + var MAX_LAYERS = 16; + var LAYER_KEYS = ['id', 'label', 'url', 'nameProperty', 'codeProperty', 'codes', 'groupBy', 'coverage', + 'color', 'colorBy', 'highlight', 'optional', 'allowOverlap', 'ignoreCodes']; + + function isStrOrStrArray(v) { + return typeof v === 'string' ? v.length > 0 : + (Array.isArray(v) && v.length > 0 && v.every(function (s) { return typeof s === 'string' && s.length > 0; })); + } + + // Structural check of layers.json. Pure; returns { errors:[], warnings:[], version }. + // errors -> the page refuses to load the manifest + // warnings-> shown to the visitor, loading continues + function validateManifest(m) { + var errors = [], warnings = []; + if (!m || typeof m !== 'object' || Array.isArray(m)) return { errors: ['layers.json must be an object'], warnings: warnings, version: null }; + var version = m.version; + if (version === undefined) warnings.push('layers.json has no "version" field; assuming version ' + MANIFEST_VERSION + '.'); + else if (typeof version !== 'number' || Math.floor(version) !== version || version < 1) errors.push('"version" must be a positive integer.'); + else if (version > MANIFEST_VERSION) errors.push('layers.json version ' + version + ' is newer than this page supports (' + MANIFEST_VERSION + ').'); + if (!Array.isArray(m.layers) || !m.layers.length) { errors.push('"layers" must be a non-empty array.'); return { errors: errors, warnings: warnings, version: version }; } + if (m.layers.length > MAX_LAYERS) errors.push('At most ' + MAX_LAYERS + ' layers are supported.'); + var ids = Object.create(null); + m.layers.forEach(function (def, i) { + var at = 'layer ' + (i + 1) + (def && def.id ? ' (' + def.id + ')' : ''); + if (!def || typeof def !== 'object' || Array.isArray(def)) { errors.push(at + ': must be an object.'); return; } + if (typeof def.id !== 'string' || !/^[A-Za-z][A-Za-z0-9_-]{0,31}$/.test(def.id)) errors.push(at + ': "id" must be 1-32 chars of letters, digits, "_" or "-", starting with a letter.'); + else if (ids[def.id]) errors.push(at + ': duplicate id.'); + else ids[def.id] = true; + if (def.label !== undefined && typeof def.label !== 'string') errors.push(at + ': "label" must be a string.'); + var files = Array.isArray(def.url) ? def.url : [def.url]; + if (def.url === undefined || !files.length) errors.push(at + ': "url" is required.'); + else files.forEach(function (f) { + var path = (f && typeof f === 'object') ? f.url : f; + if (!isSafeLayerPath(path)) errors.push(at + ': unsafe or invalid file path ' + JSON.stringify(String(path).slice(0, 60)) + '.'); + if (f && typeof f === 'object') { + if (f.name !== undefined && (typeof f.name !== 'string' || !f.name.trim())) errors.push(at + ': file "name" must be a non-empty string.'); + if (f.code !== undefined && !CODE_RE.test(String(f.code))) errors.push(at + ': file "code" ' + JSON.stringify(String(f.code)) + ' is not a valid code (lowercase a-z 0-9 _ -, at most 30 characters).'); + if (f.code !== undefined && f.name === undefined) errors.push(at + ': file "code" needs a file "name" (it merges the file into one region).'); + } + }); + ['nameProperty', 'codeProperty'].forEach(function (k) { + if (def[k] !== undefined && !isStrOrStrArray(def[k])) errors.push(at + ': "' + k + '" must be a string or array of strings.'); + }); + if (def.groupBy !== undefined && (typeof def.groupBy !== 'string' || !def.groupBy)) errors.push(at + ': "groupBy" must be a property name.'); + if (def.codes !== undefined) { + if (!Array.isArray(def.codes) || !def.codes.length) errors.push(at + ': "codes" must be a non-empty array.'); + else { + var seen = Object.create(null); + def.codes.forEach(function (c) { + if (typeof c !== 'string' || !CODE_RE.test(c)) errors.push(at + ': canonical code ' + JSON.stringify(c) + ' is invalid (lowercase a-z 0-9 _ -, at most 30 characters).'); + else if (seen[c]) errors.push(at + ': canonical code "' + c + '" is listed twice.'); + seen[c] = true; + }); + } + } + if (def.ignoreCodes !== undefined) { + if (!Array.isArray(def.ignoreCodes) || !def.ignoreCodes.length) errors.push(at + ': "ignoreCodes" must be a non-empty array.'); + else def.ignoreCodes.forEach(function (c) { + if (typeof c !== 'string' || !CODE_RE.test(c)) errors.push(at + ': ignored code ' + JSON.stringify(c) + ' is invalid (lowercase a-z 0-9 _ -, at most 30 characters).'); + else if (Array.isArray(def.codes) && def.codes.indexOf(c) >= 0) errors.push(at + ': code "' + c + '" is in both "codes" and "ignoreCodes".'); + }); + } + if (def.coverage !== undefined && def.coverage !== 'partial' && def.coverage !== 'full') errors.push(at + ': "coverage" must be "partial" or "full".'); + if (def.colorBy !== undefined && def.colorBy !== 'feature') errors.push(at + ': "colorBy" can only be "feature".'); + if (def.color !== undefined && (typeof def.color !== 'string' || !/^#[0-9a-fA-F]{3,8}$|^[a-zA-Z]+$/.test(def.color))) errors.push(at + ': "color" must be a hex or named colour.'); + ['highlight', 'optional', 'allowOverlap'].forEach(function (k) { + if (def[k] !== undefined && typeof def[k] !== 'boolean') errors.push(at + ': "' + k + '" must be true or false.'); + }); + Object.keys(def).forEach(function (k) { + if (LAYER_KEYS.indexOf(k) < 0) warnings.push(at + ': unknown field "' + k + '" ignored (typo?).'); + }); + }); + return { errors: errors, warnings: warnings, version: version }; + } + + var api = { + featureCode: featureCode, commandProblems: commandProblems, FW_MAX_NAME_LEN: FW_MAX_NAME_LEN, FW_MAX_LINE_LEN: FW_MAX_LINE_LEN, FW_MAX_REGIONS: FW_MAX_REGIONS, validateManifest: validateManifest, CODE_RE: CODE_RE, MANIFEST_VERSION: MANIFEST_VERSION, + normalizeGeoJSON: normalizeGeoJSON, countVertices: countVertices, isSafeLayerPath: isSafeLayerPath, geometryBBox: geometryBBox, onSegment: onSegment, + ringLocate: ringLocate, polygonContains: polygonContains, geometryContains: geometryContains, + mergeFeatures: mergeFeatures, groupFeatures: groupFeatures, slug: slug, featureName: featureName, + buildLayer: buildLayer, matchLayer: matchLayer, buildRegionDef: buildRegionDef, + repeaterCommands: repeaterCommands, parseLatLon: parseLatLon, featureColorOverride: featureColorOverride + }; + + root.RegionDef = api; + if (typeof module !== 'undefined' && module.exports) module.exports = api; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/public/region-def.css b/public/region-def.css new file mode 100644 index 00000000..1aadd31f --- /dev/null +++ b/public/region-def.css @@ -0,0 +1,80 @@ +/* Region Def tool (#/tools/region-def). Colours come from theme variables; the + * layer palette below is the only place raw hex values live. */ + +:root { + --region-layer-1: #2563eb; + --region-layer-2: #dc2626; + --region-layer-3: #16a34a; + --region-layer-4: #d97706; + --region-layer-5: #7c3aed; + --region-layer-6: #0891b2; + --region-layer-7: #db2777; + --region-layer-8: #65a30d; +} + +[data-theme="dark"] { + --region-layer-1: #60a5fa; + --region-layer-2: #f87171; + --region-layer-3: #4ade80; + --region-layer-4: #fbbf24; + --region-layer-5: #a78bfa; + --region-layer-6: #22d3ee; + --region-layer-7: #f472b6; + --region-layer-8: #a3e635; +} + +.rd-page { display: flex; gap: 16px; padding: 16px; height: calc(100vh - 56px); min-height: 460px; box-sizing: border-box; } +.rd-panel { flex: 0 0 clamp(300px, 30vw, 380px); min-width: 0; overflow-y: auto; color: var(--text); } +.rd-panel h2 { margin: 0 0 8px 0; } +.rd-map { flex: 1 1 auto; min-height: 320px; border: 1px solid var(--border); border-radius: 8px; } + +.rd-input-row { display: flex; gap: 8px; margin: 8px 0; } +.rd-input-row .input { flex: 1 1 auto; min-width: 0; } +.rd-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } + +.rd-msg { min-height: 1.2em; font-size: 13px; color: var(--text-muted); } +.rd-msg-err { color: var(--status-red, var(--text)); font-weight: 600; } + +.rd-cands { display: flex; flex-direction: column; gap: 6px; margin: 6px 0; max-height: 40vh; overflow-y: auto; } +.rd-cand { text-align: left; white-space: normal; } + +.rd-where { font-size: 13px; color: var(--text-muted); margin: 12px 0 4px 0; word-break: break-word; } +.rd-none { margin: 12px 0; } +.rd-cmd-head { margin: 12px 0 6px 0; font-size: 15px; } +.rd-cmd { + margin: 0 0 12px 0; padding: 10px 12px; border-radius: 6px; + background: var(--card-bg); border: 1px solid var(--border); color: var(--text); + font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); + font-size: 13px; white-space: pre-wrap; word-break: break-word; +} +.rd-cmd:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +.rd-warnings { margin: 0 0 12px 0; padding-left: 18px; font-size: 13px; color: var(--text); } + +.rd-rows { list-style: none; margin: 0; padding: 0; } +.rd-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 13px; } +.rd-row-label { flex: 0 0 110px; color: var(--text-muted); } +.rd-badge { padding: 1px 8px; border-radius: 10px; font-size: 11px; font-weight: 700; border: 1px solid var(--border); } +.rd-b-in { color: var(--accent); border-color: var(--accent); } +.rd-b-out { color: var(--text-muted); } +.rd-row-name { word-break: break-word; } + +.rd-toggles { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-top: 14px; font-size: 13px; } +.rd-toggle { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; } +.rd-swatch { width: 22px; height: 0; border-top: 3px solid; display: inline-block; } +.rd-details > summary { cursor: pointer; font-size: 13px; font-weight: 600; padding: 6px 0; } + +@media (max-width: 800px) { + .rd-page { flex-direction: column; height: auto; min-height: 0; padding: 12px; } + .rd-panel { flex: 0 0 auto; overflow: visible; } + .rd-map { height: 55vh; min-height: 280px; flex: 0 0 auto; } + .rd-input-row .input { font-size: 16px; } /* stops iOS zooming on focus */ + .rd-input-row .btn, .rd-cand, .rd-details > summary { min-height: 44px; } + .rd-row-label { flex-basis: 100%; } + .rd-toggles { gap: 10px 14px; } +} + +.rd-incomplete { margin: 12px 0; padding: 10px 12px; border-radius: 6px; border: 2px solid var(--status-red, var(--text)); color: var(--text); background: var(--card-bg); font-size: 13px; } +.rd-incomplete ul { margin: 6px 0 0 0; padding-left: 18px; } +.rd-apply { margin: 0 0 12px 0; padding: 10px 12px; border-radius: 6px; border: 1px solid var(--border); background: var(--card-bg); color: var(--text); font-size: 13px; } +.rd-apply ul { margin: 6px 0 0 0; padding-left: 18px; } diff --git a/public/region-def.js b/public/region-def.js new file mode 100644 index 00000000..4ce67a40 --- /dev/null +++ b/public/region-def.js @@ -0,0 +1,543 @@ +// Region Def — address -> `region def ...` repeater command (#/tools/region-def). +// Pure logic lives in region-def-core.js (window.RegionDef); this file is the page. +// +// Data: public/region-layers/layers.json + the GeoJSON files it lists (operator-supplied, +// served as static files). See docs/region-def.md for the manifest format. +// Geocoding: the typed address is sent from the visitor's browser to Nominatim. +(function () { + 'use strict'; + + var LAYER_DIR = 'region-layers/'; + var NOMINATIM = 'https://nominatim.openstreetmap.org/search'; + var MIN_GEOCODE_GAP_MS = 1100; // Nominatim usage policy: max 1 request/second + var OSM_FALLBACK = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; + var PALETTE_SIZE = 8; + // Line patterns repeat every 4 layers; with the colour they let layers be told apart without relying on hue alone. + var DASHES = [null, '8 6', '2 6', '12 4 2 4']; + var SWATCH_STYLES = ['solid', 'dashed', 'dotted', 'double']; + + var HEAVY_VERTICES = 50000; // layers above this start hidden: drawing them is the expensive part on phones + var GEOCODE_KEY = 'rd-last-geocode'; + // Request timeouts. window.RegionDefConfig = { layerTimeoutMs, geocodeTimeoutMs } overrides them + // (used by the E2E tests; an operator could raise them for a slow link). + var DEFAULT_LAYER_TIMEOUT_MS = 20000; + var DEFAULT_GEOCODE_TIMEOUT_MS = 10000; + function fmtMs(ms) { return ms >= 1000 ? Math.round(ms / 1000) + ' s' : ms + ' ms'; } + function cfgMs(key, dflt) { + var c = window.RegionDefConfig; + return (c && typeof c[key] === 'number' && c[key] > 0) ? c[key] : dflt; + } + + var S = null; // per-visit state; null when the page is not mounted + var lastGeocode = 0; // module-level so it survives leaving and re-entering the page (SPA) + + function el(tag, cls, text) { + var n = document.createElement(tag); + if (cls) n.className = cls; + if (text !== undefined && text !== null) n.textContent = text; + return n; + } + + function hashParams() { + return new URLSearchParams((location.hash.split('?')[1]) || ''); + } + + function cssVar(name, fallback) { + try { + var v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return v || fallback; + } catch (_) { return fallback; } + } + + // ---- manifest + layer loading --------------------------------------------- + + // fetch + parse with a timeout and errors that say what went wrong and where. + function fetchJSON(url, timeoutMs, init) { + var ctl = (typeof AbortController === 'function') ? new AbortController() : null; + var timer = null, timedOut = false; + if (ctl) timer = setTimeout(function () { timedOut = true; ctl.abort(); }, timeoutMs); + var opts = init || {}; + if (ctl) opts.signal = ctl.signal; + return fetch(url, opts).then(function (r) { + if (!r.ok) throw new Error('HTTP ' + r.status + ' for ' + url); + return r.json().catch(function () { + if (timedOut) throw new Error('timed out after ' + fmtMs(timeoutMs) + ' reading ' + url); + throw new Error('invalid JSON in ' + url); + }); + }).catch(function (err) { + if (timedOut) throw new Error('timed out after ' + fmtMs(timeoutMs) + ': ' + url); + if (err && err.name === 'TypeError') throw new Error('network error fetching ' + url); + throw err; + }).then(function (v) { clearTimeout(timer); return v; }, function (e) { clearTimeout(timer); throw e; }); + } + + // def.url: string | [string | {url, name}] -> [{url, name}] + function fileList(def) { + var raw = Array.isArray(def.url) ? def.url : [def.url]; + return raw.map(function (u) { + return (u && typeof u === 'object') ? { url: u.url, name: u.name || null, code: u.code || null } : { url: u, name: null, code: null }; + }); + } + + // One request per distinct file, however many layers reference it. Parsed GeoJSON is shared + // read-only between layers (nothing in region-def-core.js mutates it). + function loadLayer(def, fetchCached) { + var files = fileList(def); + return Promise.all(files.map(function (f) { + if (!window.RegionDef.isSafeLayerPath(f.url)) { + return Promise.reject(new Error('invalid layer path ' + JSON.stringify(String(f.url).slice(0, 60)))); + } + return fetchCached(LAYER_DIR + f.url).then(function (data) { return { name: f.name, code: f.code, data: data }; }); + })).then(function (loaded) { + var layer = window.RegionDef.buildLayer(def, loaded); + var warnings = []; + if (!layer.features.length) throw new Error('loaded but contains no valid polygons'); + if (layer.unnamed) { + var cp = layer.codeProblems, why = []; + if (cp.missing) why.push(cp.missing + ' with no name/code'); + if (cp.invalid) why.push(cp.invalid + ' with an invalid code (lowercase a-z 0-9 _ - only)'); + if (cp['not-allowed']) why.push(cp['not-allowed'] + ' not in the layer\'s canonical "codes" list'); + warnings.push(layer.label + ': ' + layer.unnamed + ' polygon(s) can never produce a code (' + why.join('; ') + ').'); + } + if (layer.vertices > HEAVY_VERTICES) { + warnings.push(layer.label + ': ' + layer.vertices + ' vertices; shown only when ticked. Simplify the file (e.g. mapshaper) for a lighter page.'); + } + return { layer: layer, warnings: warnings }; + }); + } + + function loadAll() { + var cache = Object.create(null); + var layerMs = cfgMs('layerTimeoutMs', DEFAULT_LAYER_TIMEOUT_MS); + function fetchCached(url) { return cache[url] || (cache[url] = fetchJSON(url, layerMs)); } + return fetchJSON(LAYER_DIR + 'layers.json', layerMs).then(function (manifest) { + var check = window.RegionDef.validateManifest(manifest); + if (check.errors.length) throw new Error('layers.json is invalid: ' + check.errors.join(' ')); + var defs = manifest.layers; + return Promise.all(defs.map(function (def) { + return loadLayer(def, fetchCached).then(function (r) { + return { layer: r.layer, def: def, error: null, warnings: r.warnings }; + }, function (err) { + return { layer: null, def: def, error: (def.label || def.id) + ': ' + (err && err.message ? err.message : 'failed to load'), warnings: [] }; + }); + })).then(function (entries) { return { entries: entries, warnings: check.warnings }; }); + }); + } + + // ---- map --------------------------------------------------------------------- + + function isDarkTheme() { + var attr = document.documentElement.getAttribute('data-theme'); + if (attr === 'dark') return true; + if (attr === 'light') return false; + try { return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches); } catch (_) { return false; } + } + + // Page-local theme-synced basemap built on the shared tile registry (map-tile-providers.js). + // Follow-up: extract map.js _syncDarkTiles into a shared helper and use it here. + function syncTiles() { + if (!S || !S.map) return; + var dark = isDarkTheme(); + var reg = window.MC_TILE_PROVIDERS || {}; + var id = null; + try { id = dark ? window.MC_getDarkTileProvider() : window.MC_getLightTileProvider(); } catch (_) {} + var p = id && reg[id] ? reg[id] : null; + var url = (typeof window.MC_tileUrlById === 'function' && p) ? window.MC_tileUrlById(id, OSM_FALLBACK) : OSM_FALLBACK; + var opts = { attribution: p ? (p.attribution || '') : '© OpenStreetMap contributors', maxZoom: p ? (p.maxZoom || 19) : 19 }; + var layers = [L.tileLayer(url, opts)]; + if (p && p.refUrl) layers.push(L.tileLayer(p.refUrl, opts)); + if (S.tiles) S.map.removeLayer(S.tiles); + S.tiles = L.layerGroup(layers).addTo(S.map); + var pane = S.map.getPane('tilePane'); + if (pane) pane.style.filter = (dark && p && p.invertFilter) ? p.invertFilter : ''; + if (S.tileStatus) { + S.tileStatus.textContent = p ? '' : 'Tile provider not configured; using OpenStreetMap.'; + } + } + + function layerColor(layer, index) { + if (layer.color) return layer.color; + return cssVar('--region-layer-' + ((index % PALETTE_SIZE) + 1), '#3388ff'); + } + + function featureStyle(layer, index, feature, matched) { + var color = window.RegionDef.featureColorOverride(layer, feature) || layerColor(layer, index); + return { + color: color, + weight: matched ? 3 : 1, + opacity: matched ? 1 : 0.7, + fillColor: color, + fillOpacity: matched ? 0.25 : 0.06, + dashArray: DASHES[index % DASHES.length] + }; + } + + // One L.geoJSON per layer (canvas-rendered). Built on first show, so a heavy layer that + // starts hidden costs nothing until the user ticks it. + function ensureGroup(entry) { + if (entry.group) return entry.group; + var layer = entry.layer, i = entry.index; + entry.group = L.geoJSON(layer.features.map(function (e) { return e.feature; }), { + style: function (f) { return featureStyle(layer, i, f, false); }, + onEachFeature: function (feature, lyr) { + lyr._rdFeature = feature; + // DOM node, not an HTML string: feature names are data and must never be parsed as markup. + var nm = window.RegionDef.featureName(feature, layer.nameProperty); + lyr.bindTooltip(el('span', null, layer.label + ': ' + (nm || 'unnamed')), { sticky: true }); + } + }); + if (S.lastResult) restyleEntry(entry, S.lastResult); + return entry.group; + } + + function drawLayers() { + S.loaded.forEach(function (entry, i) { + if (!entry.layer) return; + entry.index = i; + entry.visible = entry.layer.vertices <= HEAVY_VERTICES; + if (entry.visible) ensureGroup(entry).addTo(S.map); + }); + } + + function restyleEntry(entry, result) { + var per = null; + for (var k = 0; k < result.perLayer.length; k++) if (result.perLayer[k].id === entry.layer.id) per = result.perLayer[k]; + var hit = per && per.match ? per.match.feature : null; + entry.group.eachLayer(function (gj) { + var matched = !!hit && gj._rdFeature === hit && entry.layer.highlight; + gj.setStyle(featureStyle(entry.layer, entry.index, gj._rdFeature, matched)); + if (matched && gj.bringToFront) gj.bringToFront(); + }); + } + + function restyle(result) { + S.lastResult = result; + S.loaded.forEach(function (entry) { + if (entry.layer && entry.group) restyleEntry(entry, result); + }); + } + + function fitSmart(lat, lon) { + S.map.setView([lat, lon], Math.max(S.map.getZoom(), 9)); + } + + // ---- results ------------------------------------------------------------------- + + function renderResult(result, label) { + var out = S.out; + while (out.firstChild) out.removeChild(out.firstChild); + + if (label) out.appendChild(el('p', 'rd-where', label)); + + // A command missing a layer's code would be a wrong `region def` if pasted, so say so loudly. + // Required layers (the default) withhold the command; `"optional": true` layers only flag it. + var problems = []; + S.loaded.forEach(function (e) { + if (!e.layer) problems.push({ text: e.error, optional: e.def.optional === true }); + }); + result.perLayer.forEach(function (row, idx) { + if (!row.unnamed) return; + var def = S.usableDefs[idx]; + problems.push({ text: row.label + ': the point is inside a polygon with no usable code', optional: !!(def && def.optional === true) }); + }); + result.perLayer.forEach(function (row, idx) { + if (!row.gap) return; + var def = S.usableDefs[idx]; + problems.push({ text: row.label + ': no region covers this point, but the layer declares full coverage (data gap)', optional: !!(def && def.optional === true) }); + }); + (result.blocked || []).forEach(function (text) { problems.push({ text: text, optional: false }); }); + var blocking = problems.filter(function (p) { return !p.optional; }); + var optionalOnly = problems.filter(function (p) { return p.optional; }); + + if (blocking.length) { + var alertBox = el('div', 'rd-incomplete'); + alertBox.setAttribute('role', 'alert'); + alertBox.appendChild(el('strong', null, 'Command withheld: the result would be incomplete.')); + var bl = el('ul'); + blocking.forEach(function (p) { bl.appendChild(el('li', null, p.text)); }); + alertBox.appendChild(bl); + out.appendChild(alertBox); + } else if (!result.value) { + out.appendChild(el('p', 'rd-none', 'This location is not inside any configured region.')); + } else { + if (optionalOnly.length) { + var warn = el('div', 'rd-incomplete'); + warn.setAttribute('role', 'alert'); + warn.appendChild(el('strong', null, 'Incomplete result: this command is missing codes for optional layers.')); + var wl = el('ul'); + optionalOnly.forEach(function (p) { wl.appendChild(el('li', null, p.text)); }); + warn.appendChild(wl); + out.appendChild(warn); + } + out.appendChild(el('h3', 'rd-cmd-head', optionalOnly.length ? 'Type this into your repeater (incomplete)' : 'Type this into your repeater')); + var pre = el('pre', 'rd-cmd', window.RegionDef.repeaterCommands(result.value)); + pre.setAttribute('tabindex', '0'); + pre.setAttribute('aria-label', 'Repeater commands'); + out.appendChild(pre); + + // What these commands do to a repeater that already has regions (see docs/region-def.md). + var apply = el('div', 'rd-apply'); + apply.appendChild(el('strong', null, 'Before you apply this')); + var al = el('ul'); + [ + '`region def` adds to the repeater\'s existing regions; it does not clear them. Run `region` first to see what is configured, and remove obsolete entries with `region remove ` (children before parents).', + '`region allowf *` lets the repeater forward unscoped (wildcard) flood traffic. Leave that line out if this repeater should only forward region-scoped floods.', + '`region save` is last on purpose: it is the only command that writes to flash.', + 'Afterwards run `region` to check the tree, and `region list allowed` to see where flooding is allowed.' + ].forEach(function (t) { al.appendChild(el('li', null, t.replace(/`/g, ''))); }); + apply.appendChild(al); + out.appendChild(apply); + } + + // Warnings + per-layer rows live in a
so the command stays on screen on phones. + var details = el('details', 'rd-details'); + var narrow = false; + try { narrow = !!(window.matchMedia && window.matchMedia('(max-width: 800px)').matches); } catch (_) {} + if (!narrow) details.open = true; + details.appendChild(el('summary', null, 'Layer details')); + + var warnings = result.warnings.concat(S.loadWarnings); + if (warnings.length) { + var ul = el('ul', 'rd-warnings'); + warnings.forEach(function (w) { ul.appendChild(el('li', null, w)); }); + details.appendChild(ul); + } + + var rows = el('ul', 'rd-rows'); + result.perLayer.forEach(function (row) { + var li = el('li', 'rd-row'); + li.appendChild(el('span', 'rd-row-label', row.label)); + var inside = !!row.match; + li.appendChild(el('span', 'rd-badge ' + (inside ? 'rd-b-in' : 'rd-b-out'), inside ? 'INSIDE' : 'OUTSIDE')); + if (inside) { + var txt = row.match.name + ' (' + row.match.code + ')'; + if (row.matches.length > 1) txt += ' - overlaps ' + (row.matches.length - 1) + ' more'; + li.appendChild(el('span', 'rd-row-name', txt)); + } + rows.appendChild(li); + }); + details.appendChild(rows); + out.appendChild(details); + } + + function lookup(lat, lon, label) { + if (!S) return; + var usableEntries = S.loaded.filter(function (e) { return e.layer; }); + var usable = usableEntries.map(function (e) { return e.layer; }); + S.usableDefs = usableEntries.map(function (e) { return e.def; }); + var result = window.RegionDef.buildRegionDef(usable, lat, lon); + if (S.marker) S.map.removeLayer(S.marker); + S.marker = L.circleMarker([lat, lon], { + radius: 7, weight: 2, color: cssVar('--accent', '#3388ff'), fillColor: cssVar('--accent', '#3388ff'), fillOpacity: 0.8 + }).addTo(S.map); + restyle(result); + fitSmart(lat, lon); + renderResult(result, label || (lat.toFixed(5) + ', ' + lon.toFixed(5))); + S.latlon = { lat: lat, lon: lon }; + } + + function setUrl(params) { + var qs = new URLSearchParams(); + Object.keys(params).forEach(function (k) { if (params[k] !== null && params[k] !== undefined) qs.set(k, params[k]); }); + var s = qs.toString(); + try { history.replaceState(null, '', '#/tools/region-def' + (s ? '?' + s : '')); } catch (_) {} + } + + // ---- geocoding ------------------------------------------------------------------ + + function showMessage(text, isError) { + S.msg.textContent = text || ''; + S.msg.className = 'rd-msg' + (isError ? ' rd-msg-err' : ''); + } + + // Plain-language geocoder failures (never echo the request URL: it contains the typed address). + function explainGeocodeError(err) { + var m = (err && err.message) || ''; + if (/HTTP 429/.test(m)) return 'the geocoder is rate limiting requests; wait a moment and try again'; + var h = /HTTP (\d+)/.exec(m); + if (h) return 'the geocoder returned HTTP ' + h[1]; + if (/timed out/.test(m)) return 'the geocoder did not answer in time'; + if (/invalid JSON/.test(m)) return 'the geocoder sent an unreadable reply'; + return 'network error (offline, or the geocoder is blocked?)'; + } + + function geocode(q) { + var last = lastGeocode; + try { last = Math.max(last, parseInt(sessionStorage.getItem(GEOCODE_KEY), 10) || 0); } catch (_) {} + var wait = Math.max(0, last + MIN_GEOCODE_GAP_MS - Date.now()); + return new Promise(function (r) { setTimeout(r, wait); }).then(function () { + lastGeocode = Date.now(); + try { sessionStorage.setItem(GEOCODE_KEY, String(lastGeocode)); } catch (_) {} + var url = NOMINATIM + '?format=jsonv2&limit=5&q=' + encodeURIComponent(q); + return fetchJSON(url, cfgMs('geocodeTimeoutMs', DEFAULT_GEOCODE_TIMEOUT_MS), { headers: { 'Accept': 'application/json' } }); + }); + } + + function showCandidates(list) { + var box = S.cands; + while (box.firstChild) box.removeChild(box.firstChild); + list.forEach(function (c) { + var b = el('button', 'btn rd-cand', c.display_name); + b.type = 'button'; + b.addEventListener('click', function () { pickCandidate(c); }); + box.appendChild(b); + }); + } + + function pickCandidate(c) { + var lat = parseFloat(c.lat), lon = parseFloat(c.lon); + if (!isFinite(lat) || !isFinite(lon)) { showMessage('Geocoder returned an unusable location.', true); return; } + showCandidates([]); + showMessage(''); + lookup(lat, lon, c.display_name); + setUrl({ lat: lat.toFixed(5), lon: lon.toFixed(5) }); + } + + function submit(raw) { + if (!S) return; + var text = (raw || '').trim(); + showMessage(''); + showCandidates([]); + if (!text) { showMessage('Enter an address or a "lat, lon" pair.', true); return; } + var ll = window.RegionDef.parseLatLon(text); + if (ll) { + lookup(ll.lat, ll.lon); + setUrl({ lat: ll.lat, lon: ll.lon }); + return; + } + if (S.busy) return; + S.busy = true; + S.btn.disabled = true; + showMessage('Searching...'); + var mine = S; + geocode(text).then(function (list) { + if (S !== mine) return; + if (!Array.isArray(list) || !list.length) { showMessage('No match found for that address.', true); return; } + showMessage(''); + setUrl({ q: text }); + if (list.length === 1) pickCandidate(list[0]); + else { showMessage('Several matches; pick one:'); showCandidates(list); } + }).catch(function (err) { + if (S === mine) showMessage('Address lookup failed: ' + explainGeocodeError(err), true); + }).then(function () { + if (S === mine) { S.busy = false; S.btn.disabled = false; } + }); + } + + // ---- layer toggles ------------------------------------------------------------------ + + function renderToggles() { + var box = S.toggles; + S.loaded.forEach(function (entry, i) { + if (!entry.layer) return; + var lab = el('label', 'rd-toggle'); + var cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.checked = !!entry.visible; + cb.addEventListener('change', function () { + if (cb.checked) ensureGroup(entry).addTo(S.map); else if (entry.group) S.map.removeLayer(entry.group); + }); + var sw = el('span', 'rd-swatch'); + sw.style.borderTopColor = layerColor(entry.layer, i); + sw.style.borderTopStyle = SWATCH_STYLES[i % SWATCH_STYLES.length]; + lab.appendChild(cb); + lab.appendChild(sw); + lab.appendChild(el('span', null, entry.layer.label)); + box.appendChild(lab); + }); + } + + // ---- lifecycle ------------------------------------------------------------------------ + + function init(app) { + app.innerHTML = + '
' + + '
' + + '

Region Def

' + + '

Enter the repeater\'s address (or lat, lon) or click the map. ' + + 'The address is sent to OpenStreetMap Nominatim to find its coordinates.

' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
'; + + S = { + map: null, tiles: null, marker: null, loaded: [], loadWarnings: [], latlon: null, + busy: false, lastResult: null, observer: null, onTheme: null, + input: app.querySelector('#rd-input'), btn: app.querySelector('#rd-go'), + msg: app.querySelector('#rd-msg'), cands: app.querySelector('#rd-cands'), + out: app.querySelector('#rd-out'), toggles: app.querySelector('#rd-toggles'), + tileStatus: app.querySelector('#rd-tilestatus') + }; + var mine = S; + + S.map = L.map('rd-map', { zoomControl: true, preferCanvas: true }).setView([39.5, -98.35], 4); + syncTiles(); + S.onTheme = function () { syncTiles(); }; + window.addEventListener('mc-tile-provider-changed', S.onTheme); + window.addEventListener('theme-changed', S.onTheme); + if (typeof MutationObserver === 'function') { + S.observer = new MutationObserver(S.onTheme); + S.observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); + } + setTimeout(function () { if (S === mine && S.map) S.map.invalidateSize(); }, 0); + + S.btn.addEventListener('click', function () { submit(S.input.value); }); + S.input.addEventListener('keydown', function (e) { if (e.key === 'Enter') submit(S.input.value); }); + S.map.on('click', function (e) { + if (!S || !S.loaded.length) return; + var lat = e.latlng.lat, lon = ((e.latlng.lng + 540) % 360) - 180; // wrap panned-around worlds + S.input.value = lat.toFixed(5) + ', ' + lon.toFixed(5); + lookup(lat, lon); + setUrl({ lat: lat.toFixed(5), lon: lon.toFixed(5) }); + }); + + showMessage('Loading region layers...'); + S.btn.disabled = true; + loadAll().then(function (res) { + if (S !== mine) return; + var loaded = res.entries; + S.loaded = loaded; + S.loadWarnings = res.warnings.slice(); + loaded.forEach(function (e) { + if (e.error) S.loadWarnings.push(e.error); + e.warnings.forEach(function (w) { S.loadWarnings.push(w); }); + }); + if (!loaded.some(function (e) { return e.layer; })) { + showMessage('No region layers could be loaded.', true); + S.out.appendChild(el('p', 'rd-none', S.loadWarnings.join(' '))); + return; + } + showMessage(''); + S.btn.disabled = false; + drawLayers(); + renderToggles(); + var p = hashParams(); + var ll = window.RegionDef.parseLatLon((p.get('lat') || '') + ',' + (p.get('lon') || '')); + if (ll) { S.input.value = ll.lat + ', ' + ll.lon; lookup(ll.lat, ll.lon); } + else if (p.get('q')) { S.input.value = p.get('q'); submit(p.get('q')); } + }, function (err) { + if (S === mine) showMessage('Could not load region layers: ' + (err && err.message ? err.message : 'error'), true); + }); + } + + function destroy() { + if (!S) return; + window.removeEventListener('mc-tile-provider-changed', S.onTheme); + window.removeEventListener('theme-changed', S.onTheme); + if (S.observer) S.observer.disconnect(); + if (S.map) S.map.remove(); + S = null; + } + + if (typeof registerPage === 'function') registerPage('region-def', { init: init, destroy: destroy }); +})(); diff --git a/public/region-layers/known-points.json.example b/public/region-layers/known-points.json.example new file mode 100644 index 00000000..a44cd61b --- /dev/null +++ b/public/region-layers/known-points.json.example @@ -0,0 +1,158 @@ +[ + { + "name": "Cincinnati OH", + "lat": 39.1031, + "lon": -84.512, + "expect": "us-midwest oki us-oh cvg" + }, + { + "name": "Columbus OH", + "lat": 39.9612, + "lon": -82.9988, + "expect": "us-midwest oki us-oh cmh" + }, + { + "name": "Dayton OH (no metro)", + "lat": 39.7589, + "lon": -84.1916, + "expect": "us-midwest oki us-oh" + }, + { + "name": "Cleveland OH", + "lat": 41.4993, + "lon": -81.6944, + "expect": "us-midwest oki us-oh" + }, + { + "name": "Indianapolis IN", + "lat": 39.7684, + "lon": -86.1581, + "expect": "us-midwest oki us-in ind" + }, + { + "name": "Fort Wayne IN", + "lat": 41.0793, + "lon": -85.1394, + "expect": "us-midwest oki us-in" + }, + { + "name": "Covington KY (CVG metro spans the river)", + "lat": 39.0837, + "lon": -84.5086, + "expect": "us-southeast oki us-ky cvg" + }, + { + "name": "Lexington KY", + "lat": 38.0406, + "lon": -84.5037, + "expect": "us-southeast oki us-ky" + }, + { + "name": "Louisville KY", + "lat": 38.2527, + "lon": -85.7585, + "expect": "us-southeast oki us-ky" + }, + { + "name": "Detroit MI", + "lat": 42.3314, + "lon": -83.0458, + "expect": "us-midwest us-mi" + }, + { + "name": "Chicago IL", + "lat": 41.8781, + "lon": -87.6298, + "expect": "us-midwest us-il" + }, + { + "name": "Charleston WV", + "lat": 38.3498, + "lon": -81.6326, + "expect": "us-southeast us-wv" + }, + { + "name": "Pittsburgh PA (no macro, no mesh)", + "lat": 40.4406, + "lon": -79.9959, + "expect": "us-pa" + }, + { + "name": "mid-Atlantic Ocean", + "lat": 35.0, + "lon": -70.0, + "expect": "" + }, + { + "name": "OH/IN line, Indiana side, lat 40.5", + "lat": 40.5, + "lon": -84.83, + "expect": "us-midwest oki us-in" + }, + { + "name": "OH/IN line, Ohio side, lat 40.5", + "lat": 40.5, + "lon": -84.81, + "expect": "us-midwest oki us-oh" + }, + { + "name": "OH/IN line, Indiana side, lat 41.2", + "lat": 41.2, + "lon": -84.83, + "expect": "us-midwest oki us-in" + }, + { + "name": "OH/IN line, Ohio side, lat 41.2", + "lat": 41.2, + "lon": -84.81, + "expect": "us-midwest oki us-oh" + }, + { + "name": "IN/IL line, Illinois side", + "lat": 40.0, + "lon": -87.54, + "expect": "us-midwest us-il" + }, + { + "name": "IN/IL line, Indiana side", + "lat": 40.0, + "lon": -87.52, + "expect": "us-midwest oki us-in" + }, + { + "name": "OH/PA line, Ohio side", + "lat": 40.95, + "lon": -80.53, + "expect": "us-midwest oki us-oh" + }, + { + "name": "OH/PA line, Pennsylvania side", + "lat": 40.95, + "lon": -80.51, + "expect": "us-pa" + }, + { + "name": "WV panhandle (east of the Ohio River edge)", + "lat": 40.3, + "lon": -80.55, + "expect": "us-southeast us-wv" + }, + { + "name": "Ohio just west of the panhandle", + "lat": 40.3, + "lon": -80.66, + "expect": "us-midwest oki us-oh" + }, + { + "name": "Ohio River, north bank", + "lat": 39.11, + "lon": -84.51, + "expect": "us-midwest oki us-oh cvg" + }, + { + "name": "Ohio River, south bank", + "lat": 39.08, + "lon": -84.51, + "expect": "us-southeast oki us-ky cvg" + } +] diff --git a/public/region-layers/layers.json.example b/public/region-layers/layers.json.example new file mode 100644 index 00000000..3326ce97 --- /dev/null +++ b/public/region-layers/layers.json.example @@ -0,0 +1,61 @@ +{ + "version": 1, + "layers": [ + { + "id": "macro", + "label": "Macro region", + "url": [ + { + "url": "macro-midwest.geojson", + "name": "US Midwest", + "code": "us-midwest" + }, + { + "url": "macro-southeast.geojson", + "name": "US Southeast", + "code": "us-southeast" + } + ], + "codes": [ + "us-midwest", + "us-southeast" + ] + }, + { + "id": "mesh", + "label": "Mesh region", + "url": "states.geojson", + "groupBy": "meshregion", + "codes": [ + "oki" + ] + }, + { + "id": "state", + "label": "State", + "url": "states.geojson", + "nameProperty": "shapeISO", + "codes": [ + "us-oh", + "us-in", + "us-ky", + "us-mi", + "us-il", + "us-pa", + "us-wv" + ] + }, + { + "id": "metro", + "label": "Metro", + "url": "metros.geojson", + "nameProperty": "iata", + "codes": [ + "cvg", + "ind", + "cmh" + ], + "optional": true + } + ] +} diff --git a/scripts/check-region-data.js b/scripts/check-region-data.js new file mode 100644 index 00000000..20a40b96 --- /dev/null +++ b/scripts/check-region-data.js @@ -0,0 +1,265 @@ +#!/usr/bin/env node +/* Region Def data integrity check. + * + * Validates the manifest and the boundary files that are actually deployed in + * public/region-layers/ (or --dir), the same files the page loads: + * + * - layers.json is valid (schema version, ids, safe paths, codes ...) + * - every referenced file exists and is well-formed GeoJSON with polygons + * (closed rings, finite [lon, lat] numbers, in range: swapped [lat, lon] is caught) + * - every polygon produces a valid canonical repeater code, inside the layer's + * declared "codes" list, and every declared code is produced by some polygon + * - no code appears twice inside a layer + * - polygons of one layer do not overlap (shared edges are fine; sliver overlaps + * under --overlap-tolerance are ignored). Opt out per layer with "allowOverlap": true + * - the same code is not reused by two layers (the repeater command would repeat it) + * - known-points.json (if present) produces exactly the expected command, with no warnings + * + * Usage: + * node scripts/check-region-data.js [--dir DIR] [--strict] [--overlap-tolerance DEG] + * [--point LAT,LON]... + * --strict also fail on warnings + * --draft-known-points + * read the rows (name, lat, lon) of known-points.json, and print a draft of the + * file on stdout with each "expect" set to what the data produces today. Review + * every row before committing it: a draft made from wrong data bakes the error in. + * --point print the `region def` block for a coordinate (use it to produce verified + * examples for docs and known-points.json) + * + * With no layers.json in DIR there is nothing to check: exits 0 (a deployment may serve + * its data from elsewhere). + * + * Exports checkRegionData(dir, opts) for tests. + */ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const R = require('../public/region-def-core.js'); + +const DEFAULT_DIR = path.join(__dirname, '..', 'public', 'region-layers'); +const DEFAULT_TOL = 0.0005; // degrees, about 55 m: ignore misalignment slivers along shared borders + +function readJSON(file, errors, label) { + let txt; + try { txt = fs.readFileSync(file, 'utf8'); } catch (e) { errors.push(label + ': cannot read file (' + e.code + ')'); return undefined; } + try { return JSON.parse(txt); } catch (e) { errors.push(label + ': invalid JSON (' + e.message + ')'); return undefined; } +} + +// Structural geometry problems the lookup would silently turn into wrong answers. +function checkGeometry(feature, where, errors) { + const g = feature.geometry; + const polys = g.type === 'Polygon' ? [g.coordinates] : g.coordinates; + for (let p = 0; p < polys.length; p++) { + const rings = polys[p]; + if (!Array.isArray(rings) || !rings.length) { errors.push(where + ': polygon without rings'); return; } + for (let r = 0; r < rings.length; r++) { + const ring = rings[r]; + if (!Array.isArray(ring) || ring.length < 4) { errors.push(where + ': ring with fewer than 4 positions'); return; } + const a = ring[0], b = ring[ring.length - 1]; + if (a[0] !== b[0] || a[1] !== b[1]) { errors.push(where + ': ring is not closed'); return; } + for (let i = 0; i < ring.length; i++) { + const x = ring[i][0], y = ring[i][1]; + if (typeof x !== 'number' || typeof y !== 'number' || !isFinite(x) || !isFinite(y)) { errors.push(where + ': non-numeric coordinate'); return; } + if (y < -90 || y > 90) { errors.push(where + ': latitude ' + y + ' out of range (coordinates must be [lon, lat], these look swapped)'); return; } + if (x < -180 || x > 180) { errors.push(where + ': longitude ' + x + ' out of range'); return; } + } + } + } +} + +function strictlyInside(geom, lon, lat) { + const polys = geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates; + for (let i = 0; i < polys.length; i++) { + const poly = polys[i]; + if (R.ringLocate(lon, lat, poly[0]) !== 1) continue; + let inHole = false; + for (let h = 1; h < poly.length; h++) if (R.ringLocate(lon, lat, poly[h]) !== 0) { inHole = true; break; } + if (!inHole) return true; + } + return false; +} + +function distToRingsDeg(geom, lon, lat) { + const k = Math.cos(lat * Math.PI / 180); + let best = Infinity; + const polys = geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates; + polys.forEach((poly) => poly.forEach((ring) => { + for (let i = 1; i < ring.length; i++) { + const ax = ring[i - 1][0] * k, ay = ring[i - 1][1], bx = ring[i][0] * k, by = ring[i][1], px = lon * k, py = lat; + const dx = bx - ax, dy = by - ay, len2 = dx * dx + dy * dy; + let t = len2 ? ((px - ax) * dx + (py - ay) * dy) / len2 : 0; + t = Math.max(0, Math.min(1, t)); + const d = Math.hypot(px - (ax + t * dx), py - (ay + t * dy)); + if (d < best) best = d; + } + })); + return best; +} + +// Vertices and edge midpoints of A, thinned to at most `max` samples. +function samples(geom, max) { + const pts = []; + const polys = geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates; + polys.forEach((poly) => poly.forEach((ring) => { + for (let i = 1; i < ring.length; i++) { + pts.push(ring[i]); + pts.push([(ring[i - 1][0] + ring[i][0]) / 2, (ring[i - 1][1] + ring[i][1]) / 2]); + } + })); + const step = Math.max(1, Math.ceil(pts.length / max)); + return pts.filter((_, i) => i % step === 0); +} + +function overlapDepth(a, b, tol) { + // is a sample of a deeper than `tol` inside b? + const ba = a.bbox, bb = b.bbox; + if (ba[0] > bb[2] || ba[2] < bb[0] || ba[1] > bb[3] || ba[3] < bb[1]) return 0; + let checked = 0, deepest = 0; + const pts = samples(a.feature.geometry, 3000); + for (let i = 0; i < pts.length; i++) { + const x = pts[i][0], y = pts[i][1]; + if (x < bb[0] || x > bb[2] || y < bb[1] || y > bb[3]) continue; + if (!strictlyInside(b.feature.geometry, x, y)) continue; + if (++checked > 200) break; + const d = distToRingsDeg(b.feature.geometry, x, y); + if (d > tol && d > deepest) deepest = d; + } + return deepest; +} + +function checkRegionData(dir, opts) { + opts = opts || {}; + const tol = typeof opts.overlapTolerance === 'number' ? opts.overlapTolerance : DEFAULT_TOL; + const errors = [], warnings = [], summary = []; + const out = { dir: dir, errors: errors, warnings: warnings, summary: summary, layers: [], manifest: null, known: [], checked: false }; + + const manifestFile = path.join(dir, opts.manifestName || 'layers.json'); + if (!fs.existsSync(manifestFile)) return out; + out.checked = true; + const manifest = readJSON(manifestFile, errors, 'layers.json'); + if (manifest === undefined) return out; + out.manifest = manifest; + const v = R.validateManifest(manifest); + v.errors.forEach((e) => errors.push('layers.json: ' + e)); + v.warnings.forEach((w) => warnings.push('layers.json: ' + w)); + if (v.errors.length) return out; + if (manifest.version === undefined) errors.push('layers.json: set "version": ' + R.MANIFEST_VERSION + ' explicitly'); + + const cache = {}; + const codeOwner = {}; + manifest.layers.forEach((def) => { + const at = 'layer "' + def.id + '"'; + const raw = Array.isArray(def.url) ? def.url : [def.url]; + const files = []; + let ok = true; + raw.forEach((u) => { + const f = (u && typeof u === 'object') ? u : { url: u }; + if (!(f.url in cache)) cache[f.url] = readJSON(path.join(dir, f.url), errors, at + ' file ' + f.url); + const data = cache[f.url]; + if (data === undefined) { ok = false; return; } + const feats = R.normalizeGeoJSON(data); + if (!feats.length) { errors.push(at + ' file ' + f.url + ': no Polygon/MultiPolygon features'); ok = false; return; } + feats.forEach((ft, i) => checkGeometry(ft, at + ' file ' + f.url + ' feature ' + (i + 1), errors)); + files.push({ name: f.name || null, code: f.code || null, data: data }); + }); + if (!ok) return; + const layer = R.buildLayer(def, files); + out.layers.push({ def: def, layer: layer }); + if (!layer.features.length) { errors.push(at + ': no usable polygons'); return; } + if (layer.unnamed) { + const names = layer.features.filter((e) => !e.code).slice(0, 5).map((e) => JSON.stringify(e.name || '(unnamed)')).join(', '); + errors.push(at + ': ' + layer.unnamed + ' polygon(s) cannot produce a valid canonical code [' + JSON.stringify(layer.codeProblems) + '] e.g. ' + names + + '. Codes are lowercase a-z 0-9 _ - and must be in the layer\'s "codes" list when one is declared.'); + } + const seen = {}; + layer.features.forEach((e) => { + if (!e.code) return; + if (seen[e.code]) errors.push(at + ': code "' + e.code + '" is produced by more than one polygon (merge them with groupBy or a file "name")'); + seen[e.code] = true; + if (codeOwner[e.code] && codeOwner[e.code] !== def.id) warnings.push('code "' + e.code + '" is used by both layer "' + codeOwner[e.code] + '" and "' + def.id + '"; region def would repeat it'); + codeOwner[e.code] = codeOwner[e.code] || def.id; + }); + if (Array.isArray(def.codes)) { + def.codes.forEach((c) => { if (!seen[c]) errors.push(at + ': canonical code "' + c + '" is declared but no polygon produces it (missing region or wrong property?)'); }); + } + if (!def.allowOverlap) { + const withCode = layer.features.filter((e) => e.code); + for (let i = 0; i < withCode.length; i++) { + for (let j = i + 1; j < withCode.length; j++) { + const d = Math.max(overlapDepth(withCode[i], withCode[j], tol), overlapDepth(withCode[j], withCode[i], tol)); + if (d > 0) errors.push(at + ': "' + withCode[i].code + '" and "' + withCode[j].code + '" overlap (up to ~' + Math.round(d * 111000) + ' m deep). Fix the data or set "allowOverlap": true if intended'); + } + } + } + summary.push(def.id + ': ' + layer.features.length + ' region(s), ' + layer.vertices + ' vertices, codes: ' + layer.features.map((e) => e.code || '(none)').join(' ')); + }); + + const kp = path.join(dir, 'known-points.json'); + if (fs.existsSync(kp) && out.layers.length === manifest.layers.length && !errors.length) { + const points = readJSON(kp, errors, 'known-points.json'); + if (Array.isArray(points)) { + points.forEach((p) => { + if (!p || typeof p.name !== 'string' || typeof p.expect !== 'string' || !R.parseLatLon(p.lat + ',' + p.lon)) { errors.push('known-points.json: malformed entry ' + JSON.stringify(p)); return; } + const r = R.buildRegionDef(out.layers.map((x) => x.layer), p.lat, p.lon); + out.known.push({ name: p.name, expect: p.expect, got: r.value, warnings: r.warnings }); + if (r.value !== p.expect) errors.push('known point "' + p.name + '": expected "' + p.expect + '" but got "' + r.value + '"'); + else if (r.warnings.length && !p.allowWarnings) errors.push('known point "' + p.name + '": unexpected warning: ' + r.warnings.join(' | ')); + }); + } else if (points !== undefined) errors.push('known-points.json: must be an array'); + } + return out; +} + +function draftKnownPoints(res, dir) { + const kp = path.join(dir, 'known-points.json'); + let rows; + try { rows = JSON.parse(fs.readFileSync(kp, 'utf8')); } catch (e) { console.error('--draft-known-points: cannot read ' + kp + ': ' + e.message); return 2; } + if (!Array.isArray(rows)) { console.error('--draft-known-points: known-points.json must be an array'); return 2; } + const layers = res.layers.map((x) => x.layer); + const out = []; + rows.forEach((p) => { + if (!p || typeof p.name !== 'string' || !R.parseLatLon(p.lat + ',' + p.lon)) { console.error('skipped malformed row ' + JSON.stringify(p)); return; } + const r = R.buildRegionDef(layers, p.lat, p.lon); + const row = { name: p.name, lat: p.lat, lon: p.lon, expect: r.value }; + if (r.warnings.length) { row.allowWarnings = true; console.error('row "' + p.name + '" has warnings: ' + r.warnings.join(' | ')); } + if (p.expect !== undefined && p.expect !== r.value) console.error('changed "' + p.name + '": was "' + p.expect + '", now "' + r.value + '"'); + out.push(row); + }); + if (res.errors.length) console.error('note: the data still has ' + res.errors.length + ' error(s); fix those first or this draft will record broken results'); + console.log(JSON.stringify(out, null, 1)); + return 0; +} + +function main(argv) { + let dir = DEFAULT_DIR, strict = false, tol = DEFAULT_TOL, draft = false; + const points = []; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--dir') dir = path.resolve(argv[++i]); + else if (argv[i] === '--strict') strict = true; + else if (argv[i] === '--overlap-tolerance') tol = parseFloat(argv[++i]); + else if (argv[i] === '--point') points.push(argv[++i]); + else if (argv[i] === '--draft-known-points') draft = true; + else { console.error('unknown argument ' + argv[i]); return 2; } + } + const res = checkRegionData(dir, { overlapTolerance: tol }); + if (!res.checked) { console.log('check-region-data: no layers.json in ' + dir + ' - nothing to check'); return 0; } + if (draft) return draftKnownPoints(res, dir); + res.summary.forEach((s) => console.log(' ' + s)); + res.warnings.forEach((w) => console.log('WARN ' + w)); + res.errors.forEach((e) => console.log('ERROR ' + e)); + points.forEach((s) => { + const ll = R.parseLatLon(s); + if (!ll) { console.log('--point ' + s + ': not a lat,lon pair'); return; } + const r = R.buildRegionDef(res.layers.map((x) => x.layer), ll.lat, ll.lon); + console.log('\npoint ' + ll.lat + ', ' + ll.lon + (r.value ? '' : (r.blocked && r.blocked.length ? ' (command withheld)' : ' (not inside any region)'))); + if (r.value) console.log(R.repeaterCommands(r.value).split('\n').map((l) => ' ' + l).join('\n')); + (r.blocked || []).forEach((b) => console.log(' BLOCKED: ' + b)); + r.warnings.forEach((w) => console.log(' warning: ' + w)); + }); + const bad = res.errors.length || (strict && res.warnings.length); + console.log('\ncheck-region-data: ' + (bad ? 'FAIL' : 'OK') + ' - ' + res.errors.length + ' error(s), ' + res.warnings.length + ' warning(s), ' + res.known.length + ' known point(s)'); + return bad ? 1 : 0; +} + +module.exports = { checkRegionData: checkRegionData, DEFAULT_TOL: DEFAULT_TOL }; +if (require.main === module) process.exit(main(process.argv.slice(2))); diff --git a/scripts/non-unit-tests.json b/scripts/non-unit-tests.json index b72ee94f..0495614b 100644 --- a/scripts/non-unit-tests.json +++ b/scripts/non-unit-tests.json @@ -112,7 +112,8 @@ "tests/e2e/test-slideover-1168-munger-e2e.js", "tests/e2e/test-table-fluid-e2e.js", "tests/e2e/test-touch-gestures-coverage-e2e.js", - "tests/e2e/test-touch-targets.js" + "tests/e2e/test-touch-targets.js", + "tests/e2e/test-region-def-e2e.js" ] }, "domIntegration": { diff --git a/scripts/simplify-region-layers.js b/scripts/simplify-region-layers.js new file mode 100644 index 00000000..46cb47a5 --- /dev/null +++ b/scripts/simplify-region-layers.js @@ -0,0 +1,149 @@ +#!/usr/bin/env node +/* Simplify Region Def boundary files so the page downloads and draws less. + * + * node scripts/simplify-region-layers.js --in DIR --out DIR [--tolerance 0.0005] [--precision 5] + * + * Each polygon ring is reduced with Douglas-Peucker (tolerance in degrees; 0.0005 is about + * 55 m) and coordinates are rounded to `precision` decimals (5 is about 1 m) and written without + * whitespace. layers.json and known-points.json are copied unchanged. + * + * Safety net: the output directory must pass scripts/check-region-data.js (including every + * known point producing exactly the same command) or the script exits 1. Input that already + * fails the check is refused: fix the data first, then simplify. + * + * Rings are simplified independently, so two neighbours can drift apart by up to the tolerance + * along a shared border. The check ignores overlaps shallower than 2x the tolerance, and a + * point inside such a sliver can resolve to either neighbour. Pick a tolerance well below the + * accuracy you need; this is about page weight, not about surveying. + */ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const { checkRegionData } = require('./check-region-data.js'); + +function dp(points, tol, k) { + // points: open path of [lon, lat]; returns kept points (first and last always kept) + const n = points.length; + if (n <= 2) return points.slice(); + const keep = new Uint8Array(n); + keep[0] = keep[n - 1] = 1; + const stack = [[0, n - 1]]; + while (stack.length) { + const [a, b] = stack.pop(); + let best = -1, bi = -1; + const ax = points[a][0] * k, ay = points[a][1], bx = points[b][0] * k, by = points[b][1]; + const dx = bx - ax, dy = by - ay, len2 = dx * dx + dy * dy; + for (let i = a + 1; i < b; i++) { + const px = points[i][0] * k, py = points[i][1]; + let d; + if (len2 === 0) d = Math.hypot(px - ax, py - ay); + else { + let t = ((px - ax) * dx + (py - ay) * dy) / len2; + t = Math.max(0, Math.min(1, t)); + d = Math.hypot(px - (ax + t * dx), py - (ay + t * dy)); + } + if (d > best) { best = d; bi = i; } + } + if (best > tol) { keep[bi] = 1; stack.push([a, bi], [bi, b]); } + } + return points.filter((_, i) => keep[i]); +} + +function round(v, p) { const m = Math.pow(10, p); return Math.round(v * m) / m; } + +function simplifyRing(ring, tol, precision) { + const open = ring.slice(0, -1); + const meanLat = open.reduce((s, c) => s + c[1], 0) / open.length; + const k = Math.cos(meanLat * Math.PI / 180); + // anchor the ring at its two farthest-apart vertices so the closing seam is simplified like any other edge + let ia = 0, ib = 0, far = -1; + for (let i = 1; i < open.length; i++) { + const d = Math.hypot((open[i][0] - open[0][0]) * k, open[i][1] - open[0][1]); + if (d > far) { far = d; ib = i; } + } + const rot = open.slice(ia).concat(open.slice(0, ia)); + const half1 = dp(rot.slice(0, ib + 1), tol, k); + const half2 = dp(rot.slice(ib).concat([rot[0]]), tol, k); + let pts = half1.slice(0, -1).concat(half2); + pts = pts.map((c) => [round(c[0], precision), round(c[1], precision)]); + const dedup = []; + pts.forEach((c) => { const l = dedup[dedup.length - 1]; if (!l || l[0] !== c[0] || l[1] !== c[1]) dedup.push(c); }); + if (dedup[0][0] !== dedup[dedup.length - 1][0] || dedup[0][1] !== dedup[dedup.length - 1][1]) dedup.push(dedup[0].slice()); + return dedup.length >= 4 ? dedup : null; // collapsed: caller keeps the original +} + +function simplifyGeometry(geom, tol, precision, stats) { + const polys = geom.type === 'Polygon' ? [geom.coordinates] : geom.coordinates; + const out = polys.map((poly) => poly.map((ring) => { + stats.before += ring.length; + const s = simplifyRing(ring, tol, precision); + if (!s) { stats.kept++; stats.after += ring.length; return ring.map((c) => [round(c[0], precision), round(c[1], precision)]); } + stats.after += s.length; + return s; + })); + return geom.type === 'Polygon' ? { type: 'Polygon', coordinates: out[0] } : { type: 'MultiPolygon', coordinates: out }; +} + +function simplifyGeoJSON(gj, tol, precision, stats) { + const feats = gj.type === 'FeatureCollection' ? gj.features : gj.type === 'Feature' ? [gj] : [{ type: 'Feature', properties: {}, geometry: gj }]; + return { + type: 'FeatureCollection', + features: feats.map((f) => { + if (!f || !f.geometry || (f.geometry.type !== 'Polygon' && f.geometry.type !== 'MultiPolygon')) return f; + return { type: 'Feature', properties: f.properties || {}, geometry: simplifyGeometry(f.geometry, tol, precision, stats) }; + }) + }; +} + +function run(inDir, outDir, opts) { + opts = opts || {}; + const tol = opts.tolerance === undefined ? 0.0005 : opts.tolerance; + const precision = opts.precision === undefined ? 5 : opts.precision; + const log = opts.log || function () {}; + const result = { ok: false, errors: [], files: [] }; + if (path.resolve(inDir) === path.resolve(outDir)) { result.errors.push('--in and --out must be different directories'); return result; } + const before = checkRegionData(inDir); + if (!before.checked) { result.errors.push('no layers.json in ' + inDir); return result; } + if (before.errors.length) { result.errors = ['input fails the integrity check, fix it first:'].concat(before.errors); return result; } + fs.mkdirSync(outDir, { recursive: true }); + const names = {}; + before.manifest.layers.forEach((l) => (Array.isArray(l.url) ? l.url : [l.url]).forEach((u) => { names[typeof u === 'object' ? u.url : u] = true; })); + Object.keys(names).forEach((f) => { + const src = fs.readFileSync(path.join(inDir, f), 'utf8'); + const stats = { before: 0, after: 0, kept: 0 }; + fs.mkdirSync(path.dirname(path.join(outDir, f)), { recursive: true }); + const outTxt = JSON.stringify(simplifyGeoJSON(JSON.parse(src), tol, precision, stats)); + fs.writeFileSync(path.join(outDir, f), outTxt + '\n'); + result.files.push({ file: f, bytesBefore: Buffer.byteLength(src), bytesAfter: Buffer.byteLength(outTxt) + 1, vertsBefore: stats.before, vertsAfter: stats.after, collapsedKept: stats.kept }); + }); + ['layers.json', 'known-points.json'].forEach((f) => { if (fs.existsSync(path.join(inDir, f))) fs.copyFileSync(path.join(inDir, f), path.join(outDir, f)); }); + const after = checkRegionData(outDir, { overlapTolerance: tol * 2 }); + result.warnings = after.warnings; + if (after.errors.length) { + result.errors = ['simplified output fails the integrity check (tolerance ' + tol + ' is too coarse for this data, or a known point moved):'].concat(after.errors); + return result; + } + result.ok = true; + return result; +} + +function main(argv) { + let inDir, outDir, tolerance, precision; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--in') inDir = path.resolve(argv[++i]); + else if (argv[i] === '--out') outDir = path.resolve(argv[++i]); + else if (argv[i] === '--tolerance') tolerance = parseFloat(argv[++i]); + else if (argv[i] === '--precision') precision = parseInt(argv[++i], 10); + else { console.error('unknown argument ' + argv[i]); return 2; } + } + if (!inDir || !outDir) { console.error('usage: simplify-region-layers.js --in DIR --out DIR [--tolerance DEG] [--precision N]'); return 2; } + const r = run(inDir, outDir, { tolerance: tolerance, precision: precision }); + r.files.forEach((f) => console.log(' ' + f.file + ': ' + f.vertsBefore + ' -> ' + f.vertsAfter + ' vertices, ' + f.bytesBefore + ' -> ' + f.bytesAfter + ' bytes')); + (r.warnings || []).forEach((w) => console.log('WARN ' + w)); + r.errors.forEach((e) => console.log('ERROR ' + e)); + console.log(r.ok ? 'simplify-region-layers: OK (output passes check-region-data, known points unchanged)' : 'simplify-region-layers: FAIL'); + return r.ok ? 0 : 1; +} + +module.exports = { run: run, simplifyRing: simplifyRing }; +if (require.main === module) process.exit(main(process.argv.slice(2))); diff --git a/test-all.sh b/test-all.sh index 705aa080..60148d6d 100755 --- a/test-all.sh +++ b/test-all.sh @@ -178,6 +178,11 @@ node tests/unit/test-preflight-xss-gate.js node tests/unit/test-pull-to-reconnect-1091.js node tests/unit/test-pull-to-reconnect.js node tests/unit/test-repeater-metric-scatter.js +node tests/unit/test-region-def.js +node tests/unit/test-region-layers-manifest.js +node tests/unit/test-region-def-known-points.js +node tests/unit/test-check-region-data.js +node tests/unit/test-simplify-region-layers.js node tests/unit/test-rx-coverage-config-race.js node tests/unit/test-rx-coverage-escape.js node tests/unit/test-rx-coverage-viewport.js diff --git a/tests/e2e/test-region-def-e2e.js b/tests/e2e/test-region-def-e2e.js new file mode 100644 index 00000000..89647ef5 --- /dev/null +++ b/tests/e2e/test-region-def-e2e.js @@ -0,0 +1,456 @@ +#!/usr/bin/env node +/* Region Def (#/tools/region-def) E2E. + * + * Mocked part: the page reads operator-supplied files from /region-layers/. Those requests are + * intercepted and answered from tests/fixtures/region-layers/ (a synthetic multi-state + * deployment), with per-test mutations for failure modes. Nominatim is intercepted too: no test + * ever touches the real service. + * + * Production smoke (last step): if the server under test really serves /region-layers/layers.json, + * the page is loaded WITHOUT interception and the deployed files are exercised through the real + * UI (every point in the deployed known-points.json must produce its exact command). A server + * with no region data deployed skips that step. + * + * Assertions are on exact text (commands, messages, badges), not just on elements existing. + * CHROMIUM_REQUIRE=1 makes Chromium-launch failure a HARD FAIL. Exit code is nonzero on any failure. + */ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; +const FIX = path.join(__dirname, '..', 'fixtures', 'region-layers'); +const CMD_TAIL = '\nregion save\nregion allowf *'; + +let passed = 0, failed = 0; +async function step(name, fn) { + try { await fn(); passed++; console.log(' ok ' + name); } + catch (e) { failed++; console.error(' FAIL ' + name + ': ' + e.message); } +} +function eq(actual, expected, what) { + if (actual !== expected) throw new Error((what || 'value') + ': expected ' + JSON.stringify(expected) + ' but got ' + JSON.stringify(actual)); +} +function ok(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +const load = (name) => JSON.parse(fs.readFileSync(path.join(FIX, name), 'utf8')); +function freshFiles() { + const f = {}; + fs.readdirSync(FIX).filter((n) => n !== 'README.md').forEach((n) => { f[n] = load(n); }); + return f; +} +const KNOWN = load('known-points.json'); +const point = (name) => KNOWN.find((p) => p.name.indexOf(name) === 0); +const cmd = (value) => 'region def ' + value + CMD_TAIL; +const box = (x0, y0, x1, y1) => [[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]; +const polyFeature = (props, ring) => ({ type: 'Feature', properties: props, geometry: { type: 'Polygon', coordinates: [ring] } }); + +let ctx; + +/* Open a page whose /region-layers/ and Nominatim traffic is mocked. + * mutate(files) edit the fixture files (deep copies) before serving + * respond { 'name': { status, body, contentType, hang, abort } } per-file overrides + * nominatim (route) => ... custom geocoder handler (default: one Cincinnati result) + * config window.RegionDefConfig (timeouts) + * viewport { width, height } + * fn(page, hits) the test body; hits counts requests per layer file + */ +async function withPage(opts, fn) { + const files = freshFiles(); + if (opts.mutate) opts.mutate(files); + const pg = await ctx.newPage(); + if (opts.viewport) await pg.setViewportSize(opts.viewport); + if (opts.config) await pg.addInitScript((c) => { window.RegionDefConfig = c; }, opts.config); + const hits = {}; + const respond = opts.respond || {}; + const pageErrors = []; + pg.on('pageerror', (e) => pageErrors.push(e.message)); + await pg.route('**/region-layers/**', (route) => { + const name = route.request().url().split('/region-layers/')[1].split('?')[0]; + hits[name] = (hits[name] || 0) + 1; + const o = respond[name]; + if (o && o.hang) return; // never answered: exercises the timeout + if (o && o.abort) return route.abort('failed'); + if (o) return route.fulfill({ status: o.status || 200, contentType: o.contentType || 'application/json', body: typeof o.body === 'string' ? o.body : JSON.stringify(o.body) }); + if (!(name in files)) return route.fulfill({ status: 404, body: 'not found' }); + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(files[name]) }); + }); + await pg.route('https://nominatim.openstreetmap.org/**', (route) => { + if (opts.nominatim) return opts.nominatim(route); + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([{ lat: '39.1031', lon: '-84.5120', display_name: 'Cincinnati, Ohio, USA' }]) }); + }); + try { await fn(pg, hits); } + finally { await pg.close(); } + if (pageErrors.length) throw new Error('uncaught page error: ' + pageErrors[0]); +} + +const goto = (pg, hash) => pg.goto(BASE + '/#/tools/region-def' + (hash || ''), { waitUntil: 'domcontentloaded' }); +const text = async (pg, sel) => (await pg.textContent(sel)) || ''; +async function rows(pg) { + return pg.$$eval('.rd-row', (els) => els.map((e) => ({ label: e.querySelector('.rd-row-label').textContent, badge: e.querySelector('.rd-badge').textContent, name: (e.querySelector('.rd-row-name') || {}).textContent || '' }))); +} +async function expectCommand(pg, value) { + await pg.waitForSelector('.rd-cmd'); + eq(await text(pg, '.rd-cmd'), cmd(value), 'repeater command'); + eq(await text(pg, '.rd-cmd-head'), 'Type this into your repeater', 'header'); + ok(!(await pg.$('.rd-incomplete')), 'unexpected incomplete banner'); + eq(await text(pg, '#rd-msg'), '', 'status line'); +} +async function waitMsg(pg, re) { + await pg.waitForFunction((src) => new RegExp(src).test((document.querySelector('#rd-msg') || {}).textContent || ''), re.source, { timeout: 8000 }); + return text(pg, '#rd-msg'); +} +async function typeAndGo(pg, value) { await pg.fill('#rd-input', value); await pg.press('#rd-input', 'Enter'); } + +(async () => { + const requireChromium = process.env.CHROMIUM_REQUIRE === '1'; + let browser; + try { + browser = await chromium.launch({ headless: true, executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'] }); + } catch (err) { + if (requireChromium) { console.error('test-region-def-e2e.js: FAIL - Chromium required but unavailable: ' + err.message); process.exit(1); } + console.log('test-region-def-e2e.js: SKIP (Chromium unavailable)'); process.exit(0); + } + ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } }); + ctx.setDefaultTimeout(15000); + + console.log('\n=== region-def E2E against ' + BASE + ' ==='); + + // ---------------------------------------------------------------- navigation and happy path + await step('Tools landing links to Region Def', async () => { + const pg = await ctx.newPage(); + await pg.goto(BASE + '/#/tools', { waitUntil: 'domcontentloaded' }); + await pg.waitForSelector('.tools-card'); + const hrefs = await pg.$$eval('.tools-card', (a) => a.map((x) => x.getAttribute('href'))); + ok(hrefs.indexOf('#/tools/region-def') >= 0, 'card missing, got ' + hrefs.join(',')); + await pg.close(); + }); + + await step('page loads all four layers and the map', async () => { + await withPage({}, async (pg) => { + await goto(pg); + await pg.waitForSelector('.rd-toggle'); + eq(JSON.stringify(await pg.$$eval('.rd-toggle', (l) => l.map((x) => x.textContent))), JSON.stringify(['Macro region', 'Mesh region', 'State', 'Metro']), 'layer toggles'); + ok(await pg.$('#rd-map.leaflet-container'), 'leaflet map missing'); + eq(await text(pg, '#rd-msg'), '', 'status line'); + }); + }); + + for (const name of ['Cincinnati OH', 'Indianapolis IN', 'Covington KY', 'Detroit MI', 'Pittsburgh PA', 'OH/IN line, Indiana side, lat 40.5', 'OH/PA line, Pennsylvania side']) { + await step('exact command for ' + name, async () => { + const p = point(name); + await withPage({}, async (pg) => { + await goto(pg, '?lat=' + p.lat + '&lon=' + p.lon); + await expectCommand(pg, p.expect); + eq((await rows(pg)).length, 4, 'one result row per layer'); + }); + }); + } + + await step('result rows give INSIDE/OUTSIDE per layer with the matched name and code', async () => { + await withPage({}, async (pg) => { + const p = point('Cincinnati OH'); + await goto(pg, '?lat=' + p.lat + '&lon=' + p.lon); + await expectCommand(pg, p.expect); + eq(JSON.stringify(await rows(pg)), JSON.stringify([ + { label: 'Macro region', badge: 'INSIDE', name: 'US Midwest (us-midwest)' }, + { label: 'Mesh region', badge: 'INSIDE', name: 'OKI (oki)' }, + { label: 'State', badge: 'INSIDE', name: 'US-OH (us-oh)' }, + { label: 'Metro', badge: 'INSIDE', name: 'CVG (cvg)' }]), 'rows'); + }); + await withPage({}, async (pg) => { + const p = point('Detroit MI'); + await goto(pg, '?lat=' + p.lat + '&lon=' + p.lon); + await expectCommand(pg, p.expect); + eq(JSON.stringify((await rows(pg)).map((r) => r.label + ':' + r.badge)), JSON.stringify(['Macro region:INSIDE', 'Mesh region:OUTSIDE', 'State:INSIDE', 'Metro:OUTSIDE']), 'non-mesh state rows'); + }); + }); + + await step('a point outside every region shows the exact empty message and no command', async () => { + await withPage({}, async (pg) => { + await goto(pg, '?lat=35&lon=-70'); + await pg.waitForSelector('.rd-none'); + eq(await text(pg, '.rd-none'), 'This location is not inside any configured region.', 'message'); + ok(!(await pg.$('.rd-cmd')), 'command must not render'); + eq(JSON.stringify((await rows(pg)).map((r) => r.badge)), JSON.stringify(['OUTSIDE', 'OUTSIDE', 'OUTSIDE', 'OUTSIDE']), 'badges'); + }); + }); + + await step('clicking the map looks up that spot, fills the input and the deep link', async () => { + await withPage({}, async (pg) => { + await goto(pg); + await pg.waitForSelector('.rd-toggle'); + const box_ = await (await pg.$('#rd-map')).boundingBox(); + await pg.mouse.click(box_.x + box_.width / 2, box_.y + box_.height / 2); + await pg.waitForFunction(() => /^-?\d+\.\d+, -?\d+\.\d+$/.test(document.querySelector('#rd-input').value)); + ok(/lat=-?\d+\.\d+&lon=-?\d+\.\d+/.test(pg.url()), 'deep link: ' + pg.url()); + }); + }); + + await step('empty input asks for an address or coordinates', async () => { + await withPage({}, async (pg) => { + await goto(pg); + await pg.waitForSelector('.rd-toggle'); + await pg.click('#rd-go'); + eq(await text(pg, '#rd-msg'), 'Enter an address or a "lat, lon" pair.', 'message'); + }); + }); + + // ---------------------------------------------------------------- geocoder + await step('address lookup: one result gives the exact command and a lat/lon deep link', async () => { + await withPage({}, async (pg) => { + await goto(pg); + await pg.waitForSelector('.rd-toggle'); + await typeAndGo(pg, '1 Main St Cincinnati'); + await expectCommand(pg, 'us-midwest oki us-oh cvg'); + ok(/lat=39\.1031.*lon=-84\.5120/.test(pg.url()), pg.url()); + }); + }); + + await step('address lookup: several results are offered as buttons showing the exact names; picking one resolves it', async () => { + const list = [{ lat: '39.1031', lon: '-84.5120', display_name: 'Cincinnati, Ohio, USA' }, { lat: '39.7684', lon: '-86.1581', display_name: 'Indianapolis, Indiana, USA' }]; + await withPage({ nominatim: (r) => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(list) }) }, async (pg) => { + await goto(pg); + await pg.waitForSelector('.rd-toggle'); + await typeAndGo(pg, 'springfield'); + await pg.waitForSelector('.rd-cand'); + eq(await text(pg, '#rd-msg'), 'Several matches; pick one:', 'message'); + eq(JSON.stringify(await pg.$$eval('.rd-cand', (b) => b.map((x) => x.textContent))), JSON.stringify(['Cincinnati, Ohio, USA', 'Indianapolis, Indiana, USA']), 'candidates'); + await (await pg.$$('.rd-cand'))[1].click(); + await expectCommand(pg, 'us-midwest oki us-in ind'); + eq((await pg.$$('.rd-cand')).length, 0, 'candidates should be cleared'); + }); + }); + + const geocoderFailures = [ + ['HTTP 500', { nominatim: (r) => r.fulfill({ status: 500, body: 'boom' }) }, 'Address lookup failed: the geocoder returned HTTP 500'], + ['HTTP 429 (rate limited)', { nominatim: (r) => r.fulfill({ status: 429, body: 'slow down' }) }, 'Address lookup failed: the geocoder is rate limiting requests; wait a moment and try again'], + ['network failure', { nominatim: (r) => r.abort('failed') }, 'Address lookup failed: network error (offline, or the geocoder is blocked?)'], + ['unreadable reply', { nominatim: (r) => r.fulfill({ status: 200, contentType: 'application/json', body: 'not json' }) }, 'Address lookup failed: the geocoder sent an unreadable reply'], + ['timeout', { nominatim: () => {}, config: { geocodeTimeoutMs: 300 } }, 'Address lookup failed: the geocoder did not answer in time'], + ]; + for (const [label, o, expected] of geocoderFailures) { + await step('geocoder failure (' + label + ') shows a plain message, no command, and the form stays usable', async () => { + await withPage(o, async (pg) => { + await goto(pg); + await pg.waitForSelector('.rd-toggle'); + await typeAndGo(pg, 'anywhere'); + eq(await waitMsg(pg, new RegExp('^' + expected.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$')), expected, 'message'); + ok(!(await pg.$('.rd-cmd')), 'no command on failure'); + ok(!/nominatim|anywhere/i.test(await text(pg, '#rd-msg')), 'message must not echo the request URL or address'); + await pg.waitForFunction(() => !document.querySelector('#rd-go').disabled); + }); + }); + } + + await step('geocoder returning no results says so exactly', async () => { + await withPage({ nominatim: (r) => r.fulfill({ status: 200, contentType: 'application/json', body: '[]' }) }, async (pg) => { + await goto(pg); + await pg.waitForSelector('.rd-toggle'); + await typeAndGo(pg, 'zzzz nowhere'); + eq(await waitMsg(pg, /No match found/), 'No match found for that address.', 'message'); + }); + }); + + // ---------------------------------------------------------------- layer loading failures + const STATE_404 = { 'states.geojson': { status: 404, body: 'nope' } }; + await step('required layer 404: command withheld, message names the layer and file', async () => { + await withPage({ respond: STATE_404 }, async (pg) => { + const p = point('Cincinnati OH'); + await goto(pg, '?lat=' + p.lat + '&lon=' + p.lon); + await pg.waitForSelector('.rd-incomplete'); + const t = await text(pg, '.rd-incomplete'); + ok(t.indexOf('Command withheld: the result would be incomplete.') === 0, t); + ok(t.indexOf('Mesh region: HTTP 404 for region-layers/states.geojson') >= 0, t); + ok(t.indexOf('State: HTTP 404 for region-layers/states.geojson') >= 0, t); + ok(!(await pg.$('.rd-cmd')), 'command must not render'); + }); + }); + + await step('optional layer 404: command still shown, flagged incomplete, missing code named', async () => { + await withPage({ mutate: (f) => { f['layers.json'].layers[3].optional = true; }, respond: { 'metros.geojson': { status: 404, body: 'nope' } } }, async (pg) => { + const p = point('Cincinnati OH'); + await goto(pg, '?lat=' + p.lat + '&lon=' + p.lon); + await pg.waitForSelector('.rd-cmd'); + eq(await text(pg, '.rd-cmd'), cmd('us-midwest oki us-oh'), 'command without the metro'); + eq(await text(pg, '.rd-cmd-head'), 'Type this into your repeater (incomplete)', 'header'); + const t = await text(pg, '.rd-incomplete'); + ok(t.indexOf('Incomplete result: this command is missing codes for optional layers.') === 0 && t.indexOf('Metro: HTTP 404 for region-layers/metros.geojson') >= 0, t); + }); + }); + + await step('malformed GeoJSON file (invalid JSON) is reported by file name', async () => { + await withPage({ respond: { 'metros.geojson': { status: 200, body: '{"type":"FeatureCollection","features":[{oops' } } }, async (pg) => { + const p = point('Cincinnati OH'); + await goto(pg, '?lat=' + p.lat + '&lon=' + p.lon); + await pg.waitForSelector('.rd-incomplete'); + ok((await text(pg, '.rd-incomplete')).indexOf('Metro: invalid JSON in region-layers/metros.geojson') >= 0, await text(pg, '.rd-incomplete')); + ok(!(await pg.$('.rd-cmd'))); + }); + }); + + await step('valid JSON that is not polygon GeoJSON counts as a failed layer, not a loaded one', async () => { + await withPage({ respond: { 'metros.geojson': { body: { hello: 'world' } } } }, async (pg) => { + const p = point('Cincinnati OH'); + await goto(pg, '?lat=' + p.lat + '&lon=' + p.lon); + await pg.waitForSelector('.rd-incomplete'); + ok((await text(pg, '.rd-incomplete')).indexOf('Metro: loaded but contains no valid polygons') >= 0, await text(pg, '.rd-incomplete')); + }); + }); + + await step('layer request timeout: reported as a timeout, command withheld', async () => { + await withPage({ config: { layerTimeoutMs: 300 }, respond: { 'metros.geojson': { hang: true } } }, async (pg) => { + const p = point('Cincinnati OH'); + await goto(pg, '?lat=' + p.lat + '&lon=' + p.lon); + await pg.waitForSelector('.rd-incomplete'); + ok(/Metro: timed out after 300 ms: region-layers\/metros\.geojson/.test(await text(pg, '.rd-incomplete')), await text(pg, '.rd-incomplete')); + ok(!(await pg.$('.rd-cmd'))); + }); + }); + + await step('layers.json problems stop the page with an exact message: 404, invalid JSON, timeout, newer version, unsafe path', async () => { + const cases = [ + [{ respond: { 'layers.json': { status: 404, body: 'nope' } } }, 'Could not load region layers: HTTP 404 for region-layers/layers.json'], + [{ respond: { 'layers.json': { status: 200, body: '{ nope' } } }, 'Could not load region layers: invalid JSON in region-layers/layers.json'], + [{ config: { layerTimeoutMs: 300 }, respond: { 'layers.json': { hang: true } } }, 'Could not load region layers: timed out after 300 ms: region-layers/layers.json'], + [{ mutate: (f) => { f['layers.json'].version = 99; } }, 'Could not load region layers: layers.json is invalid: layers.json version 99 is newer than this page supports (1).'], + [{ mutate: (f) => { f['layers.json'].layers[3].url = '../secret.json'; } }, null], + ]; + for (const [o, expected] of cases) { + await withPage(o, async (pg, hits) => { + await goto(pg); + if (expected) eq(await waitMsg(pg, new RegExp('^' + expected.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$')), expected, 'message'); + else { + const m = await waitMsg(pg, /unsafe or invalid file path/); + ok(/^Could not load region layers: layers\.json is invalid: layer 4 \(metro\): unsafe or invalid file path "\.\.\/secret\.json"\.$/.test(m), m); + ok(!hits['../secret.json'] && !hits['secret.json'], 'traversal path must never be requested'); + } + ok(await pg.$eval('#rd-go', (b) => b.disabled), 'lookup must stay disabled when nothing loaded'); + ok(!(await pg.$('.rd-cmd'))); + }); + } + }); + + await step('a file used by several layers is fetched exactly once', async () => { + await withPage({}, async (pg, hits) => { + await goto(pg, '?lat=39.1031&lon=-84.5120'); + await pg.waitForSelector('.rd-cmd'); + eq(hits['states.geojson'], 1, 'states.geojson requests (used by the mesh and state layers)'); + }); + }); + + // ---------------------------------------------------------------- overlaps, gaps, escaping + await step('overlapping regions: first wins, the warning names both, output stays complete', async () => { + await withPage({ mutate: (f) => { + f['metros.geojson'].features.push(polyFeature({ iata: 'LUK' }, box(-84.8, 38.95, -84.3, 39.25))); + f['layers.json'].layers[3].codes.push('luk'); f['layers.json'].layers[3].allowOverlap = true; + } }, async (pg) => { + const p = point('Cincinnati OH'); + await goto(pg, '?lat=' + p.lat + '&lon=' + p.lon); + await pg.waitForSelector('.rd-cmd'); + eq(await text(pg, '.rd-cmd'), cmd(p.expect), 'first match wins'); + eq(await text(pg, '.rd-warnings'), 'Metro: point is in 2 overlapping regions (cvg, luk); using cvg.', 'warning'); + eq((await rows(pg))[3].name, 'CVG (cvg) - overlaps 1 more', 'row text'); + }); + }); + + await step('coverage:"full" mesh layer: a state outside the mesh region is a data gap and the command is withheld', async () => { + await withPage({ mutate: (f) => { f['layers.json'].layers[1].coverage = 'full'; } }, async (pg) => { + const p = point('Detroit MI'); + await goto(pg, '?lat=' + p.lat + '&lon=' + p.lon); + await pg.waitForSelector('.rd-incomplete'); + ok((await text(pg, '.rd-incomplete')).indexOf('Mesh region: no region covers this point, but the layer declares full coverage (data gap)') >= 0, await text(pg, '.rd-incomplete')); + ok(!(await pg.$('.rd-cmd'))); + }); + }); + + await step('HTML in geocoder names and in boundary data is displayed as literal text, never parsed', async () => { + const evil = [{ lat: '39.1031', lon: '-84.5120', display_name: 'Evil St' }, { lat: '39.7684', lon: '-86.1581', display_name: 'bold & ' }]; + await withPage({ nominatim: (r) => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(evil) }), + mutate: (f) => { f['metros.geojson'].features[0].properties.iata = 'x'; delete f['layers.json'].layers[3].codes; f['known-points.json'] = []; } }, async (pg) => { + await goto(pg); + await pg.waitForSelector('.rd-toggle'); + await typeAndGo(pg, 'evil'); + await pg.waitForSelector('.rd-cand'); + eq(JSON.stringify(await pg.$$eval('.rd-cand', (b) => b.map((x) => x.textContent))), JSON.stringify(evil.map((e) => e.display_name)), 'candidate text is the literal string'); + eq(await pg.$$eval('.rd-cand img, .rd-cand b, .rd-cand script', (n) => n.length), 0, 'no elements created from candidate text'); + await (await pg.$$('.rd-cand'))[0].click(); + await pg.waitForSelector('.rd-cmd'); + eq(await text(pg, '.rd-where'), evil[0].display_name, 'result heading is literal'); + eq(await pg.$$eval('.rd-where img, .rd-row-name i', (n) => n.length), 0, 'no elements created from the heading or boundary names'); + ok((await rows(pg))[3].name.indexOf('x') === 0, 'boundary name shown literally: ' + (await rows(pg))[3].name); + eq(await pg.evaluate(() => window.__xss), undefined, 'no injected script ran'); + }); + }); + + // ---------------------------------------------------------------- mobile layouts + for (const vp of [{ width: 320, height: 640 }, { width: 375, height: 667 }, { width: 768, height: 1024 }]) { + await step('mobile layout at ' + vp.width + 'x' + vp.height + ': no overflow, command fully on screen, map usable, details collapsed', async () => { + await withPage({ viewport: vp }, async (pg) => { + const p = point('Cincinnati OH'); + await goto(pg, '?lat=' + p.lat + '&lon=' + p.lon); + await expectCommand(pg, p.expect); + const m = await pg.evaluate(() => { + const r = document.querySelector('.rd-cmd').getBoundingClientRect(); + const map = document.querySelector('#rd-map').getBoundingClientRect(); + return { sw: document.documentElement.scrollWidth, vw: innerWidth, vh: innerHeight, left: r.left, right: r.right, top: r.top, bottom: r.bottom, + mapH: map.height, mapW: map.width, details: document.querySelector('.rd-details').open, + smallTargets: [].slice.call(document.querySelectorAll('.rd-input-row .btn, .rd-details > summary')).filter((e) => e.getBoundingClientRect().height < 44).length }; + }); + ok(m.sw <= m.vw, 'horizontal overflow ' + m.sw + ' > ' + m.vw); + ok(m.left >= 0 && m.right <= m.vw, 'command overflows horizontally: ' + m.left + '..' + m.right); + ok(m.top >= 0 && m.bottom <= m.vh, 'command not fully visible without scrolling: ' + m.top + '..' + m.bottom + ' of ' + m.vh); + ok(m.mapH >= 280 && m.mapW >= vp.width - 40, 'map too small: ' + m.mapW + 'x' + m.mapH); + eq(m.details, false, 'details start collapsed on narrow screens'); + eq(m.smallTargets, 0, 'touch targets under 44px'); + }); + }); + } + + await step('desktop layout on a small laptop (1024x600): panel scrolls, no page overflow, map visible', async () => { + await withPage({ viewport: { width: 1024, height: 600 } }, async (pg) => { + const p = point('Cincinnati OH'); + await goto(pg, '?lat=' + p.lat + '&lon=' + p.lon); + await expectCommand(pg, p.expect); + const m = await pg.evaluate(() => ({ sw: document.documentElement.scrollWidth, vw: innerWidth, mapH: document.querySelector('#rd-map').getBoundingClientRect().height, + panelW: document.querySelector('.rd-panel').getBoundingClientRect().width, open: document.querySelector('.rd-details').open })); + ok(m.sw <= m.vw, 'horizontal overflow'); + ok(m.mapH >= 400, 'map height ' + m.mapH); + ok(m.panelW >= 300 && m.panelW <= 380, 'panel width ' + m.panelW); + eq(m.open, true, 'details open on desktop'); + }); + }); + + // ---------------------------------------------------------------- production-data smoke + await step('production smoke: deployed region data (if any) loads cleanly and every known point yields its exact command', async () => { + const probe = await ctx.request.get(BASE + '/region-layers/layers.json'); + let manifest = null; + if (probe.status() === 200) { try { manifest = JSON.parse(await probe.text()); } catch (_) { manifest = null; } } + if (!manifest || !Array.isArray(manifest.layers)) { console.log(' (no region data deployed at ' + BASE + '/region-layers/: skipped)'); return; } + // const manifest = await probe.json(); + const kp = await ctx.request.get(BASE + '/region-layers/known-points.json'); + ok(kp.status() === 200, 'region data is deployed but known-points.json is not served: add at least one point per mapped state and one on a shared border'); + const points = await kp.json(); + ok(Array.isArray(points) && points.length > 0, 'known-points.json is empty'); + const pg = await ctx.newPage(); + const errs = []; + pg.on('pageerror', (e) => errs.push(e.message)); + for (const p of points) { + await goto(pg, '?lat=' + p.lat + '&lon=' + p.lon); + await pg.reload(); + await pg.waitForSelector('.rd-toggle', { timeout: 20000 }); + eq((await pg.$$('.rd-toggle')).length, manifest.layers.length, 'layer toggles (' + p.name + ')'); + if (p.expect === '') { await pg.waitForSelector('.rd-none'); ok(!(await pg.$('.rd-cmd')), p.name + ': expected no command'); } + else { await pg.waitForSelector('.rd-cmd'); eq(await text(pg, '.rd-cmd'), cmd(p.expect), p.name); } + ok(!(await pg.$('.rd-incomplete')), p.name + ': deployed data produced an incomplete result: ' + ((await pg.$('.rd-incomplete')) ? await text(pg, '.rd-incomplete') : '')); + if (!p.allowWarnings) ok(!(await pg.$('.rd-warnings')), p.name + ': unexpected warnings: ' + ((await pg.$('.rd-warnings')) ? await text(pg, '.rd-warnings') : '')); + } + await pg.close(); + ok(errs.length === 0, 'uncaught page error: ' + errs[0]); + }); + + await browser.close(); + console.log('\n' + passed + ' passed, ' + failed + ' failed'); + process.exit(failed ? 1 : 0); +})().catch((e) => { console.error('test-region-def-e2e.js: crashed: ' + (e && e.stack || e)); process.exit(1); }); diff --git a/tests/fixtures/region-layers/known-points.json b/tests/fixtures/region-layers/known-points.json new file mode 100644 index 00000000..f1f817a3 --- /dev/null +++ b/tests/fixtures/region-layers/known-points.json @@ -0,0 +1,158 @@ +[ + { + "name": "Cincinnati OH", + "lat": 39.1031, + "lon": -84.512, + "expect": "us-midwest oki us-oh cvg" + }, + { + "name": "Columbus OH", + "lat": 39.9612, + "lon": -82.9988, + "expect": "us-midwest oki us-oh cmh" + }, + { + "name": "Dayton OH (no metro)", + "lat": 39.7589, + "lon": -84.1916, + "expect": "us-midwest oki us-oh" + }, + { + "name": "Cleveland OH", + "lat": 41.4993, + "lon": -81.6944, + "expect": "us-midwest oki us-oh" + }, + { + "name": "Indianapolis IN", + "lat": 39.7684, + "lon": -86.1581, + "expect": "us-midwest oki us-in ind" + }, + { + "name": "Fort Wayne IN", + "lat": 41.0793, + "lon": -85.1394, + "expect": "us-midwest oki us-in" + }, + { + "name": "Covington KY (CVG metro spans the river)", + "lat": 39.0837, + "lon": -84.5086, + "expect": "us-southeast oki us-ky cvg" + }, + { + "name": "Lexington KY", + "lat": 38.0406, + "lon": -84.5037, + "expect": "us-southeast oki us-ky" + }, + { + "name": "Louisville KY", + "lat": 38.2527, + "lon": -85.7585, + "expect": "us-southeast oki us-ky" + }, + { + "name": "Detroit MI", + "lat": 42.3314, + "lon": -83.0458, + "expect": "us-midwest us-mi" + }, + { + "name": "Chicago IL", + "lat": 41.8781, + "lon": -87.6298, + "expect": "us-midwest us-il" + }, + { + "name": "Charleston WV", + "lat": 38.3498, + "lon": -81.6326, + "expect": "us-southeast us-wv" + }, + { + "name": "Pittsburgh PA (no macro, no mesh)", + "lat": 40.4406, + "lon": -79.9959, + "expect": "us-pa" + }, + { + "name": "mid-Atlantic Ocean", + "lat": 35.0, + "lon": -70.0, + "expect": "" + }, + { + "name": "OH/IN line, Indiana side, lat 40.5", + "lat": 40.5, + "lon": -84.83, + "expect": "us-midwest oki us-in" + }, + { + "name": "OH/IN line, Ohio side, lat 40.5", + "lat": 40.5, + "lon": -84.81, + "expect": "us-midwest oki us-oh" + }, + { + "name": "OH/IN line, Indiana side, lat 41.2", + "lat": 41.2, + "lon": -84.83, + "expect": "us-midwest oki us-in" + }, + { + "name": "OH/IN line, Ohio side, lat 41.2", + "lat": 41.2, + "lon": -84.81, + "expect": "us-midwest oki us-oh" + }, + { + "name": "IN/IL line, Illinois side", + "lat": 40.0, + "lon": -87.54, + "expect": "us-midwest us-il" + }, + { + "name": "IN/IL line, Indiana side", + "lat": 40.0, + "lon": -87.52, + "expect": "us-midwest oki us-in" + }, + { + "name": "OH/PA line, Ohio side", + "lat": 40.95, + "lon": -80.53, + "expect": "us-midwest oki us-oh" + }, + { + "name": "OH/PA line, Pennsylvania side", + "lat": 40.95, + "lon": -80.51, + "expect": "us-pa" + }, + { + "name": "WV panhandle (east of the Ohio River edge)", + "lat": 40.3, + "lon": -80.55, + "expect": "us-southeast us-wv" + }, + { + "name": "Ohio just west of the panhandle", + "lat": 40.3, + "lon": -80.66, + "expect": "us-midwest oki us-oh" + }, + { + "name": "Ohio River, north bank", + "lat": 39.11, + "lon": -84.51, + "expect": "us-midwest oki us-oh cvg" + }, + { + "name": "Ohio River, south bank", + "lat": 39.08, + "lon": -84.51, + "expect": "us-southeast oki us-ky cvg" + } +] \ No newline at end of file diff --git a/tests/fixtures/region-layers/layers.json b/tests/fixtures/region-layers/layers.json new file mode 100644 index 00000000..d22feb41 --- /dev/null +++ b/tests/fixtures/region-layers/layers.json @@ -0,0 +1,60 @@ +{ + "version": 1, + "layers": [ + { + "id": "macro", + "label": "Macro region", + "url": [ + { + "url": "macro-midwest.geojson", + "name": "US Midwest", + "code": "us-midwest" + }, + { + "url": "macro-southeast.geojson", + "name": "US Southeast", + "code": "us-southeast" + } + ], + "codes": [ + "us-midwest", + "us-southeast" + ] + }, + { + "id": "mesh", + "label": "Mesh region", + "url": "states.geojson", + "groupBy": "meshregion", + "codes": [ + "oki" + ] + }, + { + "id": "state", + "label": "State", + "url": "states.geojson", + "nameProperty": "shapeISO", + "codes": [ + "us-oh", + "us-in", + "us-ky", + "us-mi", + "us-il", + "us-pa", + "us-wv" + ] + }, + { + "id": "metro", + "label": "Metro", + "url": "metros.geojson", + "nameProperty": "iata", + "codes": [ + "cvg", + "ind", + "cmh" + ] + } + ] +} diff --git a/tests/fixtures/region-layers/macro-midwest.geojson b/tests/fixtures/region-layers/macro-midwest.geojson new file mode 100644 index 00000000..5408f725 --- /dev/null +++ b/tests/fixtures/region-layers/macro-midwest.geojson @@ -0,0 +1,6 @@ +{"type":"FeatureCollection","features":[ +{"type":"Feature","properties":{"shapeName":"Ohio"},"geometry":{"type":"Polygon","coordinates":[[[-84.82,41.7],[-83.45,41.73],[-82.7,41.45],[-82,41.52],[-81,41.85],[-80.52,41.98],[-80.52,40.64],[-80.72,40.07],[-81.46,39.42],[-81.56,39.27],[-82.2,38.81],[-82.6,38.4],[-83,38.73],[-83.75,38.65],[-84.3,39.01],[-84.51,39.095],[-84.7,39.08],[-84.82,39.1],[-84.82,41.7]]]}}, +{"type":"Feature","properties":{"shapeName":"Indiana"},"geometry":{"type":"Polygon","coordinates":[[[-84.82,41.7],[-87.53,41.7],[-87.53,39.35],[-88.05,37.8],[-87.5,37.92],[-86.8,37.98],[-86.3,38.15],[-85.9,38.26],[-85.4,38.72],[-85,38.78],[-84.82,39.1],[-84.82,41.7]]]}}, +{"type":"Feature","properties":{"shapeName":"Michigan"},"geometry":{"type":"Polygon","coordinates":[[[-86.82,41.7],[-84.82,41.7],[-83.45,41.73],[-82.5,42],[-82.45,43],[-83.3,44],[-83.4,45.3],[-84.7,45.8],[-85.6,45],[-86.2,44],[-86.5,43],[-86.4,42.2],[-86.82,41.7]]]}}, +{"type":"Feature","properties":{"shapeName":"Illinois"},"geometry":{"type":"Polygon","coordinates":[[[-87.53,42.5],[-90.64,42.5],[-91,41.2],[-91.4,40.4],[-90.2,38.8],[-89.1,37],[-88.05,37.8],[-87.53,39.35],[-87.53,42.5]]]}} +]} diff --git a/tests/fixtures/region-layers/macro-southeast.geojson b/tests/fixtures/region-layers/macro-southeast.geojson new file mode 100644 index 00000000..d46d062b --- /dev/null +++ b/tests/fixtures/region-layers/macro-southeast.geojson @@ -0,0 +1,4 @@ +{"type":"FeatureCollection","features":[ +{"type":"Feature","properties":{"shapeName":"Kentucky"},"geometry":{"type":"Polygon","coordinates":[[[-89.5,36.5],[-89.1,37],[-88.05,37.8],[-87.5,37.92],[-86.8,37.98],[-86.3,38.15],[-85.9,38.26],[-85.4,38.72],[-85,38.78],[-84.82,39.1],[-84.7,39.08],[-84.51,39.095],[-84.3,39.01],[-83.75,38.65],[-83,38.73],[-82.6,38.4],[-81.97,37.54],[-83.68,36.6],[-89.5,36.5]]]}}, +{"type":"Feature","properties":{"shapeName":"West Virginia"},"geometry":{"type":"Polygon","coordinates":[[[-80.52,40.64],[-80.72,40.07],[-81.46,39.42],[-81.56,39.27],[-82.2,38.81],[-82.6,38.4],[-81.97,37.54],[-81,37.2],[-80,37.5],[-79.5,38.4],[-78.3,39.3],[-77.7,39.3],[-77.8,39.6],[-79.48,39.72],[-80.52,39.72],[-80.52,40.64]]]}} +]} diff --git a/tests/fixtures/region-layers/metros.geojson b/tests/fixtures/region-layers/metros.geojson new file mode 100644 index 00000000..c83ea2b8 --- /dev/null +++ b/tests/fixtures/region-layers/metros.geojson @@ -0,0 +1,5 @@ +{"type":"FeatureCollection","features":[ +{"type":"Feature","properties":{"iata":"CVG"},"geometry":{"type":"Polygon","coordinates":[[[-84.95,38.85],[-84.15,38.85],[-84.15,39.4],[-84.95,39.4],[-84.95,38.85]]]}}, +{"type":"Feature","properties":{"iata":"IND"},"geometry":{"type":"Polygon","coordinates":[[[-86.45,39.5],[-85.85,39.5],[-85.85,40.05],[-86.45,40.05],[-86.45,39.5]]]}}, +{"type":"Feature","properties":{"iata":"CMH"},"geometry":{"type":"Polygon","coordinates":[[[-83.35,39.75],[-82.65,39.75],[-82.65,40.2],[-83.35,40.2],[-83.35,39.75]]]}} +]} diff --git a/tests/fixtures/region-layers/states.geojson b/tests/fixtures/region-layers/states.geojson new file mode 100644 index 00000000..6b788df5 --- /dev/null +++ b/tests/fixtures/region-layers/states.geojson @@ -0,0 +1,9 @@ +{"type":"FeatureCollection","features":[ +{"type":"Feature","properties":{"shapeName":"Ohio","shapeISO":"US-OH","meshregion":"OKI"},"geometry":{"type":"Polygon","coordinates":[[[-84.82,41.7],[-83.45,41.73],[-82.7,41.45],[-82,41.52],[-81,41.85],[-80.52,41.98],[-80.52,40.64],[-80.72,40.07],[-81.46,39.42],[-81.56,39.27],[-82.2,38.81],[-82.6,38.4],[-83,38.73],[-83.75,38.65],[-84.3,39.01],[-84.51,39.095],[-84.7,39.08],[-84.82,39.1],[-84.82,41.7]]]}}, +{"type":"Feature","properties":{"shapeName":"Indiana","shapeISO":"US-IN","meshregion":"OKI"},"geometry":{"type":"Polygon","coordinates":[[[-84.82,41.7],[-87.53,41.7],[-87.53,39.35],[-88.05,37.8],[-87.5,37.92],[-86.8,37.98],[-86.3,38.15],[-85.9,38.26],[-85.4,38.72],[-85,38.78],[-84.82,39.1],[-84.82,41.7]]]}}, +{"type":"Feature","properties":{"shapeName":"Kentucky","shapeISO":"US-KY","meshregion":"OKI"},"geometry":{"type":"Polygon","coordinates":[[[-89.5,36.5],[-89.1,37],[-88.05,37.8],[-87.5,37.92],[-86.8,37.98],[-86.3,38.15],[-85.9,38.26],[-85.4,38.72],[-85,38.78],[-84.82,39.1],[-84.7,39.08],[-84.51,39.095],[-84.3,39.01],[-83.75,38.65],[-83,38.73],[-82.6,38.4],[-81.97,37.54],[-83.68,36.6],[-89.5,36.5]]]}}, +{"type":"Feature","properties":{"shapeName":"Michigan","shapeISO":"US-MI"},"geometry":{"type":"Polygon","coordinates":[[[-86.82,41.7],[-84.82,41.7],[-83.45,41.73],[-82.5,42],[-82.45,43],[-83.3,44],[-83.4,45.3],[-84.7,45.8],[-85.6,45],[-86.2,44],[-86.5,43],[-86.4,42.2],[-86.82,41.7]]]}}, +{"type":"Feature","properties":{"shapeName":"Illinois","shapeISO":"US-IL"},"geometry":{"type":"Polygon","coordinates":[[[-87.53,42.5],[-90.64,42.5],[-91,41.2],[-91.4,40.4],[-90.2,38.8],[-89.1,37],[-88.05,37.8],[-87.53,39.35],[-87.53,42.5]]]}}, +{"type":"Feature","properties":{"shapeName":"Pennsylvania","shapeISO":"US-PA"},"geometry":{"type":"Polygon","coordinates":[[[-80.52,41.98],[-79.76,42.27],[-79.76,42],[-75.35,42],[-74.7,41.35],[-75,41],[-74.7,40.15],[-75.1,39.85],[-75.8,39.72],[-80.52,39.72],[-80.52,41.98]]]}}, +{"type":"Feature","properties":{"shapeName":"West Virginia","shapeISO":"US-WV"},"geometry":{"type":"Polygon","coordinates":[[[-80.52,40.64],[-80.72,40.07],[-81.46,39.42],[-81.56,39.27],[-82.2,38.81],[-82.6,38.4],[-81.97,37.54],[-81,37.2],[-80,37.5],[-79.5,38.4],[-78.3,39.3],[-77.7,39.3],[-77.8,39.6],[-79.48,39.72],[-80.52,39.72],[-80.52,40.64]]]}} +]} diff --git a/tests/unit/test-check-region-data.js b/tests/unit/test-check-region-data.js new file mode 100644 index 00000000..5de2503f --- /dev/null +++ b/tests/unit/test-check-region-data.js @@ -0,0 +1,182 @@ +/** + * Tests for scripts/check-region-data.js: the integrity check must catch each class of bad + * region data (and exit nonzero), and must not cry wolf on legitimate data. + */ +'use strict'; +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const assert = require('assert'); +const { spawnSync } = require('child_process'); +const { checkRegionData } = require('../../scripts/check-region-data.js'); + +const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'check-region-data.js'); +const FIXTURE = path.join(__dirname, '..', 'fixtures', 'region-layers'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(' ok ' + name); } + catch (e) { failed++; console.error(' FAIL ' + name + ': ' + e.message); } +} + +function tmpCopy() { + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'rd-check-')); + fs.readdirSync(FIXTURE).forEach((f) => { if (f !== 'README.md') fs.copyFileSync(path.join(FIXTURE, f), path.join(d, f)); }); + return d; +} +const rd = (d, f) => JSON.parse(fs.readFileSync(path.join(d, f), 'utf8')); +const wr = (d, f, o) => fs.writeFileSync(path.join(d, f), typeof o === 'string' ? o : JSON.stringify(o)); +const box = (x0, y0, x1, y1) => [[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]; +const metro = (iata, ring) => ({ type: 'Feature', properties: { iata: iata }, geometry: { type: 'Polygon', coordinates: [ring] } }); +function mutate(fn) { const d = tmpCopy(); fn(d); const r = checkRegionData(d); fs.rmSync(d, { recursive: true, force: true }); return r; } +const has = (res, re) => res.errors.some((e) => re.test(e)); + +test('the fixture is clean (no errors, no warnings)', () => { + const r = checkRegionData(FIXTURE); + assert.deepStrictEqual(r.errors, []); + assert.deepStrictEqual(r.warnings, []); + assert.ok(r.known.length >= 25); +}); + +test('no layers.json: nothing to check, not an error', () => { + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'rd-empty-')); + const r = checkRegionData(d); + assert.strictEqual(r.checked, false); + assert.deepStrictEqual(r.errors, []); + assert.strictEqual(spawnSync('node', [SCRIPT, '--dir', d]).status, 0); +}); + +test('malformed GeoJSON file (invalid JSON) is reported with the file name', () => { + const r = mutate((d) => wr(d, 'metros.geojson', '{"type":"FeatureCollection","features":[{oops')); + assert.ok(has(r, /metros\.geojson: invalid JSON/), r.errors.join('\n')); +}); + +test('missing boundary file is reported', () => { + const r = mutate((d) => fs.unlinkSync(path.join(d, 'metros.geojson'))); + assert.ok(has(r, /metros\.geojson: cannot read file/), r.errors.join('\n')); +}); + +test('valid JSON that is not polygon GeoJSON is reported', () => { + const r = mutate((d) => wr(d, 'metros.geojson', { hello: 'world' })); + assert.ok(has(r, /no Polygon\/MultiPolygon features/), r.errors.join('\n')); +}); + +test('swapped [lat, lon] coordinates: caught by range when |lon| > 90, otherwise by the known points', () => { + // San Francisco-ish, swapped: "latitude" -122 is impossible + const west = mutate((d) => wr(d, 'metros.geojson', { type: 'FeatureCollection', features: [metro('CVG', box(37.5, -122.6, 37.9, -122.2))] })); + assert.ok(has(west, /latitude -122\.?\d* out of range \(coordinates must be \[lon, lat\]/), west.errors.join('\n')); + // Cincinnati-ish, swapped: (-84.9) is a legal latitude, so range checks cannot see it; known-points.json does + const swap = (f) => f.map((q) => ({ type: 'Feature', properties: q.properties, geometry: { type: 'Polygon', coordinates: [q.geometry.coordinates[0].map((c) => [c[1], c[0]])] } })); + const mid = mutate((d) => { const m = rd(d, 'metros.geojson'); m.features = swap(m.features); wr(d, 'metros.geojson', m); }); + assert.ok(has(mid, /known point "Cincinnati OH": expected "us-midwest oki us-oh cvg" but got "us-midwest oki us-oh"/), mid.errors.join('\n')); +}); + +test('unclosed ring and too-short ring are caught', () => { + let r = mutate((d) => wr(d, 'metros.geojson', { type: 'FeatureCollection', features: [metro('CVG', box(-85, 38, -84, 39).slice(0, 4))] })); + assert.ok(has(r, /not closed/), r.errors.join('\n')); + r = mutate((d) => wr(d, 'metros.geojson', { type: 'FeatureCollection', features: [metro('CVG', [[0, 0], [1, 1], [0, 0]])] })); + assert.ok(has(r, /fewer than 4 positions/), r.errors.join('\n')); +}); + +test('duplicate code inside a layer is caught', () => { + const r = mutate((d) => { const m = rd(d, 'metros.geojson'); m.features.push(metro('CVG', box(-80, 35, -79, 36))); wr(d, 'metros.geojson', m); }); + assert.ok(has(r, /code "cvg" is produced by more than one polygon/), r.errors.join('\n')); +}); + +test('overlapping polygons in a layer are caught; allowOverlap opts out', () => { + const addOverlap = (d, allow) => { + const m = rd(d, 'metros.geojson'); m.features.push(metro('LUK', box(-84.8, 38.95, -84.3, 39.25))); wr(d, 'metros.geojson', m); + const l = rd(d, 'layers.json'); l.layers[3].codes.push('luk'); if (allow) l.layers[3].allowOverlap = true; wr(d, 'layers.json', l); + }; + const r = mutate((d) => addOverlap(d, false)); + assert.ok(has(r, /"cvg" and "luk" overlap/), r.errors.join('\n')); + const r2 = mutate((d) => addOverlap(d, true)); + assert.deepStrictEqual(r2.errors.filter((e) => /overlap \(up to/.test(e)), [], 'allowOverlap must silence the static overlap check'); + // at runtime the overlap is still visible: known points inside it warn (first match wins) + assert.ok(r2.errors.some((e) => /known point .*overlapping regions \(cvg, luk\)/.test(e))); +}); + +test('polygons that only share an edge, or a sliver within tolerance, are NOT overlaps', () => { + const r = mutate((d) => { + const m = rd(d, 'metros.geojson'); + m.features.push(metro('LUK', box(-84.15, 38.85, -83.9, 39.40))); // shares CVG's east edge exactly + m.features.push(metro('DAY', box(-84.9501, 40.0, -84.5, 40.3))); // far away + m.features.push(metro('SLV', box(-84.15, 39.40, -84.1, 39.5))); // touches at a corner + wr(d, 'metros.geojson', m); + const l = rd(d, 'layers.json'); l.layers[3].codes.push('luk', 'day', 'slv'); wr(d, 'layers.json', l); + }); + assert.deepStrictEqual(r.errors.filter((e) => /overlap \(up to/.test(e)), []); + const tiny = mutate((d) => { // 0.0002 deg (~20 m) sliver into CVG + const m = rd(d, 'metros.geojson'); m.features.push(metro('LUK', box(-84.1502, 38.85, -83.9, 39.40))); wr(d, 'metros.geojson', m); + const l = rd(d, 'layers.json'); l.layers[3].codes.push('luk'); wr(d, 'layers.json', l); + }); + assert.deepStrictEqual(tiny.errors.filter((e) => /overlap \(up to/.test(e)), []); +}); + +test('invalid canonical code (uppercase from codeProperty) is caught', () => { + const r = mutate((d) => { const l = rd(d, 'layers.json'); delete l.layers[2].nameProperty; l.layers[2].codeProperty = 'shapeISO'; wr(d, 'layers.json', l); }); + assert.ok(has(r, /cannot produce a valid canonical code/), r.errors.join('\n')); +}); + +test('declared canonical code with no polygon (a missing region) is caught', () => { + const r = mutate((d) => { const s = rd(d, 'states.geojson'); s.features = s.features.filter((f) => f.properties.shapeISO !== 'US-MI'); wr(d, 'states.geojson', s); }); + assert.ok(has(r, /canonical code "us-mi" is declared but no polygon produces it/), r.errors.join('\n')); +}); + +test('a typo in the data (code outside the declared list) is caught', () => { + const r = mutate((d) => { const s = rd(d, 'states.geojson'); s.features[0].properties.meshregion = 'OKl'; wr(d, 'states.geojson', s); }); + assert.ok(has(r, /cannot produce a valid canonical code/), r.errors.join('\n')); +}); + +test('known-point mismatch is caught, naming the point, expected and actual', () => { + const r = mutate((d) => { const k = rd(d, 'known-points.json'); k[0].expect = 'us-midwest oki us-oh'; wr(d, 'known-points.json', k); }); + assert.ok(has(r, /known point "Cincinnati OH": expected "us-midwest oki us-oh" but got "us-midwest oki us-oh cvg"/), r.errors.join('\n')); +}); + +test('manifest problems surface as errors: unsupported version, missing version, unsafe path', () => { + assert.ok(has(mutate((d) => { const l = rd(d, 'layers.json'); l.version = 9; wr(d, 'layers.json', l); }), /newer than this page supports/)); + assert.ok(has(mutate((d) => { const l = rd(d, 'layers.json'); delete l.version; wr(d, 'layers.json', l); }), /set "version"/)); + assert.ok(has(mutate((d) => { const l = rd(d, 'layers.json'); l.layers[3].url = '../metros.geojson'; wr(d, 'layers.json', l); }), /unsafe or invalid file path/)); + assert.ok(has(mutate((d) => wr(d, 'layers.json', '{ nope')), /layers\.json: invalid JSON/)); +}); + +test('the same code in two layers is a warning, and --strict turns it into a failure', () => { + const d = tmpCopy(); + const l = rd(d, 'layers.json'); + l.layers.push({ id: 'dup', label: 'Dup', url: 'metros.geojson', nameProperty: 'iata' }); + wr(d, 'layers.json', l); + fs.unlinkSync(path.join(d, 'known-points.json')); // the duplicate layer legitimately changes the command + const r = checkRegionData(d); + assert.ok(r.warnings.some((w) => /code "cvg" is used by both layer "metro" and "dup"/.test(w)), r.warnings.join('\n')); + assert.strictEqual(spawnSync('node', [SCRIPT, '--dir', d]).status, 0); + assert.strictEqual(spawnSync('node', [SCRIPT, '--dir', d, '--strict']).status, 1); + fs.rmSync(d, { recursive: true, force: true }); +}); + +test('CLI exit codes: 0 on the fixture, 1 on broken data; --point prints the repeater block', () => { + assert.strictEqual(spawnSync('node', [SCRIPT, '--dir', FIXTURE]).status, 0); + const d = tmpCopy(); wr(d, 'metros.geojson', '{ broken'); + const bad = spawnSync('node', [SCRIPT, '--dir', d]); + assert.strictEqual(bad.status, 1); + assert.ok(/ERROR .*invalid JSON/.test(bad.stdout.toString())); + fs.rmSync(d, { recursive: true, force: true }); + const p = spawnSync('node', [SCRIPT, '--dir', FIXTURE, '--point', '39.1031,-84.5120']); + assert.ok(/region def us-midwest oki us-oh cvg\n\s+region allowf \*\n\s+region save/.test(p.stdout.toString()), p.stdout.toString()); +}); + +test('--draft-known-points prints the rows with expect set to what the data produces, and says what changed', () => { + const dir = tmpCopy(); + const rows = JSON.parse(fs.readFileSync(path.join(dir, 'known-points.json'), 'utf8')); + rows[0].expect = 'wrong value'; + fs.writeFileSync(path.join(dir, 'known-points.json'), JSON.stringify(rows)); + const p = spawnSync('node', [SCRIPT, '--dir', dir, '--draft-known-points'], { encoding: 'utf8' }); + assert.strictEqual(p.status, 0, p.stderr); + const draft = JSON.parse(p.stdout); + assert.strictEqual(draft.length, rows.length); + assert.strictEqual(draft[0].expect, 'us-midwest oki us-oh cvg'); + assert.ok(/changed "Cincinnati OH": was "wrong value"/.test(p.stderr), p.stderr); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +console.log('\n' + passed + ' passed, ' + failed + ' failed'); +if (failed) process.exit(1); diff --git a/tests/unit/test-frontend-helpers.js b/tests/unit/test-frontend-helpers.js index d409dfbd..00812d53 100644 --- a/tests/unit/test-frontend-helpers.js +++ b/tests/unit/test-frontend-helpers.js @@ -2244,33 +2244,6 @@ console.log('\n=== analytics.js: sortChannels ==='); assert.ok(chatIdx < generalIdx, 'Chat (100 msgs) should come before General (50)'); }); - // #190: server sends the hash byte as a decimal string; the table must show hex. - test('tbodyHtml renders decimal-string hash as hex (#190)', () => { - const data = [ - { name: 'Public', hash: '17', messages: 3, senders: 1, lastActivity: '', encrypted: false }, - { name: 'Wardriving', hash: '129', messages: 2, senders: 1, lastActivity: '', encrypted: false }, - { name: 'Low', hash: '5', messages: 1, senders: 1, lastActivity: '', encrypted: false }, - ]; - const html = tbodyHtml(data, 'messages', 'desc'); - assert.ok(html.includes('>0x11<'), 'hash "17" should render as 0x11'); - assert.ok(html.includes('>0x81<'), 'hash "129" should render as 0x81'); - assert.ok(html.includes('>0x05<'), 'hash "5" should be zero-padded to 0x05'); - assert.ok(!html.includes('>17<') && !html.includes('>129<'), 'decimal must not be displayed'); - assert.ok(html.includes('?ch=17"'), 'deep link keeps the decimal value'); - }); - - test('tbodyHtml leaves non-numeric hash ids untouched and escaped (#190)', () => { - const html = tbodyHtml([{ name: 'X', hash: 'enc_A1B2', messages: 1, senders: 1, lastActivity: '', encrypted: true }], 'messages', 'desc'); - assert.ok(html.includes('>enc_A1B2<')); - const evil = tbodyHtml([{ name: 'X', hash: '', messages: 1, senders: 1, lastActivity: '', encrypted: false }], 'messages', 'desc'); - assert.ok(!evil.includes(''), 'hash must be HTML-escaped'); - }); - - test('sort by hash is numeric for decimal-string hashes (#190)', () => { - const data = [{ hash: '129' }, { hash: '17' }, { hash: '5' }]; - assert.deepStrictEqual(sortChannels(data, 'hash', 'asc').map(c => c.hash), ['5', '17', '129']); - }); - test('sort by string hash values', () => { const data = [ { name: 'A', hash: 'zz', messages: 1, senders: 1, lastActivity: '', encrypted: false }, diff --git a/tests/unit/test-region-def-known-points.js b/tests/unit/test-region-def-known-points.js new file mode 100644 index 00000000..e3418b2f --- /dev/null +++ b/tests/unit/test-region-def-known-points.js @@ -0,0 +1,124 @@ +/** + * Known-point regression tests for the Region Def lookup, driven by files on disk. + * + * 1. tests/fixtures/region-layers/ (always): a coarse synthetic deployment (see its README) with + * a table of real cities and of points just either side of shared borders. This guards the + * lookup, grouping, ordering and exclusion logic. + * 2. public/region-layers/ (when layers.json is committed): the deployed manifest and + * boundary files, checked by the same integrity routine plus the operator's own + * known-points.json. That is the test that verifies the files actually shipped. + */ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); +const R = require(path.join(__dirname, '..', '..', 'public', 'region-def-core.js')); +const { checkRegionData } = require(path.join(__dirname, '..', '..', 'scripts', 'check-region-data.js')); + +const ROOT = path.join(__dirname, '..', '..'); +const FIXTURE = path.join(ROOT, 'tests', 'fixtures', 'region-layers'); +const PROD = path.join(ROOT, 'public', 'region-layers'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(' ok ' + name); } + catch (e) { failed++; console.error(' FAIL ' + name + ': ' + e.message); } +} + +function suite(label, dir) { + const res = checkRegionData(dir); + test(label + ': manifest and boundary files pass the integrity check', () => { + assert.deepStrictEqual(res.errors, []); + assert.deepStrictEqual(res.warnings, []); + }); + const layers = res.layers.map((x) => x.layer); + const kpFile = path.join(dir, 'known-points.json'); + const points = fs.existsSync(kpFile) ? JSON.parse(fs.readFileSync(kpFile, 'utf8')) : []; + points.forEach((p) => { + test(label + ': ' + p.name + ' -> "' + p.expect + '"', () => { + const r = R.buildRegionDef(layers, p.lat, p.lon); + assert.strictEqual(r.value, p.expect); + if (!p.allowWarnings) assert.deepStrictEqual(r.warnings, []); + assert.ok(!r.perLayer.some((x) => x.unnamed || x.gap)); + }); + }); + test(label + ': every code any polygon can produce is a valid repeater identifier', () => { + res.layers.forEach(({ def, layer }) => layer.features.forEach((e) => { + assert.ok(e.code && R.CODE_RE.test(e.code), def.id + ': ' + JSON.stringify(e.name) + ' -> ' + e.code); + })); + }); + return { res: res, layers: layers, points: points }; +} + +// ---- 1. fixture ------------------------------------------------------------------------------ +const fx = suite('fixture', FIXTURE); +const lookup = (lat, lon) => R.buildRegionDef(fx.layers, lat, lon); + +test('fixture: table covers Ohio, Indiana, Kentucky, non-mesh states, borders and an empty result', () => { + const names = fx.points.map((p) => p.name).join(' | '); + ['Cincinnati OH', 'Indianapolis IN', 'Covington KY', 'Detroit MI', 'Chicago IL', 'Charleston WV', 'Pittsburgh PA', 'OH/IN line', 'IN/IL line', 'OH/PA line', 'Ohio River'] + .forEach((n) => assert.ok(names.indexOf(n) >= 0, 'missing ' + n)); + assert.ok(fx.points.some((p) => p.expect === ''), 'needs an outside-everything point'); + assert.ok(fx.points.length >= 25); +}); + +test('Indiana regression: an Indiana point yields the macro region AND the state (not the macro region alone)', () => { + const t = lookup(39.7684, -86.1581).tokens; + assert.ok(t.indexOf('us-midwest') === 0 && t.indexOf('us-in') > 0, t.join(' ')); +}); + +test('mesh states always carry oki between macro and state; non-mesh states never do', () => { + fx.points.forEach((p) => { + const t = lookup(p.lat, p.lon).tokens; + const isMesh = ['us-oh', 'us-in', 'us-ky'].some((s) => t.indexOf(s) >= 0); + if (isMesh) assert.strictEqual(t[1], 'oki', p.name + ': ' + t.join(' ')); + else assert.ok(t.indexOf('oki') < 0, p.name + ': ' + t.join(' ')); + }); +}); + +test('output order is always macro, mesh, state, metro', () => { + const rank = (c) => (c.indexOf('us-midwest') === 0 || c === 'us-southeast' ? 0 : c === 'oki' ? 1 : /^(cvg|cmh|ind)$/.test(c) ? 3 : 2); + fx.points.forEach((p) => { + const ranks = lookup(p.lat, p.lon).tokens.map(rank); + assert.deepStrictEqual(ranks, ranks.slice().sort((a, b) => a - b), p.name); + }); +}); + +test('a point exactly on the Ohio/Indiana border is deterministic: first state in the file wins, with a warning', () => { + const r = lookup(40.0, -84.82); + assert.strictEqual(r.perLayer[2].match.code, 'us-oh'); + assert.ok(r.warnings.some((w) => /overlapping regions \(us-oh, us-in\)/.test(w)), r.warnings.join(' | ')); + assert.strictEqual(r.perLayer[0].matches.length, 1, 'both states are in the same macro region: one match'); +}); + +test('every state has an interior point that resolves to its own state (no hole in the fixture)', () => { + const inside = { 'us-oh': [40.3, -82.8], 'us-in': [40.0, -86.2], 'us-ky': [37.5, -85.5], 'us-mi': [43.3, -84.5], 'us-il': [40.0, -89.0], + 'us-pa': [40.9, -77.5], 'us-wv': [38.6, -80.6] }; + Object.keys(inside).forEach((code) => { + const r = lookup(inside[code][0], inside[code][1]); + assert.strictEqual(r.perLayer[2].match && r.perLayer[2].match.code, code); + }); +}); + +test('sweeping a line across the Ohio/Indiana border flips state exactly once', () => { + let flips = 0, prev = null; + for (let lon = -85.2; lon <= -84.4; lon += 0.01) { + const code = lookup(40.5, Math.round(lon * 100) / 100).perLayer[2].match.code; + if (prev && code !== prev) flips++; + prev = code; + } + assert.strictEqual(flips, 1); +}); + +// ---- 2. the deployed files, when committed -------------------------------------------------------- +if (!fs.existsSync(path.join(PROD, 'layers.json'))) { + console.log(' skip deployed-data checks (public/region-layers/layers.json not committed)'); +} else { + suite('deployed', PROD); + if (!fs.existsSync(path.join(PROD, 'known-points.json'))) { + test('deployed: known-points.json exists (add at least one point per mapped state and one on a shared border)', () => assert.fail('missing public/region-layers/known-points.json')); + } +} + +console.log('\n' + passed + ' passed, ' + failed + ' failed'); +if (failed) process.exit(1); diff --git a/tests/unit/test-region-def.js b/tests/unit/test-region-def.js new file mode 100644 index 00000000..43558f77 --- /dev/null +++ b/tests/unit/test-region-def.js @@ -0,0 +1,455 @@ +/** + * Unit tests for public/region-def-core.js (Region Def tool) plus static wiring + * checks for the page. Pure Node: no jsdom, no network, no browser. + */ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +const ROOT = path.join(__dirname, '..', '..'); +const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8'); +const R = require(path.join(ROOT, 'public', 'region-def-core.js')); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(' ok ' + name); } + catch (e) { failed++; console.error(' FAIL ' + name + ': ' + e.message); } +} + +// square(lon0, lat0, lon1, lat1) -> closed ring +const sq = (x0, y0, x1, y1) => [[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]; +const poly = (props, ...rings) => ({ type: 'Feature', properties: props, geometry: { type: 'Polygon', coordinates: rings } }); +const fc = (...features) => ({ type: 'FeatureCollection', features }); + +// ---- point in polygon ------------------------------------------------------- +test('inside / outside simple square', () => { + const g = poly({}, sq(0, 0, 10, 10)).geometry; + assert.strictEqual(R.geometryContains(g, 5, 5), true); + assert.strictEqual(R.geometryContains(g, 11, 5), false); + assert.strictEqual(R.geometryContains(g, 5, -0.001), false); +}); + +test('lon/lat order: [lon, lat] (lat 39, lon -84 is Cincinnati-ish)', () => { + const g = poly({}, sq(-85, 38, -83, 40)).geometry; + assert.strictEqual(R.geometryContains(g, -84, 39), true); + assert.strictEqual(R.geometryContains(g, 39, -84), false); // swapped args must miss +}); + +test('boundary counts as inside (edge and vertex)', () => { + const g = poly({}, sq(0, 0, 10, 10)).geometry; + assert.strictEqual(R.geometryContains(g, 0, 5), true); + assert.strictEqual(R.geometryContains(g, 10, 10), true); + assert.strictEqual(R.geometryContains(g, 5, 10), true); +}); + +test('hole is outside, hole edge is outside, ring between is inside', () => { + const g = poly({}, sq(0, 0, 10, 10), sq(4, 4, 6, 6)).geometry; + assert.strictEqual(R.geometryContains(g, 5, 5), false); + assert.strictEqual(R.geometryContains(g, 4, 5), false); + assert.strictEqual(R.geometryContains(g, 2, 2), true); +}); + +test('MultiPolygon: either part matches', () => { + const g = { type: 'MultiPolygon', coordinates: [[sq(0, 0, 1, 1)], [sq(10, 10, 11, 11)]] }; + assert.strictEqual(R.geometryContains(g, 0.5, 0.5), true); + assert.strictEqual(R.geometryContains(g, 10.5, 10.5), true); + assert.strictEqual(R.geometryContains(g, 5, 5), false); +}); + +test('bbox covers all parts', () => { + const g = { type: 'MultiPolygon', coordinates: [[sq(0, 0, 1, 1)], [sq(10, 20, 11, 21)]] }; + assert.deepStrictEqual(R.geometryBBox(g), [0, 0, 11, 21]); +}); + +// ---- normalize -------------------------------------------------------------- +test('normalizeGeoJSON: FeatureCollection, Feature, bare geometry; drops points/garbage', () => { + const p = poly({ name: 'A' }, sq(0, 0, 1, 1)); + assert.strictEqual(R.normalizeGeoJSON(fc(p, { type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [0, 0] } })).length, 1); + assert.strictEqual(R.normalizeGeoJSON(p).length, 1); + assert.strictEqual(R.normalizeGeoJSON(p.geometry).length, 1); + assert.strictEqual(R.normalizeGeoJSON(null).length, 0); + assert.strictEqual(R.normalizeGeoJSON('x').length, 0); + assert.strictEqual(R.normalizeGeoJSON({ type: 'FeatureCollection' }).length, 0); +}); + +// ---- slug ------------------------------------------------------------------- +test('slug', () => { + assert.strictEqual(R.slug(' US Midwest '), 'us-midwest'); + assert.strictEqual(R.slug('St. Louis!'), 'st-louis'); + assert.strictEqual(R.slug('CVG'), 'cvg'); + assert.strictEqual(R.slug(null), ''); + assert.strictEqual(R.slug(42), '42'); + assert.strictEqual(R.slug('a_b-c'), 'a_b-c'); +}); + +// ---- grouping / merging ----------------------------------------------------- +test('groupBy merges features sharing a property and drops those without it', () => { + const feats = R.normalizeGeoJSON(fc( + poly({ shapeName: 'Indiana', mesh: 'oki' }, sq(0, 0, 1, 1)), + poly({ shapeName: 'Ohio', mesh: 'oki' }, sq(1, 0, 2, 1)), + poly({ shapeName: 'Texas', mesh: 'tx' }, sq(5, 5, 6, 6)), + poly({ shapeName: 'Nowhere' }, sq(8, 8, 9, 9)) + )); + const g = R.groupFeatures(feats, 'mesh'); + assert.deepStrictEqual(g.map(f => f.properties.__name), ['oki', 'tx']); + assert.strictEqual(g[0].geometry.type, 'MultiPolygon'); + assert.strictEqual(g[0].geometry.coordinates.length, 2); +}); + +test('buildLayer: per-file name merges the whole file into one region', () => { + const layer = R.buildLayer({ id: 'macro', label: 'Macro', url: ['a', 'b'] }, [ + { name: 'US-Midwest', data: fc(poly({ x: 1 }, sq(0, 0, 1, 1)), poly({ x: 2 }, sq(1, 0, 2, 1))) }, + { name: 'US-Southeast', data: fc(poly({}, sq(0, -5, 2, -1))) } + ]); + assert.strictEqual(layer.features.length, 2); + assert.strictEqual(R.slug(layer.features[0].name), 'us-midwest'); + assert.strictEqual(R.matchLayer(layer, 0.5, 0.5).matches[0].code, 'us-midwest'); + assert.strictEqual(R.matchLayer(layer, 0.5, 1.5).matches[0].code, 'us-midwest'); + assert.strictEqual(R.matchLayer(layer, -3, 1).matches[0].code, 'us-southeast'); +}); + +test('nameProperty array falls back to the first non-empty property', () => { + const layer = R.buildLayer({ id: 's', nameProperty: ['shapeName', 'name'] }, [ + { data: fc(poly({ shapeName: '', name: 'Ohio' }, sq(0, 0, 1, 1))) } + ]); + assert.strictEqual(R.matchLayer(layer, 0.5, 0.5).matches[0].code, 'ohio'); +}); + +// ---- region def ------------------------------------------------------------- +function layers() { + return [ + R.buildLayer({ id: 'macro', label: 'Macro region' }, [{ name: 'US-Midwest', data: fc(poly({}, sq(0, 0, 20, 20))) }]), + R.buildLayer({ id: 'mesh', label: 'Mesh region', nameProperty: 'name' }, [{ data: fc(poly({ name: 'OKI' }, sq(0, 0, 10, 10))) }]), + R.buildLayer({ id: 'state', label: 'State', nameProperty: 'name' }, [{ data: fc(poly({ name: 'Ohio' }, sq(0, 0, 5, 10)), poly({ name: 'Indiana' }, sq(5, 0, 10, 10))) }]), + R.buildLayer({ id: 'metro', label: 'Metro', nameProperty: 'iata' }, [{ data: fc(poly({ iata: 'CVG' }, sq(1, 1, 3, 3)), poly({ iata: 'DAY' }, sq(1, 6, 3, 8))) }]) + ]; +} + +test('regionDef: top-to-bottom order, one token per layer', () => { + const r = R.buildRegionDef(layers(), 2, 2); + assert.strictEqual(r.value, 'us-midwest oki ohio cvg'); + assert.deepStrictEqual(r.warnings, []); +}); + +test('regionDef: sibling metros do not collide; no metro match leaves the token out', () => { + assert.strictEqual(R.buildRegionDef(layers(), 7, 2).value, 'us-midwest oki ohio day'); + assert.strictEqual(R.buildRegionDef(layers(), 4, 4.5).value, 'us-midwest oki ohio'); + assert.strictEqual(R.buildRegionDef(layers(), 7, 8).value, 'us-midwest oki indiana'); +}); + +test('regionDef: outside everything is empty', () => { + const r = R.buildRegionDef(layers(), 50, 50); + assert.strictEqual(r.value, ''); + assert.strictEqual(R.repeaterCommands(r.value), ''); +}); + +test('regionDef: overlap uses first match and warns', () => { + const L = [R.buildLayer({ id: 'metro', label: 'Metro', nameProperty: 'iata' }, [ + { data: fc(poly({ iata: 'AAA' }, sq(0, 0, 5, 5)), poly({ iata: 'BBB' }, sq(3, 3, 8, 8))) }])]; + const r = R.buildRegionDef(L, 4, 4); + assert.strictEqual(r.value, 'aaa'); + assert.strictEqual(r.warnings.length, 1); + assert.ok(/aaa, bbb/.test(r.warnings[0])); +}); + +test('regionDef: unnamed polygon never produces a token, warns instead', () => { + const L = [R.buildLayer({ id: 'm', label: 'Metro', nameProperty: 'iata' }, [{ data: fc(poly({}, sq(0, 0, 5, 5))) }])]; + const r = R.buildRegionDef(L, 2, 2); + assert.strictEqual(r.value, ''); + assert.strictEqual(r.warnings.length, 1); +}); + +test('repeaterCommands: exact three-line block, region save last (allowf does not persist on its own)', () => { + assert.strictEqual(R.repeaterCommands('us-midwest oki ohio cvg'), + 'region def us-midwest oki ohio cvg\nregion allowf *\nregion save'); +}); + +// ---- firmware limits (MeshCore RegionMap/CommonCLI): name[31], 160-byte CLI line ---------------- +const code30 = 'a'.repeat(30), code31 = 'a'.repeat(31); + +test('CODE_RE: 30 characters is the longest accepted code; 31 and 64 are rejected', () => { + assert.strictEqual(R.FW_MAX_NAME_LEN, 30); + assert.ok(R.CODE_RE.test(code30)); + assert.ok(!R.CODE_RE.test(code31)); + assert.ok(!R.CODE_RE.test('a'.repeat(64))); +}); + +test('CODE_RE: still rejects uppercase, spaces, separators and the firmware-special $ and #', () => { + ['US-OH', 'a b', 'a,b', 'a|b', '$priv', '#oki', '-lead', '', 'a.b'].forEach((c) => assert.ok(!R.CODE_RE.test(c), c)); +}); + +test('featureCode: an over-long explicit or slugged code is invalid, never truncated', () => { + const explicit = R.featureCode({ properties: { __code: code31 } }, {}); + assert.deepStrictEqual(explicit, { code: null, reason: 'invalid' }); + const slugged = R.featureCode({ properties: { name: code31 } }, { nameProperty: 'name' }); + assert.deepStrictEqual(slugged, { code: null, reason: 'invalid' }); + assert.strictEqual(R.featureCode({ properties: { __code: code30 } }, {}).code, code30); +}); + +test('validateManifest: a 31-character canonical code or ignored code is an error', () => { + const m = (extra) => ({ version: 1, layers: [Object.assign({ id: 'a', url: 'a.geojson' }, extra)] }); + assert.ok(R.validateManifest(m({ codes: [code31] })).errors.length > 0); + assert.ok(R.validateManifest(m({ ignoreCodes: [code31] })).errors.length > 0); + assert.strictEqual(R.validateManifest(m({ codes: [code30] })).errors.length, 0); +}); + +test('commandProblems: the 158-character line limit is exact', () => { + // 'region def ' is 11 characters; 147 more fill the line to exactly 158. + const fit = [code30, code30, code30, code30, 'b'.repeat(147 - 4 * 30 - 4)].join(' '); + assert.strictEqual(('region def ' + fit).length, 158); + assert.deepStrictEqual(R.commandProblems(fit), []); + assert.ok(R.repeaterCommands(fit).length > 0); + const over = fit + 'b'; + assert.strictEqual(('region def ' + over).length, 159); + assert.ok(R.commandProblems(over).some((p) => /159 characters/.test(p))); + assert.strictEqual(R.repeaterCommands(over), ''); +}); + +test('commandProblems: more regions than a repeater can hold, and invalid tokens, are rejected', () => { + const many = Array.from({ length: 33 }, (_, i) => 'r' + i).join(' '); + assert.ok(R.commandProblems(many).some((p) => /33 regions/.test(p))); + assert.ok(R.commandProblems('oki US-OH').some((p) => /"US-OH"/.test(p))); + assert.ok(R.commandProblems(code31).length > 0); + assert.strictEqual(R.repeaterCommands(code31), ''); + assert.deepStrictEqual(R.commandProblems(''), []); +}); + +test('buildRegionDef: a command over the line limit is withheld with the reason, not returned as valid', () => { + const names = ['a', 'b', 'c', 'd', 'e', 'f'].map((ch) => ch.repeat(30)); + const L = names.map((n, i) => R.buildLayer({ id: 'l' + i, label: 'L' + i, nameProperty: 'name' }, [{ data: fc(poly({ name: n }, sq(0, 0, 10, 10))) }])); + const r = R.buildRegionDef(L, 5, 5); + assert.strictEqual(r.tokens.length, 6); + assert.strictEqual(r.value, ''); + assert.ok(r.blocked.some((p) => /characters; the repeater accepts at most 158/.test(p)), r.blocked.join('\n')); + assert.strictEqual(R.repeaterCommands(r.value), ''); +}); + +test('buildRegionDef: a normal result has no blocked reasons', () => { + assert.deepStrictEqual(R.buildRegionDef(layers(), 2, 2).blocked, []); +}); + +// ---- input parsing ---------------------------------------------------------- +test('parseLatLon', () => { + assert.deepStrictEqual(R.parseLatLon('39.1, -84.5'), { lat: 39.1, lon: -84.5 }); + assert.deepStrictEqual(R.parseLatLon(' 39.1 -84.5 '), { lat: 39.1, lon: -84.5 }); + assert.strictEqual(R.parseLatLon('91, 0'), null); + assert.strictEqual(R.parseLatLon('0, 181'), null); + assert.strictEqual(R.parseLatLon('123 Main St'), null); + assert.strictEqual(R.parseLatLon('1,2,3'), null); + assert.strictEqual(R.parseLatLon(null), null); +}); + +test('featureColorOverride only applies with colorBy=feature and a sane value', () => { + const f = { properties: { color: '#db2777' } }; + assert.strictEqual(R.featureColorOverride({ colorBy: 'feature' }, f), '#db2777'); + assert.strictEqual(R.featureColorOverride({}, f), null); + assert.strictEqual(R.featureColorOverride({ colorBy: 'feature' }, { properties: { color: 'red;x:y' } }), null); +}); + +// ---- layer path allowlist / sizing ------------------------------------------- +test('isSafeLayerPath: accepts plain relative paths, rejects everything else', () => { + ['a.geojson', 'geojson/states.geojson', 'x/y/z-1_2.json'].forEach((p) => assert.strictEqual(R.isSafeLayerPath(p), true, p)); + ['../x.json', 'a/../b.json', 'a/./b.json', '/a.json', 'a//b.json', 'http://x/a.json', '//x/a.json', + 'a.json?x=1', 'a.json#f', 'a%2e%2e/b.json', 'a\\b.json', '.hidden.json', 'a.txt', '', null, 42, './a.json', + 'a/b/' + 'x'.repeat(200) + '.json'].forEach((p) => assert.strictEqual(R.isSafeLayerPath(p), false, String(p))); +}); + +test('buildLayer reports vertex count and unnamed polygons', () => { + const L = R.buildLayer({ id: 'm', nameProperty: 'iata' }, [{ data: fc(poly({ iata: 'A' }, sq(0, 0, 1, 1)), poly({}, sq(2, 2, 3, 3))) }]); + assert.strictEqual(L.vertices, 10); + assert.strictEqual(L.unnamed, 1); + assert.strictEqual(R.countVertices({ type: 'MultiPolygon', coordinates: [[sq(0, 0, 1, 1), sq(0.2, 0.2, 0.4, 0.4)]] }), 10); +}); + +test('buildRegionDef exposes per-layer unnamed hits so callers can withhold the command', () => { + const L = [R.buildLayer({ id: 'm', label: 'Metro', nameProperty: 'iata' }, [{ data: fc(poly({}, sq(0, 0, 5, 5))) }])]; + const r = R.buildRegionDef(L, 2, 2); + assert.strictEqual(r.perLayer[0].unnamed, 1); +}); + +// ---- canonical codes ------------------------------------------------------------ +test('codeProperty is used verbatim and validated, never slugged', () => { + const L = R.buildLayer({ id: 's', nameProperty: 'shapeName', codeProperty: 'abbr' }, [{ data: fc( + poly({ shapeName: 'Ohio', abbr: 'oh' }, sq(0, 0, 1, 1)), + poly({ shapeName: 'Indiana', abbr: 'IN' }, sq(2, 2, 3, 3)), // uppercase: invalid, not lowercased for you + poly({ shapeName: 'Kentucky', abbr: 'k y' }, sq(4, 4, 5, 5)), // space: invalid + poly({ shapeName: 'Michigan' }, sq(6, 6, 7, 7))) }]); // no abbr: falls back to slug(name) + assert.strictEqual(R.matchLayer(L, 0.5, 0.5).matches[0].code, 'oh'); + assert.strictEqual(R.matchLayer(L, 2.5, 2.5).matches.length, 0); + assert.strictEqual(R.matchLayer(L, 4.5, 4.5).matches.length, 0); + assert.strictEqual(R.matchLayer(L, 6.5, 6.5).matches[0].code, 'michigan'); + assert.strictEqual(L.codeProblems.invalid, 2); +}); + +test('codes allowlist: a code outside the canonical list is unusable', () => { + const L = R.buildLayer({ id: 'mesh', nameProperty: 'name', codes: ['oki', 'swoh'] }, [{ data: fc( + poly({ name: 'OKI' }, sq(0, 0, 1, 1)), poly({ name: 'Typo' }, sq(2, 2, 3, 3))) }]); + assert.strictEqual(R.matchLayer(L, 0.5, 0.5).matches[0].code, 'oki'); + assert.strictEqual(R.matchLayer(L, 2.5, 2.5).matches.length, 0); + assert.strictEqual(L.codeProblems['not-allowed'], 1); + const r = R.buildRegionDef([L], 2.5, 2.5); + assert.strictEqual(r.perLayer[0].unnamed, 1); +}); + +test('file-level code and groupBy+codeProperty', () => { + const macro = R.buildLayer({ id: 'macro' }, [{ name: 'US Midwest', code: 'us-midwest', data: fc(poly({}, sq(0, 0, 1, 1))) }]); + assert.strictEqual(R.matchLayer(macro, 0.5, 0.5).matches[0].code, 'us-midwest'); + assert.strictEqual(R.matchLayer(macro, 0.5, 0.5).matches[0].name, 'US Midwest'); + const badFile = R.buildLayer({ id: 'macro' }, [{ name: 'X', code: 'Bad Code', data: fc(poly({}, sq(0, 0, 1, 1))) }]); + assert.strictEqual(R.matchLayer(badFile, 0.5, 0.5).matches.length, 0); + const mesh = R.buildLayer({ id: 'mesh', groupBy: 'grp', codeProperty: 'abbr' }, [{ data: fc( + poly({ grp: 'Ohio-Kentucky-Indiana', abbr: 'oki' }, sq(0, 0, 1, 1)), + poly({ grp: 'Ohio-Kentucky-Indiana', abbr: 'oki' }, sq(1, 0, 2, 1)))}]); + assert.strictEqual(mesh.features.length, 1); + const m = R.matchLayer(mesh, 0.5, 1.5).matches[0]; + assert.strictEqual(m.code, 'oki'); // from codeProperty, not the group value + assert.strictEqual(m.name, 'Ohio-Kentucky-Indiana'); +}); + +test('groupBy merges one region across several files of the same layer', () => { + const west = fc(poly({ mesh: 'oki' }, sq(0, 0, 1, 1)), poly({ mesh: 'swoh' }, sq(5, 5, 6, 6))); + const east = fc(poly({ mesh: 'oki' }, sq(1, 0, 2, 1))); + const L = R.buildLayer({ id: 'mesh', groupBy: 'mesh' }, [{ data: west }, { data: east }]); + assert.strictEqual(L.features.length, 2, 'one polygon per mesh region, not one per file'); + assert.strictEqual(L.features.filter((e) => e.code === 'oki').length, 1); + assert.strictEqual(R.matchLayer(L, 0.5, 0.5).matches[0].code, 'oki'); // in the west file + assert.strictEqual(R.matchLayer(L, 0.5, 1.5).matches[0].code, 'oki'); // in the east file + assert.strictEqual(R.matchLayer(L, 5.5, 5.5).matches[0].code, 'swoh'); +}); + +test('ignoreCodes drops deliberately excluded regions: not drawn, not matched, not a problem', () => { + const data = fc(poly({ iso: 'US-OH' }, sq(0, 0, 1, 1)), poly({ iso: 'US-PA' }, sq(1, 0, 2, 1)), poly({ iso: 'US-WV' }, sq(2, 0, 3, 1))); + const def = { id: 's', nameProperty: 'iso', codes: ['us-oh'] }; + const strict = R.buildLayer(def, [{ data }]); + assert.strictEqual(strict.unnamed, 2, 'without ignoreCodes the extra states are reported'); + const L = R.buildLayer(Object.assign({}, def, { ignoreCodes: ['us-pa', 'us-wv'] }), [{ data }]); + assert.strictEqual(L.unnamed, 0); + assert.strictEqual(L.features.length, 1); + assert.strictEqual(R.matchLayer(L, 0.5, 0.5).matches[0].code, 'us-oh'); + assert.strictEqual(R.matchLayer(L, 0.5, 1.5).matches.length, 0); + assert.strictEqual(R.matchLayer(L, 0.5, 2.5).matches.length, 0); + // an ignored code does not need to be in `codes`, and a code in both is a manifest error + const bad = R.validateManifest({ version: 1, layers: [{ id: 'a', url: 'a.geojson', codes: ['us-oh'], ignoreCodes: ['us-oh'] }] }); + assert.ok(bad.errors.some((e) => /both "codes" and "ignoreCodes"/.test(e)), bad.errors.join('\n')); + const bad2 = R.validateManifest({ version: 1, layers: [{ id: 'a', url: 'a.geojson', ignoreCodes: ['US-PA'] }] }); + assert.ok(bad2.errors.some((e) => /ignored code/.test(e))); + const ok = R.validateManifest({ version: 1, layers: [{ id: 'a', url: 'a.geojson', codes: ['us-oh'], ignoreCodes: ['us-pa'] }] }); + assert.deepStrictEqual(ok.errors, []); + assert.ok(!ok.warnings.some((w) => /unknown/i.test(w)), ok.warnings.join('\n')); +}); + +test('coverage:"full" turns a miss into a data gap only when another layer matched', () => { + const mk = (cov) => [ + R.buildLayer({ id: 'state', label: 'State', nameProperty: 'name' }, [{ data: fc(poly({ name: 'Ohio' }, sq(0, 0, 10, 10))) }]), + R.buildLayer({ id: 'mesh', label: 'Mesh', nameProperty: 'name', coverage: cov }, [{ data: fc(poly({ name: 'OKI' }, sq(0, 0, 5, 10))) }])]; + const gap = R.buildRegionDef(mk('full'), 5, 8); // in Ohio, outside OKI + assert.strictEqual(gap.perLayer[1].gap, true); + assert.ok(gap.warnings.some((w) => /data gap/.test(w))); + assert.strictEqual(R.buildRegionDef(mk('partial'), 5, 8).perLayer[1].gap, false); // valid exclusion + assert.strictEqual(R.buildRegionDef(mk('full'), 50, 50).perLayer[1].gap, false); // outside everything + assert.strictEqual(R.buildRegionDef(mk('full'), 2, 2).perLayer[1].gap, false); // covered +}); + +test('duplicate code across layers is flagged', () => { + const a = R.buildLayer({ id: 'a', label: 'A', nameProperty: 'n' }, [{ data: fc(poly({ n: 'oki' }, sq(0, 0, 5, 5))) }]); + const b = R.buildLayer({ id: 'b', label: 'B', nameProperty: 'n' }, [{ data: fc(poly({ n: 'oki' }, sq(0, 0, 5, 5))) }]); + assert.ok(R.buildRegionDef([a, b], 2, 2).warnings.some((w) => /repeat/.test(w))); +}); + +// ---- manifest validation ---------------------------------------------------------- +const goodManifest = () => ({ version: 1, layers: [ + { id: 'macro', label: 'Macro', url: [{ url: 'a.geojson', name: 'US-Midwest', code: 'us-midwest' }], codes: ['us-midwest'] }, + { id: 'metro', url: 'metros.geojson', nameProperty: 'iata', optional: true }] }); + +test('validateManifest: good manifest has no errors or warnings', () => { + const v = R.validateManifest(goodManifest()); + assert.deepStrictEqual(v.errors, []); + assert.deepStrictEqual(v.warnings, []); +}); + +test('validateManifest: version handling', () => { + const m = goodManifest(); delete m.version; + const v = R.validateManifest(m); + assert.strictEqual(v.errors.length, 0); + assert.ok(v.warnings.some((w) => /no "version"/.test(w))); + m.version = 2; assert.ok(R.validateManifest(m).errors.some((e) => /newer than this page/.test(e))); + m.version = '1'; assert.ok(R.validateManifest(m).errors.length > 0); + m.version = 0; assert.ok(R.validateManifest(m).errors.length > 0); +}); + +test('validateManifest: rejects structural problems', () => { + const bad = (mut) => { const m = goodManifest(); mut(m); return R.validateManifest(m).errors; }; + assert.ok(bad((m) => { m.layers = []; }).length); + assert.ok(bad((m) => { m.layers[1].id = 'macro'; }).some((e) => /duplicate id/.test(e))); + assert.ok(bad((m) => { m.layers[0].id = '1bad'; }).length); + assert.ok(bad((m) => { m.layers[0].url = '../x.json'; }).some((e) => /unsafe/.test(e))); + assert.ok(bad((m) => { delete m.layers[0].url; }).some((e) => /required/.test(e))); + assert.ok(bad((m) => { m.layers[0].codes = ['OKI']; }).some((e) => /invalid/.test(e))); + assert.ok(bad((m) => { m.layers[0].codes = ['oki', 'oki']; }).some((e) => /twice/.test(e))); + assert.ok(bad((m) => { m.layers[0].coverage = 'all'; }).some((e) => /coverage/.test(e))); + assert.ok(bad((m) => { m.layers[0].optional = 'yes'; }).some((e) => /optional/.test(e))); + assert.ok(bad((m) => { m.layers[0].url[0].code = 'Bad'; }).some((e) => /not a valid code/.test(e))); + assert.ok(bad((m) => { m.layers = new Array(17).fill(0).map((_, i) => ({ id: 'l' + i, url: 'a.json' })); }).some((e) => /At most/.test(e))); + assert.ok(R.validateManifest(null).errors.length && R.validateManifest([]).errors.length); +}); + +test('validateManifest: unknown fields are warned about (catches typos like "optionnal")', () => { + const m = goodManifest(); m.layers[1].optionnal = true; + const v = R.validateManifest(m); + assert.deepStrictEqual(v.errors, []); + assert.ok(v.warnings.some((w) => /optionnal/.test(w))); +}); + +// ---- static wiring ---------------------------------------------------------- +test('wiring: index.html loads css + core before page script', () => { + const html = read('public/index.html'); + assert.ok(/region-def\.css\?v=__BUST__/.test(html), 'css link'); + const a = html.indexOf('region-def-core.js?v=__BUST__'); + const b = html.indexOf('region-def.js?v=__BUST__'); + assert.ok(a > 0 && b > a, 'core must load before page'); +}); + +test('wiring: app.js routes #/tools/region-def and highlights Tools nav', () => { + const app = read('public/app.js'); + assert.ok(/#\/tools\/region-def/.test(app), 'landing card'); + assert.ok(/routeParam === 'region-def'/.test(app), 'sub-route'); + assert.ok(/basePage === 'region-def'/.test(app), 'nav active'); +}); + +test('wiring: page registers and only the static skeleton uses innerHTML', () => { + const js = read('public/region-def.js'); + assert.ok(/registerPage\('region-def'/.test(js)); + const hits = js.match(/innerHTML/g) || []; + assert.strictEqual(hits.length, 1, 'exactly one innerHTML (static skeleton); all dynamic data via textContent'); + assert.ok(/app\.innerHTML\s*=\s*\n?\s*'
/.test(js)); +}); + +test('wiring: loader dedupes requests, uses the path allowlist, withholds incomplete commands', () => { + const js = read('public/region-def.js'); + assert.ok(/cache\[url\]\s*\|\|\s*\(cache\[url\]\s*=/.test(js), 'per-URL request cache'); + assert.ok(/RegionDef\.isSafeLayerPath/.test(js), 'allowlist used'); + assert.ok(/contains no valid polygons/.test(js), 'empty layers must count as failed'); + assert.ok(/Command withheld/.test(js) && /optional === true/.test(js), 'withhold / optional logic'); + assert.ok(/preferCanvas:\s*true/.test(js), 'canvas renderer'); +}); + +test('theming: region-def.css has no hex colours outside :root / [data-theme]', () => { + const css = read('public/region-def.css').replace(/\/\*[^]*?\*\//g, ''); + const blocks = css.split('}'); + blocks.forEach((b) => { + const sel = b.split('{')[0]; + if (/:root|\[data-theme/.test(sel)) return; + assert.ok(!/#[0-9a-fA-F]{3,8}\b/.test(b), 'hex colour in rule: ' + sel.trim()); + }); +}); + +test('no emoji in new frontend files (#1648 policy)', () => { + const re = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u; + ['public/region-def.js', 'public/region-def-core.js', 'public/region-def.css'].forEach((f) => { + assert.ok(!re.test(read(f)), f); + }); +}); + +console.log('\n' + passed + ' passed, ' + failed + ' failed'); +if (failed) process.exit(1); diff --git a/tests/unit/test-region-layers-manifest.js b/tests/unit/test-region-layers-manifest.js new file mode 100644 index 00000000..59efbd24 --- /dev/null +++ b/tests/unit/test-region-layers-manifest.js @@ -0,0 +1,68 @@ +/** + * Manifest checks for public/region-layers/. + * + * - layers.json.example (the documented format) must always validate cleanly. + * - It must use the same conventions as the test fixture, which is what the integrity and + * known-point tests run against (guards the documentation drifting away from what is tested: + * same layer ids, same name/code/group properties, same file shapes). + * - known-points.json.example must match the fixture's table. + * - If a real public/region-layers/layers.json is committed it is validated, and the full + * deployed-data checks live in test-region-def-known-points.js / scripts/check-region-data.js. + */ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +const ROOT = path.join(__dirname, '..', '..'); +const DIR = path.join(ROOT, 'public', 'region-layers'); +const FIX = path.join(ROOT, 'tests', 'fixtures', 'region-layers'); +const R = require(path.join(ROOT, 'public', 'region-def-core.js')); +const json = (f) => JSON.parse(fs.readFileSync(f, 'utf8')); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(' ok ' + name); } + catch (e) { failed++; console.error(' FAIL ' + name + ': ' + e.message); } +} + +const example = json(path.join(DIR, 'layers.json.example')); + +test('layers.json.example validates cleanly and carries the schema version', () => { + const v = R.validateManifest(example); + assert.deepStrictEqual(v.errors, []); + assert.deepStrictEqual(v.warnings, []); + assert.strictEqual(example.version, R.MANIFEST_VERSION); +}); + +test('layers.json.example uses the same conventions as the tested fixture', () => { + const shape = (m) => m.layers.map((l) => ({ + id: l.id, nameProperty: l.nameProperty || null, codeProperty: l.codeProperty || null, groupBy: l.groupBy || null, + files: (Array.isArray(l.url) ? l.url : [l.url]).map((u) => (typeof u === 'object' ? { name: u.name, code: u.code } : 'plain')) + })); + assert.deepStrictEqual(shape(example), shape(json(path.join(FIX, 'layers.json')))); +}); + +test('known-points.json.example matches the fixture table (verified against the fixture by the known-points test)', () => { + assert.deepStrictEqual(json(path.join(DIR, 'known-points.json.example')), json(path.join(FIX, 'known-points.json'))); +}); + +test('the fixture manifest validates cleanly', () => { + const v = R.validateManifest(json(path.join(FIX, 'layers.json'))); + assert.deepStrictEqual(v.errors.concat(v.warnings), []); +}); + +const prod = path.join(DIR, 'layers.json'); +if (!fs.existsSync(prod)) { + console.log(' skip deployed manifest check (public/region-layers/layers.json not committed)'); +} else { + test('deployed layers.json validates and sets "version"', () => { + const m = json(prod); + const v = R.validateManifest(m); + assert.deepStrictEqual(v.errors, []); + assert.strictEqual(m.version, R.MANIFEST_VERSION); + }); +} + +console.log('\n' + passed + ' passed, ' + failed + ' failed'); +if (failed) process.exit(1); diff --git a/tests/unit/test-simplify-region-layers.js b/tests/unit/test-simplify-region-layers.js new file mode 100644 index 00000000..b92e2790 --- /dev/null +++ b/tests/unit/test-simplify-region-layers.js @@ -0,0 +1,100 @@ +/** + * Tests for scripts/simplify-region-layers.js + */ +'use strict'; +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const assert = require('assert'); +const { spawnSync } = require('child_process'); +const { run, simplifyRing } = require('../../scripts/simplify-region-layers.js'); +const { checkRegionData } = require('../../scripts/check-region-data.js'); + +const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'simplify-region-layers.js'); +const FIXTURE = path.join(__dirname, '..', 'fixtures', 'region-layers'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(' ok ' + name); } + catch (e) { failed++; console.error(' FAIL ' + name + ': ' + e.message); } +} +const tmp = (p) => fs.mkdtempSync(path.join(os.tmpdir(), p)); + +// A dense copy of the fixture: every edge subdivided into 40 points with sub-tolerance noise, +// coordinates over-precise, as a real boundary download would be. +function denseFixture() { + const d = tmp('rd-dense-'); + let seed = 7; const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647 - 0.5; + const dense = (ring) => { + const out = []; + for (let i = 1; i < ring.length; i++) { + const [x0, y0] = ring[i - 1], [x1, y1] = ring[i]; + for (let s = 0; s < 40; s++) { + const t = s / 40; + const noise = (s === 0) ? 0 : rnd() * 0.0001; // far below the 0.0005 tolerance, vertices of the original stay exact + out.push([x0 + (x1 - x0) * t + noise, y0 + (y1 - y0) * t + noise]); + } + } + out.push(ring[ring.length - 1]); + return out; + }; + fs.readdirSync(FIXTURE).forEach((f) => { + if (f === 'README.md') return; + const src = fs.readFileSync(path.join(FIXTURE, f), 'utf8'); + if (!/\.geojson$/.test(f)) { fs.writeFileSync(path.join(d, f), src); return; } + const g = JSON.parse(src); + g.features.forEach((ft) => { ft.geometry.coordinates = ft.geometry.coordinates.map((r) => dense(r)); }); + fs.writeFileSync(path.join(d, f), JSON.stringify(g, null, 2)); + }); + return d; +} + +test('simplifyRing: removes collinear points, keeps corners, stays closed with >= 4 positions', () => { + const ring = [[0, 0], [1, 0], [2, 0], [3, 0], [3, 1], [3, 2], [3, 3], [0, 3], [0, 2], [0, 1], [0, 0]]; + const s = simplifyRing(ring, 0.001, 5); + assert.deepStrictEqual(s, [[0, 0], [3, 0], [3, 3], [0, 3], [0, 0]]); +}); + +test('simplifyRing: a ring that collapses is reported (null) so the caller keeps the original', () => { + assert.strictEqual(simplifyRing([[0, 0], [0.00001, 0], [0.00001, 0.00001], [0, 0.00001], [0, 0]], 1, 5), null); +}); + +test('dense data: big vertex and byte reduction, output passes the integrity check, known points unchanged', () => { + const inDir = denseFixture(), outDir = tmp('rd-simp-'); + assert.deepStrictEqual(checkRegionData(inDir, { overlapTolerance: 0.001 }).errors, [], 'dense input should itself be valid'); + const r = run(inDir, outDir, { tolerance: 0.0005, precision: 5 }); + assert.deepStrictEqual(r.errors, []); + assert.ok(r.ok); + const sum = (k) => r.files.reduce((s, f) => s + f[k], 0); + assert.ok(sum('vertsAfter') < sum('vertsBefore') / 10, 'vertices ' + sum('vertsBefore') + ' -> ' + sum('vertsAfter')); + assert.ok(sum('bytesAfter') < sum('bytesBefore') / 10, 'bytes ' + sum('bytesBefore') + ' -> ' + sum('bytesAfter')); + const after = checkRegionData(outDir); + assert.deepStrictEqual(after.errors, []); + assert.strictEqual(after.known.length, 26); + assert.ok(after.known.every((k) => k.got === k.expect)); +}); + +test('too coarse a tolerance is refused (output would change answers) with a clear error', () => { + const inDir = denseFixture(), outDir = tmp('rd-simp-'); + const r = run(inDir, outDir, { tolerance: 3, precision: 5 }); + assert.strictEqual(r.ok, false); + assert.ok(/fails the integrity check/.test(r.errors[0]), r.errors.join('\n')); +}); + +test('input that already fails the integrity check is refused', () => { + const inDir = tmp('rd-bad-'); fs.writeFileSync(path.join(inDir, 'layers.json'), '{ nope'); + const r = run(inDir, tmp('rd-simp-'), {}); + assert.strictEqual(r.ok, false); + assert.ok(/input fails the integrity check/.test(r.errors[0])); +}); + +test('--in and --out must differ; CLI exit codes', () => { + assert.strictEqual(run(FIXTURE, FIXTURE, {}).ok, false); + const out = tmp('rd-simp-'); + assert.strictEqual(spawnSync('node', [SCRIPT, '--in', FIXTURE, '--out', out]).status, 0); + assert.strictEqual(spawnSync('node', [SCRIPT, '--in', FIXTURE, '--out', FIXTURE]).status, 1); + assert.strictEqual(spawnSync('node', [SCRIPT]).status, 2); +}); + +console.log('\n' + passed + ' passed, ' + failed + ' failed'); +if (failed) process.exit(1); From 85ca2a22a3c5679c1ef0e15942c21e38370d40c5 Mon Sep 17 00:00:00 2001 From: ITBlacksheep Date: Mon, 12 Oct 2026 13:39:15 +0000 Subject: [PATCH 2/3] Fixing test to match prod --- tests/e2e/test-region-def-e2e.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/test-region-def-e2e.js b/tests/e2e/test-region-def-e2e.js index 89647ef5..4bd5a71b 100644 --- a/tests/e2e/test-region-def-e2e.js +++ b/tests/e2e/test-region-def-e2e.js @@ -21,7 +21,7 @@ const { chromium } = require('playwright'); const BASE = process.env.BASE_URL || 'http://localhost:13581'; const FIX = path.join(__dirname, '..', 'fixtures', 'region-layers'); -const CMD_TAIL = '\nregion save\nregion allowf *'; +const CMD_TAIL = '\nregion allowf *\nregion save'; let passed = 0, failed = 0; async function step(name, fn) { From 25063f84725c9e96ef2a0ca69f90455715da74d4 Mon Sep 17 00:00:00 2001 From: ITBlacksheep Date: Mon, 12 Oct 2026 18:08:50 +0000 Subject: [PATCH 3/3] readding in correct test --- tests/unit/test-frontend-helpers.js | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/unit/test-frontend-helpers.js b/tests/unit/test-frontend-helpers.js index 00812d53..d409dfbd 100644 --- a/tests/unit/test-frontend-helpers.js +++ b/tests/unit/test-frontend-helpers.js @@ -2244,6 +2244,33 @@ console.log('\n=== analytics.js: sortChannels ==='); assert.ok(chatIdx < generalIdx, 'Chat (100 msgs) should come before General (50)'); }); + // #190: server sends the hash byte as a decimal string; the table must show hex. + test('tbodyHtml renders decimal-string hash as hex (#190)', () => { + const data = [ + { name: 'Public', hash: '17', messages: 3, senders: 1, lastActivity: '', encrypted: false }, + { name: 'Wardriving', hash: '129', messages: 2, senders: 1, lastActivity: '', encrypted: false }, + { name: 'Low', hash: '5', messages: 1, senders: 1, lastActivity: '', encrypted: false }, + ]; + const html = tbodyHtml(data, 'messages', 'desc'); + assert.ok(html.includes('>0x11<'), 'hash "17" should render as 0x11'); + assert.ok(html.includes('>0x81<'), 'hash "129" should render as 0x81'); + assert.ok(html.includes('>0x05<'), 'hash "5" should be zero-padded to 0x05'); + assert.ok(!html.includes('>17<') && !html.includes('>129<'), 'decimal must not be displayed'); + assert.ok(html.includes('?ch=17"'), 'deep link keeps the decimal value'); + }); + + test('tbodyHtml leaves non-numeric hash ids untouched and escaped (#190)', () => { + const html = tbodyHtml([{ name: 'X', hash: 'enc_A1B2', messages: 1, senders: 1, lastActivity: '', encrypted: true }], 'messages', 'desc'); + assert.ok(html.includes('>enc_A1B2<')); + const evil = tbodyHtml([{ name: 'X', hash: '', messages: 1, senders: 1, lastActivity: '', encrypted: false }], 'messages', 'desc'); + assert.ok(!evil.includes(''), 'hash must be HTML-escaped'); + }); + + test('sort by hash is numeric for decimal-string hashes (#190)', () => { + const data = [{ hash: '129' }, { hash: '17' }, { hash: '5' }]; + assert.deepStrictEqual(sortChannels(data, 'hash', 'asc').map(c => c.hash), ['5', '17', '129']); + }); + test('sort by string hash values', () => { const data = [ { name: 'A', hash: 'zz', messages: 1, senders: 1, lastActivity: '', encrypted: false },