Files
meshcore-analyzer/test-customizer-v2.js
Kpa-clawbotandyou 64745f89b1 feat: customizer v2 — event-driven state management (#502) (#503)
## Summary

Implements the customizer v2 per the [approved
spec](docs/specs/customizer-rework.md), replacing the v1 customizer's
scattered state management with a clean event-driven architecture.
Resolves #502.

## What Changed

### New: `public/customize-v2.js`
Complete rewrite of the customizer as a self-contained IIFE with:

- **Single localStorage key** (`cs-theme-overrides`) replacing 7
scattered keys
- **Three state layers:** server defaults (immutable) → user overrides
(delta) → effective config (computed)
- **Full data flow pipeline:** `write → read-back → merge → atomic
SITE_CONFIG assign → apply CSS → dispatch theme-changed`
- **Color picker optimistic CSS** (Decision #12): `input` events update
CSS directly for responsiveness; `change` events trigger the full
pipeline
- **Override indicator dots** (●) on each field — click to reset
individual values
- **Section-level override count badges** on tabs
- **Browser-local banner** in panel header: "These settings are saved in
your browser only"
- **Auto-save status indicator** in footer: "All changes saved" /
"Saving..." / "⚠️ Storage full"
- **Export/Import** with full shape validation (`validateShape()`)
- **Presets** flow through the standard pipeline
(`writeOverrides(presetData) → pipeline`)
- **One-time migration** from 7 legacy localStorage keys (exact field
mapping per spec)
- **Validation** on all writes: color format, opacity range, timestamp
enum values
- **QuotaExceededError handling** with visible user warning

### Modified: `public/app.js`
Replaced ~80 lines of inline theme application code with a 15-line
`_customizerV2.init(cfg)` call. The customizer v2 handles all merging,
CSS application, and global state updates.

### Modified: `public/index.html`
Swapped `customize.js` → `customize-v2.js` script tag.

### Added: `docs/specs/customizer-rework.md`
The full approved spec, included in the repo for reference.

## Migration

On first page load:
1. Checks if `cs-theme-overrides` already exists → skip if yes
2. Reads all 7 legacy keys (`meshcore-user-theme`,
`meshcore-timestamp-*`, `meshcore-heatmap-opacity`,
`meshcore-live-heatmap-opacity`)
3. Maps them to the new delta format per the spec's field-by-field
mapping
4. Writes to `cs-theme-overrides`, removes all legacy keys
5. Continues with normal init

Users with existing customizations will see them preserved
automatically.

## Dark/Light Mode

- `theme` section stores light mode overrides, `themeDark` stores dark
mode overrides
- `meshcore-theme` localStorage key remains **separate** (view
preference, not customization)
- Switching modes re-runs the full pipeline with the correct section

## Testing

- All existing tests pass (`test-packet-filter.js`, `test-aging.js`,
`test-frontend-helpers.js`)
- Old `customize.js` is NOT modified — left in place for reference but
no longer loaded

## Not in Scope (per spec)

- Undo/redo stack
- Cross-tab synchronization
- Server-side admin import endpoint
- Map config / geo-filter overrides

---------

Co-authored-by: you <you@example.com>
2026-04-02 21:14:38 -07:00

409 lines
15 KiB
JavaScript

/* Unit tests for customizer v2 core functions */
'use strict';
const vm = require('vm');
const fs = require('fs');
const assert = require('assert');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(` ✅ ${name}`); }
catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); }
}
function makeSandbox() {
const storage = {};
const localStorage = {
_data: storage,
getItem(k) { return k in storage ? storage[k] : null; },
setItem(k, v) { storage[k] = String(v); },
removeItem(k) { delete storage[k]; },
clear() { for (const k in storage) delete storage[k]; }
};
const ctx = {
window: {
addEventListener: () => {},
dispatchEvent: () => {},
SITE_CONFIG: {},
_SITE_CONFIG_ORIGINAL_HOME: null,
},
document: {
readyState: 'loading',
createElement: (tag) => ({
id: '', textContent: '', innerHTML: '', className: '',
setAttribute: () => {}, appendChild: () => {},
style: {}, addEventListener: () => {},
querySelectorAll: () => [], querySelector: () => null,
}),
head: { appendChild: () => {} },
getElementById: () => null,
addEventListener: () => {},
querySelectorAll: () => [],
querySelector: () => null,
documentElement: {
style: { setProperty: () => {}, removeProperty: () => {}, getPropertyValue: () => '' },
dataset: { theme: 'dark' },
getAttribute: () => 'dark',
},
},
console,
localStorage,
setTimeout: (fn) => fn(),
clearTimeout: () => {},
Date, Math, Array, Object, JSON, String, Number, Boolean,
parseInt, parseFloat, isNaN, Infinity, NaN, undefined,
MutationObserver: class { observe() {} },
HashChangeEvent: class {},
CustomEvent: class CustomEvent { constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; } },
getComputedStyle: () => ({ getPropertyValue: () => '' }),
};
ctx.window.localStorage = localStorage;
ctx.self = ctx.window;
return ctx;
}
function loadCustomizer() {
const ctx = makeSandbox();
const code = fs.readFileSync('public/customize-v2.js', 'utf8');
vm.createContext(ctx);
vm.runInContext(code, ctx, { filename: 'customize-v2.js' });
return { ctx, api: ctx.window._customizerV2, ls: ctx.localStorage };
}
console.log('\n📋 Customizer V2 — Core Function Tests\n');
// ── readOverrides ──
console.log('readOverrides:');
test('returns {} when key is absent', () => {
const { api } = loadCustomizer();
const result = api.readOverrides();
assert.strictEqual(JSON.stringify(result), '{}');
});
test('returns {} when key contains invalid JSON', () => {
const { api, ls } = loadCustomizer();
ls.setItem('cs-theme-overrides', 'not json{{{');
assert.strictEqual(JSON.stringify(api.readOverrides()), '{}');
});
test('returns {} when key contains a non-object (string)', () => {
const { api, ls } = loadCustomizer();
ls.setItem('cs-theme-overrides', '"just a string"');
assert.strictEqual(JSON.stringify(api.readOverrides()), '{}');
});
test('returns {} when key contains an array', () => {
const { api, ls } = loadCustomizer();
ls.setItem('cs-theme-overrides', '[1,2,3]');
assert.strictEqual(JSON.stringify(api.readOverrides()), '{}');
});
test('returns {} when key contains a number', () => {
const { api, ls } = loadCustomizer();
ls.setItem('cs-theme-overrides', '42');
assert.strictEqual(JSON.stringify(api.readOverrides()), '{}');
});
test('returns parsed object when valid', () => {
const { api, ls } = loadCustomizer();
const data = { theme: { accent: '#ff0000' } };
ls.setItem('cs-theme-overrides', JSON.stringify(data));
assert.deepStrictEqual(api.readOverrides(), data);
});
// ── writeOverrides ──
console.log('\nwriteOverrides:');
test('writes serialized JSON to localStorage', () => {
const { api, ls } = loadCustomizer();
const data = { theme: { accent: '#ff0000' } };
api.writeOverrides(data);
assert.deepStrictEqual(JSON.parse(ls.getItem('cs-theme-overrides')), data);
});
test('removes key when delta is empty {}', () => {
const { api, ls } = loadCustomizer();
ls.setItem('cs-theme-overrides', '{"theme":{}}');
api.writeOverrides({});
assert.strictEqual(ls.getItem('cs-theme-overrides'), null);
});
test('round-trips correctly (write → read = identical)', () => {
const { api } = loadCustomizer();
const data = { theme: { accent: '#abc', text: '#def' }, nodeColors: { repeater: '#111' } };
api.writeOverrides(data);
assert.deepStrictEqual(api.readOverrides(), data);
});
test('strips invalid color values silently', () => {
const { api, ls } = loadCustomizer();
api.writeOverrides({ theme: { accent: 'not-a-color' } });
// Invalid color is stripped by _validateDelta; remaining empty object is stored as '{}'
const stored = JSON.parse(ls.getItem('cs-theme-overrides'));
assert.strictEqual(stored.theme, undefined);
});
test('strips out-of-range opacity', () => {
const { api, ls } = loadCustomizer();
api.writeOverrides({ heatmapOpacity: 1.5 });
const stored1 = JSON.parse(ls.getItem('cs-theme-overrides'));
assert.strictEqual(stored1.heatmapOpacity, undefined);
api.writeOverrides({ heatmapOpacity: -0.1 });
const stored2 = JSON.parse(ls.getItem('cs-theme-overrides'));
assert.strictEqual(stored2.heatmapOpacity, undefined);
});
test('accepts valid opacity', () => {
const { api, ls } = loadCustomizer();
api.writeOverrides({ heatmapOpacity: 0.5 });
const stored = JSON.parse(ls.getItem('cs-theme-overrides'));
assert.strictEqual(stored.heatmapOpacity, 0.5);
});
// ── computeEffective ──
console.log('\ncomputeEffective:');
test('returns server defaults when overrides is {}', () => {
const { api } = loadCustomizer();
const defaults = { theme: { accent: '#aaa', text: '#bbb' }, nodeColors: { repeater: '#ccc' } };
const result = api.computeEffective(defaults, {});
assert.deepStrictEqual(result, defaults);
});
test('overrides a single key in a section', () => {
const { api } = loadCustomizer();
const defaults = { theme: { accent: '#aaa', text: '#bbb' } };
const result = api.computeEffective(defaults, { theme: { accent: '#ff0000' } });
assert.strictEqual(result.theme.accent, '#ff0000');
assert.strictEqual(result.theme.text, '#bbb');
});
test('overrides multiple keys across sections', () => {
const { api } = loadCustomizer();
const defaults = { theme: { accent: '#aaa' }, nodeColors: { repeater: '#bbb' } };
const result = api.computeEffective(defaults, { theme: { accent: '#111' }, nodeColors: { repeater: '#222' } });
assert.strictEqual(result.theme.accent, '#111');
assert.strictEqual(result.nodeColors.repeater, '#222');
});
test('does not mutate either input', () => {
const { api } = loadCustomizer();
const defaults = { theme: { accent: '#aaa' } };
const overrides = { theme: { accent: '#bbb' } };
const defCopy = JSON.stringify(defaults);
const ovrCopy = JSON.stringify(overrides);
api.computeEffective(defaults, overrides);
assert.strictEqual(JSON.stringify(defaults), defCopy);
assert.strictEqual(JSON.stringify(overrides), ovrCopy);
});
test('handles missing sections in overrides gracefully', () => {
const { api } = loadCustomizer();
const defaults = { theme: { accent: '#aaa' }, nodeColors: { repeater: '#bbb' } };
const result = api.computeEffective(defaults, { theme: { accent: '#ccc' } });
assert.strictEqual(result.nodeColors.repeater, '#bbb');
});
test('array values in home are fully replaced, not merged', () => {
const { api } = loadCustomizer();
const defaults = { home: { steps: [{ emoji: '1', title: 'a', description: 'b' }], heroTitle: 'X' } };
const overrides = { home: { steps: [{ emoji: '2', title: 'c', description: 'd' }, { emoji: '3', title: 'e', description: 'f' }] } };
const result = api.computeEffective(defaults, overrides);
assert.strictEqual(result.home.steps.length, 2);
assert.strictEqual(result.home.steps[0].emoji, '2');
assert.strictEqual(result.home.heroTitle, 'X'); // untouched
});
test('top-level scalars are directly replaced', () => {
const { api } = loadCustomizer();
const defaults = { heatmapOpacity: 0.5 };
const result = api.computeEffective(defaults, { heatmapOpacity: 0.8 });
assert.strictEqual(result.heatmapOpacity, 0.8);
});
// ── validateShape ──
console.log('\nvalidateShape:');
test('accepts valid delta objects', () => {
const { api } = loadCustomizer();
const result = api.validateShape({ theme: { accent: '#fff' }, heatmapOpacity: 0.5 });
assert.strictEqual(result.valid, true);
});
test('accepts empty object', () => {
const { api } = loadCustomizer();
assert.strictEqual(api.validateShape({}).valid, true);
});
test('rejects non-objects (string)', () => {
const { api } = loadCustomizer();
assert.strictEqual(api.validateShape('hello').valid, false);
});
test('rejects non-objects (array)', () => {
const { api } = loadCustomizer();
assert.strictEqual(api.validateShape([1, 2]).valid, false);
});
test('rejects non-objects (null)', () => {
const { api } = loadCustomizer();
assert.strictEqual(api.validateShape(null).valid, false);
});
test('warns on unknown top-level keys', () => {
const { api } = loadCustomizer();
const result = api.validateShape({ unknownKey: {} });
// Unknown keys produce a console.warn but validateShape still returns valid
assert.strictEqual(result.valid, true);
assert.strictEqual(result.errors.length, 0);
});
test('validates section types (rejects non-object section)', () => {
const { api } = loadCustomizer();
const result = api.validateShape({ theme: 'not an object' });
assert.strictEqual(result.valid, false);
});
test('accepts valid rgb() color values in theme', () => {
const { api } = loadCustomizer();
const result = api.validateShape({ theme: { accent: 'rgb(1,2,3)' } });
assert.strictEqual(result.valid, true);
});
test('rejects out-of-range opacity values', () => {
const { api } = loadCustomizer();
assert.strictEqual(api.validateShape({ heatmapOpacity: 2.0 }).valid, false);
assert.strictEqual(api.validateShape({ liveHeatmapOpacity: -1 }).valid, false);
});
// ── migrateOldKeys ──
console.log('\nmigrateOldKeys:');
test('migrates all 7 keys correctly', () => {
const { api, ls } = loadCustomizer();
ls.setItem('meshcore-user-theme', JSON.stringify({ theme: { accent: '#f00' }, branding: { siteName: 'Test' } }));
ls.setItem('meshcore-timestamp-mode', 'absolute');
ls.setItem('meshcore-timestamp-timezone', 'utc');
ls.setItem('meshcore-timestamp-format', 'iso-seconds');
ls.setItem('meshcore-timestamp-custom-format', 'YYYY-MM-DD');
ls.setItem('meshcore-heatmap-opacity', '0.7');
ls.setItem('meshcore-live-heatmap-opacity', '0.3');
const result = api.migrateOldKeys();
assert.strictEqual(result.theme.accent, '#f00');
assert.strictEqual(result.branding.siteName, 'Test');
assert.strictEqual(result.timestamps.defaultMode, 'absolute');
assert.strictEqual(result.timestamps.timezone, 'utc');
assert.strictEqual(result.heatmapOpacity, 0.7);
assert.strictEqual(result.liveHeatmapOpacity, 0.3);
// Legacy keys removed
assert.strictEqual(ls.getItem('meshcore-user-theme'), null);
assert.strictEqual(ls.getItem('meshcore-timestamp-mode'), null);
// New key written
assert.notStrictEqual(ls.getItem('cs-theme-overrides'), null);
});
test('handles partial migration (only some keys)', () => {
const { api, ls } = loadCustomizer();
ls.setItem('meshcore-timestamp-mode', 'ago');
const result = api.migrateOldKeys();
assert.strictEqual(result.timestamps.defaultMode, 'ago');
assert.strictEqual(ls.getItem('meshcore-timestamp-mode'), null);
});
test('handles invalid JSON in meshcore-user-theme', () => {
const { api, ls } = loadCustomizer();
ls.setItem('meshcore-user-theme', '{bad json');
const result = api.migrateOldKeys();
// Should not crash, returns delta (possibly empty besides what was valid)
assert(result !== null);
assert.strictEqual(ls.getItem('meshcore-user-theme'), null);
});
test('skips migration if cs-theme-overrides already exists', () => {
const { api, ls } = loadCustomizer();
ls.setItem('cs-theme-overrides', '{"theme":{}}');
ls.setItem('meshcore-user-theme', JSON.stringify({ theme: { accent: '#f00' } }));
const result = api.migrateOldKeys();
assert.strictEqual(result, null);
// Legacy key NOT removed (migration skipped entirely)
assert.notStrictEqual(ls.getItem('meshcore-user-theme'), null);
});
test('returns null when no legacy keys found', () => {
const { api } = loadCustomizer();
assert.strictEqual(api.migrateOldKeys(), null);
});
test('drops unknown keys from meshcore-user-theme', () => {
const { api, ls } = loadCustomizer();
ls.setItem('meshcore-user-theme', JSON.stringify({ theme: { accent: '#f00' }, unknownStuff: 'hi' }));
const result = api.migrateOldKeys();
assert.strictEqual(result.theme.accent, '#f00');
assert.strictEqual(result.unknownStuff, undefined);
});
// ── THEME_CSS_MAP completeness ──
console.log('\nTHEME_CSS_MAP:');
test('includes surface3 mapping', () => {
const { api } = loadCustomizer();
assert.strictEqual(api.THEME_CSS_MAP.surface3, '--surface-3');
});
test('includes sectionBg mapping', () => {
const { api } = loadCustomizer();
assert.strictEqual(api.THEME_CSS_MAP.sectionBg, '--section-bg');
});
test('matches all keys from old app.js varMap', () => {
const { api } = loadCustomizer();
const expectedKeys = [
'accent', 'accentHover', 'navBg', 'navBg2', 'navText', 'navTextMuted',
'background', 'text', 'textMuted', 'border',
'statusGreen', 'statusYellow', 'statusRed',
'surface1', 'surface2', 'surface3',
'cardBg', 'contentBg', 'inputBg',
'rowStripe', 'rowHover', 'detailBg',
'selectedBg', 'sectionBg',
'font', 'mono'
];
for (const key of expectedKeys) {
assert(key in api.THEME_CSS_MAP, `Missing key: ${key}`);
}
});
// ── _isOverridden tests ──
console.log('\n_isOverridden (value comparison):');
test('returns false when no overrides exist', () => {
const { api } = loadCustomizer();
api.init({ theme: { accent: '#aaa' } });
assert.strictEqual(api.isOverridden('theme', 'accent'), false);
});
test('returns false when override matches server default', () => {
const { api, ls } = loadCustomizer();
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#aaa' } }));
api.init({ theme: { accent: '#aaa' } });
assert.strictEqual(api.isOverridden('theme', 'accent'), false);
});
test('returns true when override differs from server default', () => {
const { api, ls } = loadCustomizer();
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#bbb' } }));
api.init({ theme: { accent: '#aaa' } });
assert.strictEqual(api.isOverridden('theme', 'accent'), true);
});
test('returns false for key not in overrides', () => {
const { api, ls } = loadCustomizer();
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#bbb' } }));
api.init({ theme: { accent: '#aaa', border: '#ccc' } });
assert.strictEqual(api.isOverridden('theme', 'border'), false);
});
test('returns true when server has no default for overridden key', () => {
const { api, ls } = loadCustomizer();
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#bbb' } }));
api.init({});
assert.strictEqual(api.isOverridden('theme', 'accent'), true);
});
// ── Summary ──
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`);
process.exit(failed > 0 ? 1 : 0);