ui: amber pending-edit markers for unapplied config changes
Add hoover/dirty.js: line-matching helpers that flag UI rows/cards edited (saved to config) but not yet applied, consuming the pending state the daemon already streams — status.pending_diff for hash subsystems, firewall pending zone+type for firewalld. Visual language is amber (.config-dirty + PendingDot), distinct from the red .pending-delete; orphanInfo surfaces removed entries (e.g. WireGuard peers) on their container table. Wired into the backends, dhcp, interfaces, nat, proxy, rules, wireguard, and zones pages; Card and Table gain cls/title props. Covered by 27 node tests (tests/test-dirty.js).
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* 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;
|
||||
})();
|
||||
Reference in New Issue
Block a user