/** * Tests for hoover/dirty.js — pending-edit marker matching. * * dirty.js has no imports — DOM-free at import, so the tests run under * plain node (same pattern as test-model-set.js). * * Run with `node tests/test-dirty.js`. */ import { dirtySet, isDirty, dirtyTitle, dirtyInfo, orphanInfo, fwDirty, fwIsDirty, fwTitle, fwInfo } from '../webui/static/hoover/dirty.js'; let passed = 0; let failed = 0; const tests = []; function test(name, fn) { tests.push({ name, fn }); } function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion failed'); } function assertEq(a, b, msg) { if (a !== b) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`); } /* ── dirtySet ────────────────────────────────────────────────── */ test('dirtySet collects pending paths from pending_diff', () => { const set = dirtySet({ pending_diff: [ { path: 'dhcp.ranges[0].start', action: 'changed' }, { path: 'dns.domain', action: 'added' }, ]}); assert(set.has('dhcp.ranges[0].start'), 'first path collected'); assert(set.has('dns.domain'), 'second path collected'); assertEq(set.size, 2, 'exactly two paths'); }); test('dirtySet skips diff entries without a path', () => { const set = dirtySet({ pending_diff: [null, {}, { action: 'changed' }, { path: 'a.b' }] }); assertEq(set.size, 1, 'only well-formed entries'); assert(set.has('a.b'), 'valid path collected'); }); test('dirtySet is empty when pending_diff is absent', () => { assertEq(dirtySet(null).size, 0, 'null status'); assertEq(dirtySet({}).size, 0, 'empty status'); assertEq(dirtySet({ pending_diff: 'nope' }).size, 0, 'non-array pending_diff'); }); /* ── never-applied sentinel ──────────────────────────────────── */ test('dirtySet marks everything dirty when saved but never applied', () => { const set = dirtySet({ pending_changes: true, pending_diff: [] }); assertEq(set.size, 1, 'sentinel only'); assert(isDirty(set, 'dhcp.ranges[0].start'), 'any path is dirty'); assert(isDirty(set, 'interface.listen_port'), 'any other path is dirty'); assertEq(dirtyTitle(set, 'dhcp.ranges[0].start'), 'Configuration saved but not applied yet', 'sentinel tooltip'); }); test('dirtySet has no sentinel when there is no pending state', () => { const set = dirtySet({ pending_changes: false, pending_diff: [] }); assert(!isDirty(set, 'dhcp.ranges'), 'clean when nothing is pending'); assertEq(dirtyTitle(set, 'dhcp.ranges'), '', 'no tooltip when clean'); }); test('dirtySet has no sentinel when a real diff exists', () => { const set = dirtySet({ pending_changes: true, pending_diff: [{ path: 'dns.domain', action: 'changed' }], }); assert(isDirty(set, 'dns.domain'), 'matching path is dirty'); assert(!isDirty(set, 'dhcp.ranges'), 'unrelated path stays clean'); assertEq(dirtyTitle(set, 'dns.domain'), 'Unapplied changes: dns.domain', 'normal tooltip, not the sentinel'); }); /* ── line matching ───────────────────────────────────────────── */ test('isDirty matches an exact pending leaf', () => { const set = dirtySet({ pending_diff: [{ path: 'interface.listen_port', action: 'changed' }] }); assert(isDirty(set, 'interface.listen_port'), 'equal path is dirty'); }); test('a pending list marks every indexed row (ancestor of element)', () => { const set = dirtySet({ pending_diff: [{ path: 'dhcp.ranges', action: 'changed' }] }); for (const i of [0, 1, 12]) { assert(isDirty(set, `dhcp.ranges[${i}]`), `row ${i} is dirty`); assert(isDirty(set, `dhcp.ranges[${i}].start`), `row ${i} field is dirty`); } }); test('a pending row field marks the list (descendant of element)', () => { const set = dirtySet({ pending_diff: [{ path: 'dhcp.ranges[0].start', action: 'changed' }] }); assert(isDirty(set, 'dhcp.ranges'), 'the list container is dirty'); assert(isDirty(set, 'dhcp'), 'the top-level container is dirty'); }); test('unrelated paths do not match', () => { const set = dirtySet({ pending_diff: [{ path: 'dns.domain', action: 'changed' }] }); assert(!isDirty(set, 'dhcp.ranges'), 'different root'); }); test('index brackets do not prefix-match across digits', () => { const set = dirtySet({ pending_diff: [{ path: 'dhcp.ranges[1]', action: 'changed' }] }); assert(!isDirty(set, 'dhcp.ranges[12]'), 'ranges[1] must not mark row 12'); assert(!isDirty(set, 'dhcp.ranges[10]'), 'ranges[1] must not mark row 10'); assert(isDirty(set, 'dhcp.ranges[1]'), 'the exact row is dirty'); }); test('plain keys do not prefix-match similar names', () => { const set = dirtySet({ pending_diff: [{ path: 'interface.listen_port', action: 'changed' }] }); assert(!isDirty(set, 'interfaces.eth0'), 'interface must not mark interfaces.eth0'); assert(!isDirty(set, 'interface2.port'), 'interface must not mark interface2'); }); test('isDirty is false for an empty or missing set', () => { assert(!isDirty(new Set(), 'a.b'), 'empty set'); assert(!isDirty(null, 'a.b'), 'null set'); assert(!isDirty(dirtySet({}), 'a.b'), 'status with no pending'); }); test('isDirty tolerates an empty path', () => { const set = dirtySet({ pending_diff: [{ path: 'a.b', action: 'changed' }] }); assert(!isDirty(set, ''), 'empty element path is not dirty'); assert(!isDirty(set, null), 'null element path is not dirty'); }); /* ── dirtyTitle / dirtyInfo ──────────────────────────────────── */ test('dirtyTitle lists all matching pending paths sorted', () => { const set = dirtySet({ pending_diff: [ { path: 'dhcp.ranges[1].start', action: 'changed' }, { path: 'dhcp.ranges[0].end', action: 'changed' }, { path: 'dns.domain', action: 'changed' }, ]}); assertEq( dirtyTitle(set, 'dhcp.ranges'), 'Unapplied changes: dhcp.ranges[0].end, dhcp.ranges[1].start', 'both rows listed, sorted, unrelated path excluded', ); }); test('dirtyTitle is empty when the element is clean', () => { const set = dirtySet({ pending_diff: [{ path: 'dns.domain', action: 'changed' }] }); assertEq(dirtyTitle(set, 'dhcp.ranges'), '', 'no tooltip for unrelated element'); }); test('dirtyInfo returns the full marker object', () => { const set = dirtySet({ pending_diff: [{ path: 'dns.domain', action: 'changed' }] }); const hit = dirtyInfo(set, 'dns.domain'); assertEq(hit.dirty, true, 'dirty flag'); assertEq(hit.class, 'config-dirty', 'class'); assertEq(hit.title, 'Unapplied changes: dns.domain', 'tooltip'); const miss = dirtyInfo(set, 'dhcp.ranges'); assertEq(miss.dirty, false, 'clean flag'); assertEq(miss.class, '', 'clean class'); assertEq(miss.title, '', 'clean title'); }); /* ── orphanInfo (removed dict keys) ──────────────────────────── */ test('orphanInfo flags a removed peer with no live row', () => { const set = dirtySet({ pending_diff: [{ path: 'peers.p1', action: 'removed' }] }); const info = orphanInfo(set, 'peers', ['peers.p2', 'peers.p3']); assertEq(info.dirty, true, 'orphan is dirty'); assertEq(info.class, 'config-dirty', 'orphan class'); assertEq(info.title, 'Unapplied changes: peers.p1', 'orphan tooltip'); }); test('orphanInfo is clean when the pending path still has a live row', () => { const set = dirtySet({ pending_diff: [{ path: 'peers.p1.endpoint', action: 'changed' }] }); assertEq(orphanInfo(set, 'peers', ['peers.p1', 'peers.p2']).dirty, false, 'matched child is not an orphan'); }); test('orphanInfo flags a removed peer when no peers remain', () => { const set = dirtySet({ pending_diff: [{ path: 'peers.p1', action: 'removed' }] }); assertEq(orphanInfo(set, 'peers', []).dirty, true, 'no children means the orphan stands'); }); test('orphanInfo ignores pending paths outside the root', () => { const set = dirtySet({ pending_diff: [{ path: 'interface.listen_port', action: 'changed' }] }); assertEq(orphanInfo(set, 'peers', ['peers.p1']).dirty, false, 'unrelated root'); }); test('orphanInfo is clean when the root itself is pending', () => { // A whole-dict `peers` change marks every child row instead; the // container-level marker would be redundant. const set = dirtySet({ pending_diff: [{ path: 'peers', action: 'changed' }] }); assertEq(orphanInfo(set, 'peers', ['peers.p1']).dirty, false, 'root-pending is not an orphan'); assert(isDirty(set, 'peers.p1'), 'but the rows are still marked'); }); test('orphanInfo is clean for an empty set or the never-applied sentinel', () => { assertEq(orphanInfo(new Set(), 'peers', []).dirty, false, 'empty set'); const sentinel = dirtySet({ pending_changes: true, pending_diff: [] }); assertEq(orphanInfo(sentinel, 'peers', []).dirty, false, 'sentinel: element markers already cover it'); }); test('orphanInfo lists multiple orphans sorted', () => { const set = dirtySet({ pending_diff: [ { path: 'peers.b', action: 'removed' }, { path: 'peers.a', action: 'removed' }, { path: 'peers.c.field', action: 'changed' }, ]}); const info = orphanInfo(set, 'peers', ['peers.c']); assertEq(info.title, 'Unapplied changes: peers.a, peers.b', 'only the orphans, sorted'); }); /* ── firewall zone + type granularity ────────────────────────── */ test('fwDirty builds a zone-to-types map', () => { const m = fwDirty({ pending: [ { zone: 'public', type: 'services' }, { zone: 'public', type: 'rich_rules' }, { zone: 'dmz', type: 'interfaces' }, { zone: null }, { zone: 'lan' }, ], }); assertEq(m.size, 3, 'three zones (null-zone entry skipped, typeless zone kept)'); assert(m.get('public').has('services'), 'public services'); assert(m.get('public').has('rich_rules'), 'public rich_rules'); assert(m.get('dmz').has('interfaces'), 'dmz interfaces'); assert(m.get('lan').size === 0, 'typeless zone has an empty type set'); }); test('fwIsDirty by zone and by zone+type', () => { const m = fwDirty({ pending: [{ zone: 'public', type: 'services' }] }); assert(fwIsDirty(m, 'public'), 'zone-only match'); assert(fwIsDirty(m, 'public', 'services'), 'zone+type match'); assert(!fwIsDirty(m, 'public', 'rich_rules'), 'wrong type'); assert(!fwIsDirty(m, 'dmz'), 'unknown zone'); assert(!fwIsDirty(new Map(), 'public'), 'empty map'); }); test('fwInfo and fwTitle carry the pending types', () => { const m = fwDirty({ pending: [ { zone: 'public', type: 'rich_rules' }, { zone: 'public', type: 'services' }, ], }); const zone = fwInfo(m, 'public'); assertEq(zone.dirty, true, 'zone dirty'); assertEq(zone.class, 'config-dirty', 'zone class'); assertEq(zone.title, 'Unapplied changes: rich_rules, services', 'zone tooltip lists all types'); const typed = fwInfo(m, 'public', 'services'); assertEq(typed.title, 'Unapplied changes: services', 'typed tooltip lists only that type'); assertEq(fwInfo(m, 'dmz').dirty, false, 'unknown zone clean'); assertEq(fwTitle(m, 'nope'), '', 'no tooltip for unknown zone'); }); /* ── Runner ──────────────────────────────────────────────────── */ (async () => { for (const { name, fn } of tests) { try { await fn(); console.log(` \u2713 ${name}`); passed++; } catch (e) { console.error(` \u2717 ${name}: ${e.message}`); failed++; } } console.log(`${passed + failed} tests: ${passed} passed, ${failed} failed`); process.exitCode = failed ? 1 : 0; })();