diff --git a/public/map.js b/public/map.js index 5016d72..1b98715 100644 --- a/public/map.js +++ b/public/map.js @@ -441,7 +441,7 @@ for (const n of nodes) { const role = (n.role || 'companion').toLowerCase(); if (!roleCounts[role]) roleCounts[role] = { active: 0, stale: 0 }; - const lastMs = n.last_seen ? new Date(n.last_seen).getTime() : 0; + const lastMs = (n.last_heard || n.last_seen) ? new Date(n.last_heard || n.last_seen).getTime() : 0; const status = getNodeStatus(role, lastMs); roleCounts[role][status]++; } @@ -584,7 +584,7 @@ // Status filter if (filters.statusFilter !== 'all') { const role = (n.role || 'companion').toLowerCase(); - const lastMs = n.last_seen ? new Date(n.last_seen).getTime() : 0; + const lastMs = (n.last_heard || n.last_seen) ? new Date(n.last_heard || n.last_seen).getTime() : 0; const status = getNodeStatus(role, lastMs); if (status !== filters.statusFilter) return false; } @@ -594,7 +594,8 @@ const allMarkers = []; for (const node of filtered) { - const isStale = getNodeStatus(node.role || 'companion', node.last_seen ? new Date(node.last_seen).getTime() : 0) === 'stale'; + const lastSeenTime = node.last_heard || node.last_seen; + const isStale = getNodeStatus(node.role || 'companion', lastSeenTime ? new Date(lastSeenTime).getTime() : 0) === 'stale'; const useLabel = node.role === 'repeater' && filters.hashLabels; const icon = useLabel ? makeRepeaterLabelIcon(node, isStale) : makeMarkerIcon(node.role || 'companion', isStale); const latLng = L.latLng(node.lat, node.lon); diff --git a/public/nodes.js b/public/nodes.js index 08d2b86..be018c2 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -629,7 +629,8 @@ tbody.innerHTML = sorted.map(n => { const roleColor = ROLE_COLORS[n.role] || '#6b7280'; const isClaimed = myKeys.has(n.public_key); - const status = getNodeStatus(n.role || 'companion', n.last_seen ? new Date(n.last_seen).getTime() : 0); + const lastSeenTime = n.last_heard || n.last_seen; + const status = getNodeStatus(n.role || 'companion', lastSeenTime ? new Date(lastSeenTime).getTime() : 0); const lastSeenClass = status === 'active' ? 'last-seen-active' : 'last-seen-stale'; return ` ${favStar(n.public_key, 'node-fav')}${isClaimed ? ' ' : ''}${n.name || '(unnamed)'} diff --git a/test-aging.js b/test-aging.js new file mode 100644 index 0000000..eb8801b --- /dev/null +++ b/test-aging.js @@ -0,0 +1,189 @@ +/* Unit tests for node aging system */ +'use strict'; +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +// Load roles.js in a sandboxed context +const ctx = { window: {}, console, Date, Infinity, document: { readyState: 'complete', createElement: () => ({ id: '' }), head: { appendChild: () => {} }, getElementById: () => null, addEventListener: () => {} }, fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }) }; +vm.createContext(ctx); +vm.runInContext(fs.readFileSync('public/roles.js', 'utf8'), ctx); + +// The IIFE assigns to window.*, but the functions reference HEALTH_THRESHOLDS as a bare global +// In the VM context, window.X doesn't create a global X, so we need to copy them +for (const k of Object.keys(ctx.window)) { + ctx[k] = ctx.window[k]; +} + +const { getNodeStatus, getHealthThresholds, HEALTH_THRESHOLDS } = ctx.window; + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(` ✅ ${name}`); } + catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); } +} + +console.log('\n=== HEALTH_THRESHOLDS ==='); +test('infraSilentMs = 72h (259200000)', () => assert.strictEqual(HEALTH_THRESHOLDS.infraSilentMs, 259200000)); +test('nodeSilentMs = 24h (86400000)', () => assert.strictEqual(HEALTH_THRESHOLDS.nodeSilentMs, 86400000)); + +console.log('\n=== getHealthThresholds ==='); +test('repeater uses infra thresholds', () => { + const t = getHealthThresholds('repeater'); + assert.strictEqual(t.silentMs, 259200000); +}); +test('room uses infra thresholds', () => { + const t = getHealthThresholds('room'); + assert.strictEqual(t.silentMs, 259200000); +}); +test('companion uses node thresholds', () => { + const t = getHealthThresholds('companion'); + assert.strictEqual(t.silentMs, 86400000); +}); + +console.log('\n=== getNodeStatus ==='); +const now = Date.now(); +const h = 3600000; + +test('repeater seen 1h ago → active', () => assert.strictEqual(getNodeStatus('repeater', now - 1*h), 'active')); +test('repeater seen 71h ago → active', () => assert.strictEqual(getNodeStatus('repeater', now - 71*h), 'active')); +test('repeater seen 73h ago → stale', () => assert.strictEqual(getNodeStatus('repeater', now - 73*h), 'stale')); +test('room seen 73h ago → stale (same as repeater)', () => assert.strictEqual(getNodeStatus('room', now - 73*h), 'stale')); +test('companion seen 1h ago → active', () => assert.strictEqual(getNodeStatus('companion', now - 1*h), 'active')); +test('companion seen 23h ago → active', () => assert.strictEqual(getNodeStatus('companion', now - 23*h), 'active')); +test('companion seen 25h ago → stale', () => assert.strictEqual(getNodeStatus('companion', now - 25*h), 'stale')); +test('sensor seen 25h ago → stale', () => assert.strictEqual(getNodeStatus('sensor', now - 25*h), 'stale')); +test('unknown role → uses node (24h) threshold', () => assert.strictEqual(getNodeStatus('unknown', now - 25*h), 'stale')); +test('unknown role seen 23h ago → active', () => assert.strictEqual(getNodeStatus('unknown', now - 23*h), 'active')); +test('null lastSeenMs → stale', () => assert.strictEqual(getNodeStatus('repeater', null), 'stale')); +test('undefined lastSeenMs → stale', () => assert.strictEqual(getNodeStatus('repeater', undefined), 'stale')); +test('0 lastSeenMs → stale', () => assert.strictEqual(getNodeStatus('repeater', 0), 'stale')); + +// === getStatusInfo tests (inline since nodes.js has too many DOM deps) === +console.log('\n=== getStatusInfo (logic validation) ==='); + +// Simulate getStatusInfo logic +function mockGetStatusInfo(n) { + const ROLE_COLORS = ctx.window.ROLE_COLORS; + const role = (n.role || '').toLowerCase(); + const roleColor = ROLE_COLORS[n.role] || '#6b7280'; + const lastHeardTime = n._lastHeard || n.last_heard || n.last_seen; + const lastHeardMs = lastHeardTime ? new Date(lastHeardTime).getTime() : 0; + const status = getNodeStatus(role, lastHeardMs); + const statusLabel = status === 'active' ? '🟢 Active' : '⚪ Stale'; + const isInfra = role === 'repeater' || role === 'room'; + + let explanation = ''; + if (status === 'active') { + explanation = 'Last heard recently'; + } else { + const reason = isInfra + ? 'repeaters typically advertise every 12-24h' + : 'companions only advertise when user initiates, this may be normal'; + explanation = 'Not heard — ' + reason; + } + return { status, statusLabel, roleColor, explanation, role }; +} + +test('active repeater → 🟢 Active, red color', () => { + const info = mockGetStatusInfo({ role: 'repeater', last_seen: new Date(now - 1*h).toISOString() }); + assert.strictEqual(info.status, 'active'); + assert.strictEqual(info.statusLabel, '🟢 Active'); + assert.strictEqual(info.roleColor, '#dc2626'); +}); + +test('stale companion → ⚪ Stale, explanation mentions "this may be normal"', () => { + const info = mockGetStatusInfo({ role: 'companion', last_seen: new Date(now - 25*h).toISOString() }); + assert.strictEqual(info.status, 'stale'); + assert.strictEqual(info.statusLabel, '⚪ Stale'); + assert(info.explanation.includes('this may be normal'), 'should mention "this may be normal"'); +}); + +test('missing last_seen → stale', () => { + const info = mockGetStatusInfo({ role: 'repeater' }); + assert.strictEqual(info.status, 'stale'); +}); + +test('missing role → defaults to empty string, uses node threshold', () => { + const info = mockGetStatusInfo({ last_seen: new Date(now - 25*h).toISOString() }); + assert.strictEqual(info.status, 'stale'); + assert.strictEqual(info.roleColor, '#6b7280'); +}); + +test('prefers last_heard over last_seen', () => { + // last_seen is stale, but last_heard is recent + const info = mockGetStatusInfo({ + role: 'companion', + last_seen: new Date(now - 48*h).toISOString(), + last_heard: new Date(now - 1*h).toISOString() + }); + assert.strictEqual(info.status, 'active'); +}); + +// === getStatusTooltip tests === +console.log('\n=== getStatusTooltip ==='); + +// Load from nodes.js by extracting the function +// Since nodes.js is complex, I'll re-implement the tooltip function for testing +function getStatusTooltip(role, status) { + const isInfra = role === 'repeater' || role === 'room'; + const threshold = isInfra ? '72h' : '24h'; + if (status === 'active') { + return 'Active — heard within the last ' + threshold + '.' + (isInfra ? ' Repeaters typically advertise every 12-24h.' : ''); + } + if (role === 'companion') { + return 'Stale — not heard for over ' + threshold + '. Companions only advertise when the user initiates — this may be normal.'; + } + if (role === 'sensor') { + return 'Stale — not heard for over ' + threshold + '. This sensor may be offline.'; + } + return 'Stale — not heard for over ' + threshold + '. This ' + role + ' may be offline or out of range.'; +} + +test('active repeater mentions "72h" and "advertise every 12-24h"', () => { + const tip = getStatusTooltip('repeater', 'active'); + assert(tip.includes('72h'), 'should mention 72h'); + assert(tip.includes('advertise every 12-24h'), 'should mention advertise frequency'); +}); + +test('active companion mentions "24h"', () => { + const tip = getStatusTooltip('companion', 'active'); + assert(tip.includes('24h'), 'should mention 24h'); +}); + +test('stale companion mentions "24h" and "user initiates"', () => { + const tip = getStatusTooltip('companion', 'stale'); + assert(tip.includes('24h'), 'should mention 24h'); + assert(tip.includes('user initiates'), 'should mention user initiates'); +}); + +test('stale repeater mentions "offline or out of range"', () => { + const tip = getStatusTooltip('repeater', 'stale'); + assert(tip.includes('offline or out of range'), 'should mention offline or out of range'); +}); + +test('stale sensor mentions "sensor may be offline"', () => { + const tip = getStatusTooltip('sensor', 'stale'); + assert(tip.includes('sensor may be offline')); +}); + +test('stale room uses 72h threshold', () => { + const tip = getStatusTooltip('room', 'stale'); + assert(tip.includes('72h')); +}); + +// === Bug check: renderRows uses last_seen instead of last_heard || last_seen === +console.log('\n=== BUG CHECK ==='); +const nodesJs = fs.readFileSync('public/nodes.js', 'utf8'); +const renderRowsMatch = nodesJs.match(/const status = getNodeStatus\(n\.role[^;]+/); +if (renderRowsMatch) { + const line = renderRowsMatch[0]; + console.log(` renderRows status line: ${line}`); + if (!line.includes('last_heard')) { + console.log(' 🐛 BUG: renderRows() uses only n.last_seen, ignoring n.last_heard!'); + console.log(' Should be: n.last_heard || n.last_seen'); + } +} + +console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`); +process.exit(failed > 0 ? 1 : 0);