mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-11 11:57:57 +00:00
feat(nodes): export the visible node list as MeshCore companion contacts JSON (#1889)
## What
Adds an **Export JSON** button to the Nodes topbar that downloads the
currently visible node list as a MeshCore companion-app config file:
```json
{
"contacts": [
{
"type": 2,
"name": "BE-MGU-RP03 | ON7YT",
"custom_name": null,
"public_key": "7a7a37d4819fb27440ed1439ca7d281fdecceb83b27e61a69745feb004d726a4",
"flags": 0,
"latitude": "51.07307",
"longitude": "5.5796",
"last_advert": 1786545431,
"last_modified": 1786545431,
"out_path_list": null
}
]
}
```
That is the exact shape the companion app itself writes, so the file
imports straight back into a companion app as contacts. The practical
use case is per-area: pick an area in the area filter, export, and hand
someone the repeaters for that region instead of having them wait for
adverts.
## Scope of the export
WYSIWYG — the button exports the rows the table is showing, in the
table's current order, so area, region, role tab, search, last-heard and
status filters all carry over. The button label shows how many contacts
the file will contain and is disabled when that count is zero.
## Field mapping
| JSON field | Source | Notes |
|---|---|---|
| `type` | `node.role` | repeater→2, companion→1, room→3, sensor→4;
unknown/empty→1 |
| `name` | `node.name` | unchanged, emoji included |
| `custom_name` | — | always `null` |
| `public_key` | `node.public_key` | full 64-hex |
| `flags` | — | always `0` |
| `latitude` / `longitude` | `node.lat` / `node.lon` | stringified, as
the format expects |
| `last_advert` | `node.last_seen` | RFC3339 → unix seconds |
| `last_modified` | mirrors `last_advert` | no separate source exists |
| `out_path_list` | — | always `null`; the companion app discovers
routes itself |
Nodes are skipped when they have no name, a pubkey shorter than 64 hex
chars, or no usable position (missing, non-numeric, or null island).
Filename: `corescope_nodes_<area|all>_YYYY-MM-DD-HHMMSS.json`.
## Shape of the change
The mapping lives in a self-contained `public/nodes-export.js`
(`window.NodesExport.buildContacts/filename/download`); `nodes.js` only
gains the button markup, a click handler and a small
`updateExportBtn()`. No backend change, no new API call — the export
reuses the already-fetched node list, so there is nothing per-node to
fetch.
## Tests
- `test-nodes-export.js` — field mapping and key order, role→type table,
skip rules, order preservation, filename format (added to
`test-all.sh`).
- `test-nodes-export-wiring.js` — `index.html` loads the module before
`nodes.js`; the button lives in the topbar and hands the filtered
`nodes` array plus `AreaFilter.getSelected()` to
`NodesExport.download()` (added to `test-all.sh`).
- `test-nodes-export-e2e.js` — Playwright: downloads the file, validates
the JSON shape per contact, asserts the button count matches the file,
and that narrowing the search shrinks the export set.
## Browser validation
Deployed to staging and checked in Chromium:
- Desktop 1400×900 — button renders at the right of the topbar next to
the count pills, `Export JSON (1962)`.
- Mobile 390×844 — topbar stacks, button visible, no horizontal overflow
(`scrollWidth == clientWidth`).
- E2E against staging: 1761 contacts exported,
`corescope_nodes_all_2026-08-12-164349.json`, shape validated, search
narrowing confirmed.
- With the `BE-LIM` area filter active: 44 contacts,
`corescope_nodes_BE-LIM_2026-08-12-164445.json`, type histogram `{1: 1,
2: 42, 3: 1}`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d821d9a390
commit
aabbd50d27
8 files changed
+357
No files matched your search
@@ -208,6 +208,7 @@
|
||||
<script src="route-view.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="table-sort.js?v=__BUST__"></script>
|
||||
<script src="nodes-export.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="path-inspector.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/* === CoreScope — nodes-export.js (MeshCore companion contacts export) === */
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* Exports the visible node list as a MeshCore companion-app config file:
|
||||
* { "contacts": [ … ] }, the same shape the companion app itself writes, so the
|
||||
* result can be imported there directly. Kept out of nodes.js so the feature
|
||||
* stays a self-contained unit.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
var ROLE_TYPES = { repeater: 2, companion: 1, room: 3, sensor: 4 };
|
||||
var UNKNOWN_TYPE = 1;
|
||||
|
||||
function epochSeconds(ts) {
|
||||
if (!ts) return 0;
|
||||
var ms = new Date(ts).getTime();
|
||||
return isNaN(ms) ? 0 : Math.floor(ms / 1000);
|
||||
}
|
||||
|
||||
function coord(v) {
|
||||
if (v === null || v === undefined || v === '') return null;
|
||||
var n = Number(v);
|
||||
return isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
// Returns the companion contact for a node, or null when the node cannot be
|
||||
// represented (no name, truncated pubkey, or no usable position).
|
||||
function contactFor(n) {
|
||||
if (!n) return null;
|
||||
if (typeof n.name !== 'string' || !n.name.trim()) return null;
|
||||
if (typeof n.public_key !== 'string' || n.public_key.length < 64) return null;
|
||||
var lat = coord(n.lat);
|
||||
var lon = coord(n.lon);
|
||||
if (lat === null || lon === null) return null;
|
||||
if (lat === 0 && lon === 0) return null;
|
||||
|
||||
var advert = epochSeconds(n.last_seen);
|
||||
return {
|
||||
type: ROLE_TYPES[String(n.role || '').toLowerCase()] || UNKNOWN_TYPE,
|
||||
name: n.name,
|
||||
custom_name: null,
|
||||
public_key: n.public_key,
|
||||
flags: 0,
|
||||
latitude: String(lat),
|
||||
longitude: String(lon),
|
||||
last_advert: advert,
|
||||
last_modified: advert,
|
||||
out_path_list: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildContacts(nodes) {
|
||||
var contacts = [];
|
||||
(nodes || []).forEach(function (n) {
|
||||
var c = contactFor(n);
|
||||
if (c) contacts.push(c);
|
||||
});
|
||||
return { contacts: contacts };
|
||||
}
|
||||
|
||||
function pad2(v) { return v < 10 ? '0' + v : String(v); }
|
||||
|
||||
function filename(areaKey, date) {
|
||||
var d = date || new Date();
|
||||
var stamp = d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()) +
|
||||
'-' + pad2(d.getHours()) + pad2(d.getMinutes()) + pad2(d.getSeconds());
|
||||
var area = String(areaKey || '').replace(/[^A-Za-z0-9_-]+/g, '_').replace(/^_+|_+$/g, '');
|
||||
return 'corescope_nodes_' + (area || 'all') + '_' + stamp + '.json';
|
||||
}
|
||||
|
||||
// Triggers the browser download. Returns the number of exported contacts.
|
||||
function download(nodes, areaKey) {
|
||||
var payload = buildContacts(nodes);
|
||||
var blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json;charset=utf-8' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename(areaKey, new Date());
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(function () { URL.revokeObjectURL(url); }, 0);
|
||||
return payload.contacts.length;
|
||||
}
|
||||
|
||||
window.NodesExport = {
|
||||
buildContacts: buildContacts,
|
||||
filename: filename,
|
||||
download: download,
|
||||
};
|
||||
})();
|
||||
@@ -511,6 +511,8 @@
|
||||
<div class="nodes-topbar">
|
||||
<input type="text" class="nodes-search" id="nodeSearch" placeholder="Search by name or pubkey prefix…" aria-label="Search nodes by name or pubkey prefix">
|
||||
<div class="nodes-counts" id="nodeCounts"></div>
|
||||
<button class="btn-secondary nodes-export-btn" id="nodesExportBtn" disabled
|
||||
title="Download the visible nodes as a MeshCore companion config">Export JSON</button>
|
||||
</div>
|
||||
<div id="nodesRegionFilter" class="region-filter-container"></div>
|
||||
<div id="nodesAreaFilter" style="display:none"></div>
|
||||
@@ -536,6 +538,12 @@
|
||||
loadNodes();
|
||||
}, 250));
|
||||
|
||||
document.getElementById('nodesExportBtn').addEventListener('click', function () {
|
||||
// WYSIWYG: `nodes` is the list the table is showing, so every active
|
||||
// filter (area, region, tab, search, last-heard, status) carries over.
|
||||
window.NodesExport.download(nodes, AreaFilter.getSelected());
|
||||
});
|
||||
|
||||
loadNodes();
|
||||
if (directNode) selectNode(directNode);
|
||||
// Auto-refresh when ADVERT packets arrive via WebSocket (fixes #131)
|
||||
@@ -1274,6 +1282,7 @@
|
||||
syncClaimedToFavorites();
|
||||
|
||||
renderCounts();
|
||||
updateExportBtn();
|
||||
if (refreshOnly) {
|
||||
renderRows();
|
||||
} else {
|
||||
@@ -1290,6 +1299,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
function updateExportBtn() {
|
||||
const btn = document.getElementById('nodesExportBtn');
|
||||
if (!btn || !window.NodesExport) return;
|
||||
const n = window.NodesExport.buildContacts(nodes).contacts.length;
|
||||
btn.disabled = n === 0;
|
||||
btn.textContent = n ? 'Export JSON (' + n + ')' : 'Export JSON';
|
||||
}
|
||||
|
||||
function renderCounts() {
|
||||
const el = document.getElementById('nodeCounts');
|
||||
if (!el) return;
|
||||
|
||||
@@ -1947,6 +1947,7 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; }
|
||||
font-size: 13px; background: var(--input-bg); color: var(--text); font-family: var(--font);
|
||||
}
|
||||
.nodes-counts { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.nodes-export-btn { flex-shrink: 0; white-space: nowrap; }
|
||||
.node-count-pill {
|
||||
display: inline-block; padding: 3px 10px; border-radius: 12px;
|
||||
font-size: 12px; font-weight: 600; color: #fff; white-space: nowrap;
|
||||
|
||||
@@ -69,6 +69,8 @@ node test-issue-1473-reserved-prefixes.js
|
||||
node test-issue-1473-prefix-generator.js
|
||||
node test-issue-1770-mobile-row-clamp.js
|
||||
node test-issue-1849-trace-hashbytes.js
|
||||
node test-nodes-export.js
|
||||
node test-nodes-export-wiring.js
|
||||
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════"
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// E2E for the Nodes JSON export button (#/nodes → "Export JSON").
|
||||
// Defaults to localhost:3000 — NEVER point at prod (AGENTS.md). CI sets BASE_URL.
|
||||
const { chromium } = require('playwright');
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:3000';
|
||||
|
||||
const REF_KEYS = ['type', 'name', 'custom_name', 'public_key', 'flags', 'latitude',
|
||||
'longitude', 'last_advert', 'last_modified', 'out_path_list'];
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ acceptDownloads: true });
|
||||
|
||||
await page.goto(BASE + '/#/nodes');
|
||||
await page.waitForSelector('#nodesLeft[data-loaded="true"]', { timeout: 30000 });
|
||||
|
||||
const btn = await page.$('#nodesExportBtn');
|
||||
if (!btn) throw new Error('#nodesExportBtn is missing from the nodes topbar');
|
||||
|
||||
const label = (await btn.textContent()).trim();
|
||||
if (await btn.isDisabled()) {
|
||||
// No exportable node in this dataset (all lack a name or a position).
|
||||
if (label !== 'Export JSON') {
|
||||
throw new Error('disabled button should carry no count, got "' + label + '"');
|
||||
}
|
||||
console.log('nodes-export E2E SKIP (no exportable node in dataset)');
|
||||
await browser.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const m = label.match(/^Export JSON \((\d+)\)$/);
|
||||
if (!m) throw new Error('enabled button must show the contact count, got "' + label + '"');
|
||||
const expectedCount = Number(m[1]);
|
||||
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download', { timeout: 15000 }),
|
||||
btn.click(),
|
||||
]);
|
||||
|
||||
const name = download.suggestedFilename();
|
||||
if (!/^corescope_nodes_[A-Za-z0-9_-]+_\d{4}-\d{2}-\d{2}-\d{6}\.json$/.test(name)) {
|
||||
throw new Error('unexpected download filename: ' + name);
|
||||
}
|
||||
|
||||
const stream = await download.createReadStream();
|
||||
let raw = '';
|
||||
for await (const chunk of stream) raw += chunk;
|
||||
const payload = JSON.parse(raw);
|
||||
|
||||
if (!Array.isArray(payload.contacts)) throw new Error('export must have a contacts array');
|
||||
if (payload.contacts.length !== expectedCount) {
|
||||
throw new Error('button said ' + expectedCount + ' contacts, file has ' + payload.contacts.length);
|
||||
}
|
||||
if (Object.keys(payload).length !== 1) {
|
||||
throw new Error('export must contain only the contacts key, got ' + Object.keys(payload).join(','));
|
||||
}
|
||||
|
||||
payload.contacts.forEach(function (c, i) {
|
||||
const keys = Object.keys(c).join(',');
|
||||
if (keys !== REF_KEYS.join(',')) {
|
||||
throw new Error('contact ' + i + ' key mismatch: ' + keys);
|
||||
}
|
||||
if (typeof c.public_key !== 'string' || c.public_key.length < 64) {
|
||||
throw new Error('contact ' + i + ' has a truncated public_key');
|
||||
}
|
||||
if (typeof c.latitude !== 'string' || typeof c.longitude !== 'string') {
|
||||
throw new Error('contact ' + i + ' coordinates must be strings');
|
||||
}
|
||||
if (c.latitude === '0' && c.longitude === '0') {
|
||||
throw new Error('contact ' + i + ' is at null island and should have been skipped');
|
||||
}
|
||||
if (typeof c.last_advert !== 'number' || typeof c.type !== 'number') {
|
||||
throw new Error('contact ' + i + ' last_advert/type must be numbers');
|
||||
}
|
||||
});
|
||||
|
||||
// The export is WYSIWYG: narrowing the table with the search box must narrow
|
||||
// the export too.
|
||||
const first = payload.contacts[0].name;
|
||||
await page.fill('#nodeSearch', first);
|
||||
await page.waitForFunction(function (want) {
|
||||
var b = document.getElementById('nodesExportBtn');
|
||||
return b && b.textContent.trim() !== want;
|
||||
}, label, { timeout: 15000 });
|
||||
const narrowed = (await page.textContent('#nodesExportBtn')).trim();
|
||||
const nm = narrowed.match(/^Export JSON \((\d+)\)$/);
|
||||
if (!nm || Number(nm[1]) >= expectedCount) {
|
||||
throw new Error('search should shrink the export set, got "' + narrowed + '" vs ' + expectedCount);
|
||||
}
|
||||
|
||||
console.log('nodes-export E2E OK (' + expectedCount + ' contacts, ' + name + ')');
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Wiring tests for the Nodes JSON export: index.html loads nodes-export.js,
|
||||
* and nodes.js renders an Export JSON button in the topbar that hands the
|
||||
* currently filtered node list + active area key to NodesExport.download().
|
||||
*
|
||||
* Pure source-string assertions (no browser); behavior lives in
|
||||
* test-nodes-export.js, DOM behavior in test-nodes-export-e2e.js.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, 'public/index.html'), 'utf8');
|
||||
const src = fs.readFileSync(path.join(__dirname, 'public/nodes.js'), 'utf8');
|
||||
|
||||
console.log('\n=== nodes-export.js is loaded by the SPA shell ===');
|
||||
assert(/<script src="nodes-export\.js\?v=__BUST__"/.test(html),
|
||||
'index.html loads nodes-export.js with the __BUST__ cache buster');
|
||||
const exportTagIdx = html.indexOf('src="nodes-export.js');
|
||||
const nodesTagIdx = html.indexOf('src="nodes.js');
|
||||
assert(exportTagIdx > 0 && nodesTagIdx > 0 && exportTagIdx < nodesTagIdx,
|
||||
'nodes-export.js is loaded before nodes.js');
|
||||
|
||||
console.log('\n=== Nodes topbar renders the export button ===');
|
||||
assert(/id="nodesExportBtn"/.test(src), 'nodes.js renders an element with id nodesExportBtn');
|
||||
const topbarIdx = src.indexOf('nodes-topbar');
|
||||
const topbarBlock = src.substring(topbarIdx, src.indexOf('</div>', src.indexOf('nodesAreaFilter')));
|
||||
assert(topbarIdx > 0 && /nodesExportBtn/.test(topbarBlock),
|
||||
'the export button sits in the nodes topbar');
|
||||
|
||||
console.log('\n=== Click handler exports the visible list for the active area ===');
|
||||
const handlerIdx = src.indexOf("getElementById('nodesExportBtn')");
|
||||
assert(handlerIdx > 0, 'found the nodesExportBtn handler block');
|
||||
const handlerBlock = src.substring(handlerIdx, handlerIdx + 600);
|
||||
assert(/NodesExport\.download\s*\(/.test(handlerBlock),
|
||||
'handler calls NodesExport.download(...)');
|
||||
assert(/NodesExport\.download\(\s*nodes\s*,/.test(handlerBlock),
|
||||
'handler passes the filtered `nodes` array (WYSIWYG), not _allNodes');
|
||||
assert(/AreaFilter\.getSelected\(\)/.test(handlerBlock),
|
||||
'handler passes the active area key from AreaFilter.getSelected()');
|
||||
|
||||
console.log('\n=== Empty list disables the button ===');
|
||||
assert(/nodesExportBtn[\s\S]{0,400}?\.disabled\s*=/.test(src) ||
|
||||
/exportBtn\.disabled\s*=/.test(src),
|
||||
'the export button is disabled when there is nothing to export');
|
||||
|
||||
console.log('\n' + passed + ' passed, ' + failed + ' failed');
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,96 @@
|
||||
'use strict';
|
||||
// Unit test for nodes-export.js — the MeshCore companion "contacts" JSON export
|
||||
// of the visible node list. Loads the browser IIFE in a vm sandbox (pattern from
|
||||
// test-node-reach-coverage.js) and exercises the pure mapping helpers.
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const code = fs.readFileSync(path.join(__dirname, 'public', 'nodes-export.js'), 'utf8');
|
||||
const sandbox = { window: {}, document: {} };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(code, sandbox);
|
||||
|
||||
const { buildContacts, filename } = sandbox.window.NodesExport;
|
||||
|
||||
// ─── Field mapping ───────────────────────────────────────────────────────────
|
||||
// The companion app reads a fixed key set; emitting extra/renamed keys makes the
|
||||
// import silently drop contacts, so both the keys AND their order are asserted.
|
||||
const REF_KEYS = ['type', 'name', 'custom_name', 'public_key', 'flags', 'latitude',
|
||||
'longitude', 'last_advert', 'last_modified', 'out_path_list'];
|
||||
|
||||
const PK = 'efef7943505052b47f1809488ea4b4d3942d4ed72d2b1953b90a9f5e62a65fb5';
|
||||
const repeater = {
|
||||
public_key: PK, name: 'BE-BRE-ON8AR🔋', role: 'repeater',
|
||||
lat: 51.137798, lon: 5.590199, last_seen: '2026-05-14T09:25:43Z',
|
||||
};
|
||||
|
||||
const out = buildContacts([repeater]);
|
||||
assert.deepStrictEqual(Object.keys(out), ['contacts'], 'top level is a single contacts array');
|
||||
assert.strictEqual(out.contacts.length, 1);
|
||||
const c = out.contacts[0];
|
||||
assert.deepStrictEqual(Object.keys(c), REF_KEYS, 'contact keys must match the companion format, in order');
|
||||
assert.strictEqual(c.type, 2, 'repeater → type 2');
|
||||
assert.strictEqual(c.name, 'BE-BRE-ON8AR🔋', 'name passes through unchanged, emoji included');
|
||||
assert.strictEqual(c.custom_name, null);
|
||||
assert.strictEqual(c.public_key, PK);
|
||||
assert.strictEqual(c.flags, 0);
|
||||
assert.strictEqual(c.latitude, '51.137798', 'latitude is a string');
|
||||
assert.strictEqual(c.longitude, '5.590199', 'longitude is a string');
|
||||
assert.strictEqual(c.last_advert, 1778750743, 'last_seen → unix seconds');
|
||||
assert.strictEqual(c.last_modified, 1778750743, 'last_modified mirrors last_advert');
|
||||
assert.strictEqual(c.out_path_list, null);
|
||||
|
||||
// ─── Role → type ─────────────────────────────────────────────────────────────
|
||||
function typeOf(role) {
|
||||
return buildContacts([Object.assign({}, repeater, { role: role })]).contacts[0].type;
|
||||
}
|
||||
assert.strictEqual(typeOf('repeater'), 2, 'repeater → 2');
|
||||
assert.strictEqual(typeOf('companion'), 1, 'companion → 1');
|
||||
assert.strictEqual(typeOf('room'), 3, 'room → 3');
|
||||
assert.strictEqual(typeOf('sensor'), 4, 'sensor → 4');
|
||||
assert.strictEqual(typeOf('Repeater'), 2, 'role match is case-insensitive');
|
||||
assert.strictEqual(typeOf('observer'), 1, 'unknown role → 1');
|
||||
assert.strictEqual(typeOf(null), 1, 'missing role → 1');
|
||||
|
||||
// ─── Skip rules ──────────────────────────────────────────────────────────────
|
||||
function kept(patch) {
|
||||
return buildContacts([Object.assign({}, repeater, patch)]).contacts.length;
|
||||
}
|
||||
assert.strictEqual(kept({ name: null }), 0, 'no name → skipped');
|
||||
assert.strictEqual(kept({ name: ' ' }), 0, 'blank name → skipped');
|
||||
assert.strictEqual(kept({ public_key: 'efef7943' }), 0, 'short pubkey → skipped');
|
||||
assert.strictEqual(kept({ lat: null }), 0, 'missing lat → skipped');
|
||||
assert.strictEqual(kept({ lon: undefined }), 0, 'missing lon → skipped');
|
||||
assert.strictEqual(kept({ lat: 0, lon: 0 }), 0, 'null island → skipped');
|
||||
assert.strictEqual(kept({ lat: '0', lon: '0' }), 0, 'null island as strings → skipped');
|
||||
assert.strictEqual(kept({ lat: 0, lon: 5.59 }), 1, 'lat 0 with a real lon is a valid position');
|
||||
assert.strictEqual(kept({ lat: 'n/a' }), 0, 'non-numeric lat → skipped');
|
||||
assert.strictEqual(kept({ last_seen: null }), 1, 'a node without last_seen is still exported');
|
||||
assert.strictEqual(
|
||||
buildContacts([Object.assign({}, repeater, { last_seen: null })]).contacts[0].last_advert, 0,
|
||||
'missing last_seen → last_advert 0');
|
||||
assert.strictEqual(buildContacts([]).contacts.length, 0, 'empty input → empty contacts');
|
||||
assert.strictEqual(buildContacts(null).contacts.length, 0, 'null input → empty contacts');
|
||||
|
||||
// Order is preserved: the export is WYSIWYG w.r.t. the table's current sort.
|
||||
const two = buildContacts([
|
||||
Object.assign({}, repeater, { name: 'first' }),
|
||||
Object.assign({}, repeater, { name: 'second', public_key: PK.replace(/^efef/, 'aaaa') }),
|
||||
]);
|
||||
// Joined, not deepStrictEqual: arrays built inside the vm sandbox have a
|
||||
// different Array prototype and would fail the realm check.
|
||||
assert.strictEqual(two.contacts.map(function (x) { return x.name; }).join(','), 'first,second',
|
||||
'input order is preserved');
|
||||
|
||||
// ─── Filename ────────────────────────────────────────────────────────────────
|
||||
const d = new Date(2026, 7, 12, 16, 5, 17); // local time, 2026-08-12 16:05:17
|
||||
assert.strictEqual(filename('BE-LIM', d), 'corescope_nodes_BE-LIM_2026-08-12-160517.json');
|
||||
assert.strictEqual(filename(null, d), 'corescope_nodes_all_2026-08-12-160517.json',
|
||||
'no area selected → "all"');
|
||||
assert.strictEqual(filename('', d), 'corescope_nodes_all_2026-08-12-160517.json');
|
||||
assert.strictEqual(filename('NL / Zuid', d), 'corescope_nodes_NL_Zuid_2026-08-12-160517.json',
|
||||
'unsafe filename chars are collapsed to underscores');
|
||||
|
||||
console.log('nodes-export mapping, skip rules and filename OK');
|
||||
Reference in new issue
Block a user