mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-05 14:01:32 +00:00
382b3505dc
## Summary Implements M2 of channel color highlighting (#271): a right-click context menu popover for quick-assigning colors to hash channels. Builds on M1 (PR #607) which provides `ChannelColors.set/get/remove` storage primitives. ## What's new ### Color picker popover (`channel-color-picker.js`) - **Right-click** any GRP_TXT/CHAN row in the **live feed** or **packets table** → opens a color picker popover at the click point - **Long-press** (500ms) on mobile triggers the same popover - **10 preset swatches** — maximally distinct, ColorBrewer-inspired palette - **Custom hex** — native `<input type="color">` with Apply button - **Clear button** — removes color assignment (hidden when no color assigned) - **Popover positioning** — auto-adjusts to avoid viewport overflow - **Dismiss** — click outside or Escape key ### Immediate feedback - Assigning a color instantly re-styles all visible live feed items with that channel - Packets table triggers `renderVisibleRows()` via exposed `window._packetsRenderVisible` ### Wiring - Feed items store `_ccPkt` packet reference for channel extraction - Picker installed via `registerPage` init hooks in both `live.js` and `packets.js` - Single shared popover DOM element, repositioned on each open ### Styling - Dark card with border, matching existing CoreScope dropdown patterns - CSS in `style.css` under `.cc-picker-*` classes - Uses CSS variables (`--surface-1`, `--border`, `--accent`, etc.) for theme compatibility ## Files changed | File | Change | |------|--------| | `public/channel-color-picker.js` | New — popover component (IIFE, no dependencies except `ChannelColors`) | | `public/index.html` | Script tag for picker | | `public/live.js` | Store `_ccPkt` on feed items, install picker on init | | `public/packets.js` | Install picker on init, expose `_packetsRenderVisible` | | `public/style.css` | Popover CSS | | `test-channel-colors.js` | 2 new tests for picker loading and graceful degradation | ## Testing - All 21 channel-colors tests pass (19 M1 + 2 M2) - All 445 frontend-helpers tests pass - All 62 packet-filter tests pass ## Performance No hot-path impact. The popover is a single shared DOM element created lazily on first use. Context menu handlers use event delegation on the feed/table containers (one listener each, not per-row). The `refreshVisibleRows` function only iterates currently-visible DOM elements. Closes milestone M2 of #271. --------- Co-authored-by: you <you@example.com>
259 lines
10 KiB
JavaScript
259 lines
10 KiB
JavaScript
/* Unit tests for channel color highlighting (M1) — #271 */
|
|
'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}`);
|
|
}
|
|
}
|
|
|
|
// Build minimal sandbox with localStorage mock
|
|
function makeSandbox() {
|
|
const store = {};
|
|
const localStorage = {
|
|
getItem: function(k) { return store[k] !== undefined ? store[k] : null; },
|
|
setItem: function(k, v) { store[k] = String(v); },
|
|
removeItem: function(k) { delete store[k]; },
|
|
clear: function() { for (var k in store) delete store[k]; }
|
|
};
|
|
const ctx = {
|
|
window: {},
|
|
localStorage: localStorage,
|
|
console: console,
|
|
JSON: JSON,
|
|
};
|
|
ctx.window.ChannelColors = undefined;
|
|
vm.createContext(ctx);
|
|
const src = fs.readFileSync(__dirname + '/public/channel-colors.js', 'utf8');
|
|
vm.runInContext(src, ctx);
|
|
return ctx;
|
|
}
|
|
|
|
console.log('\n🎨 Channel Colors — Storage CRUD');
|
|
|
|
test('getChannelColor returns null for unassigned channel', function() {
|
|
const ctx = makeSandbox();
|
|
assert.strictEqual(ctx.window.ChannelColors.get('#test'), null);
|
|
});
|
|
|
|
test('setChannelColor + getChannelColor round-trip', function() {
|
|
const ctx = makeSandbox();
|
|
ctx.window.ChannelColors.set('#sf', '#ef4444');
|
|
assert.strictEqual(ctx.window.ChannelColors.get('#sf'), '#ef4444');
|
|
});
|
|
|
|
test('setChannelColor overwrites existing color', function() {
|
|
const ctx = makeSandbox();
|
|
ctx.window.ChannelColors.set('#sf', '#ef4444');
|
|
ctx.window.ChannelColors.set('#sf', '#3b82f6');
|
|
assert.strictEqual(ctx.window.ChannelColors.get('#sf'), '#3b82f6');
|
|
});
|
|
|
|
test('removeChannelColor removes assignment', function() {
|
|
const ctx = makeSandbox();
|
|
ctx.window.ChannelColors.set('#test', '#ff0000');
|
|
ctx.window.ChannelColors.remove('#test');
|
|
assert.strictEqual(ctx.window.ChannelColors.get('#test'), null);
|
|
});
|
|
|
|
test('removeChannelColor on non-existent channel is no-op', function() {
|
|
const ctx = makeSandbox();
|
|
ctx.window.ChannelColors.remove('#nonexistent');
|
|
assert.deepStrictEqual(ctx.window.ChannelColors.getAll(), {});
|
|
});
|
|
|
|
test('getAllChannelColors returns all assignments', function() {
|
|
const ctx = makeSandbox();
|
|
ctx.window.ChannelColors.set('#a', '#111111');
|
|
ctx.window.ChannelColors.set('#b', '#222222');
|
|
const all = ctx.window.ChannelColors.getAll();
|
|
assert.strictEqual(JSON.stringify(all), JSON.stringify({ '#a': '#111111', '#b': '#222222' }));
|
|
});
|
|
|
|
test('getAllChannelColors returns empty object when none set', function() {
|
|
const ctx = makeSandbox();
|
|
assert.strictEqual(JSON.stringify(ctx.window.ChannelColors.getAll()), '{}');
|
|
});
|
|
|
|
test('handles corrupt localStorage gracefully', function() {
|
|
const ctx = makeSandbox();
|
|
ctx.localStorage.setItem('live-channel-colors', 'not-json{{{');
|
|
assert.strictEqual(ctx.window.ChannelColors.get('#test'), null);
|
|
assert.strictEqual(JSON.stringify(ctx.window.ChannelColors.getAll()), '{}');
|
|
});
|
|
|
|
test('set with null/empty channel is no-op', function() {
|
|
const ctx = makeSandbox();
|
|
ctx.window.ChannelColors.set('', '#ff0000');
|
|
ctx.window.ChannelColors.set(null, '#ff0000');
|
|
assert.strictEqual(JSON.stringify(ctx.window.ChannelColors.getAll()), '{}');
|
|
});
|
|
|
|
test('set rejects invalid hex colors', function() {
|
|
const ctx = makeSandbox();
|
|
ctx.window.ChannelColors.set('#ch', 'red');
|
|
ctx.window.ChannelColors.set('#ch', '#xyz');
|
|
ctx.window.ChannelColors.set('#ch', '#12345');
|
|
ctx.window.ChannelColors.set('#ch', '#1234567');
|
|
ctx.window.ChannelColors.set('#ch', 'ff0000');
|
|
assert.strictEqual(ctx.window.ChannelColors.get('#ch'), null);
|
|
});
|
|
|
|
test('set normalizes 3-digit hex to 6-digit', function() {
|
|
const ctx = makeSandbox();
|
|
ctx.window.ChannelColors.set('#ch', '#abc');
|
|
assert.strictEqual(ctx.window.ChannelColors.get('#ch'), '#aabbcc');
|
|
});
|
|
|
|
test('set accepts valid 6-digit hex', function() {
|
|
const ctx = makeSandbox();
|
|
ctx.window.ChannelColors.set('#ch', '#ef4444');
|
|
assert.strictEqual(ctx.window.ChannelColors.get('#ch'), '#ef4444');
|
|
});
|
|
|
|
test('get with null/empty channel returns null', function() {
|
|
const ctx = makeSandbox();
|
|
assert.strictEqual(ctx.window.ChannelColors.get(''), null);
|
|
assert.strictEqual(ctx.window.ChannelColors.get(null), null);
|
|
});
|
|
|
|
console.log('\n🎨 Channel Colors — Row Style Generation');
|
|
|
|
test('getRowStyle returns empty string for non-GRP_TXT types', function() {
|
|
const ctx = makeSandbox();
|
|
ctx.window.ChannelColors.set('#test', '#ff0000');
|
|
assert.strictEqual(ctx.window.ChannelColors.getRowStyle('ADVERT', '#test'), '');
|
|
assert.strictEqual(ctx.window.ChannelColors.getRowStyle('TXT_MSG', '#test'), '');
|
|
assert.strictEqual(ctx.window.ChannelColors.getRowStyle('ACK', '#test'), '');
|
|
});
|
|
|
|
test('getRowStyle returns empty string for unassigned channel', function() {
|
|
const ctx = makeSandbox();
|
|
assert.strictEqual(ctx.window.ChannelColors.getRowStyle('GRP_TXT', '#unassigned'), '');
|
|
});
|
|
|
|
test('getRowStyle returns empty string for null channel', function() {
|
|
const ctx = makeSandbox();
|
|
assert.strictEqual(ctx.window.ChannelColors.getRowStyle('GRP_TXT', null), '');
|
|
});
|
|
|
|
test('getRowStyle returns border + background for assigned GRP_TXT channel', function() {
|
|
const ctx = makeSandbox();
|
|
ctx.window.ChannelColors.set('#sf', '#ef4444');
|
|
const style = ctx.window.ChannelColors.getRowStyle('GRP_TXT', '#sf');
|
|
assert.ok(style.includes('border-left:4px solid #ef4444'), 'should have left border');
|
|
assert.ok(style.includes('background:#ef44441a'), 'should have 10% opacity background');
|
|
});
|
|
|
|
test('getRowStyle works with CHAN type (alias for GRP_TXT)', function() {
|
|
const ctx = makeSandbox();
|
|
ctx.window.ChannelColors.set('#mesh', '#3b82f6');
|
|
const style = ctx.window.ChannelColors.getRowStyle('CHAN', '#mesh');
|
|
assert.ok(style.includes('border-left:4px solid #3b82f6'), 'should have left border');
|
|
assert.ok(style.includes('background:#3b82f61a'), 'should have background tint');
|
|
});
|
|
|
|
test('getRowStyle returns empty when channel has no assigned color', function() {
|
|
const ctx = makeSandbox();
|
|
ctx.window.ChannelColors.set('#other', '#ff0000');
|
|
assert.strictEqual(ctx.window.ChannelColors.getRowStyle('GRP_TXT', '#nope'), '');
|
|
});
|
|
|
|
// ── M2: Channel Color Picker tests ──
|
|
|
|
test('channel-color-picker.js loads without error in sandbox', function() {
|
|
const ctx = makeSandbox();
|
|
// Provide minimal DOM stubs for the picker
|
|
const elements = {};
|
|
const createdEls = [];
|
|
ctx.document = {
|
|
createElement: function(tag) {
|
|
var el = {
|
|
tagName: tag.toUpperCase(),
|
|
className: '', style: { cssText: '', display: '' },
|
|
innerHTML: '', textContent: '', title: '',
|
|
children: [],
|
|
_attrs: {},
|
|
_listeners: {},
|
|
setAttribute: function(k, v) { this._attrs[k] = v; },
|
|
getAttribute: function(k) { return this._attrs[k] || null; },
|
|
addEventListener: function(ev, fn, opts) { this._listeners[ev] = fn; },
|
|
removeEventListener: function() {},
|
|
appendChild: function(c) { this.children.push(c); return c; },
|
|
querySelector: function(sel) {
|
|
// Very basic selector matching for test
|
|
if (sel === '.cc-picker-swatches') return { addEventListener: function(){}, appendChild: function(c){} };
|
|
if (sel === '.cc-picker-apply') return { addEventListener: function(){} };
|
|
if (sel === '.cc-picker-clear') return { addEventListener: function(){}, style: {} };
|
|
if (sel === '.cc-picker-close') return { addEventListener: function(){} };
|
|
if (sel === '.cc-picker-title') return { textContent: '' };
|
|
if (sel === '.cc-picker-input') return { value: '#000000' };
|
|
return null;
|
|
},
|
|
querySelectorAll: function() { return []; },
|
|
classList: { toggle: function(){}, remove: function(){}, add: function(){} },
|
|
contains: function() { return false; },
|
|
closest: function() { return null; },
|
|
getBoundingClientRect: function() { return { width: 200, height: 200 }; }
|
|
};
|
|
createdEls.push(el);
|
|
return el;
|
|
},
|
|
getElementById: function() { return null; },
|
|
addEventListener: function() {},
|
|
removeEventListener: function() {},
|
|
body: { appendChild: function(c) {} },
|
|
querySelectorAll: function() { return []; }
|
|
};
|
|
ctx.setTimeout = function(fn) { fn(); };
|
|
ctx.window.innerWidth = 1024;
|
|
ctx.window.innerHeight = 768;
|
|
const pickerSrc = fs.readFileSync(__dirname + '/public/channel-color-picker.js', 'utf8');
|
|
vm.runInContext(pickerSrc, ctx);
|
|
assert.ok(ctx.window.ChannelColorPicker, 'ChannelColorPicker should be exported');
|
|
assert.strictEqual(typeof ctx.window.ChannelColorPicker.install, 'function');
|
|
assert.strictEqual(typeof ctx.window.ChannelColorPicker.show, 'function');
|
|
assert.strictEqual(typeof ctx.window.ChannelColorPicker.hide, 'function');
|
|
});
|
|
|
|
test('ChannelColorPicker.install does not throw when elements missing', function() {
|
|
const ctx = makeSandbox();
|
|
ctx.document = {
|
|
createElement: function() {
|
|
return { className: '', style: {}, innerHTML: '', _attrs: {}, children: [],
|
|
setAttribute: function(){}, getAttribute: function(){ return null; },
|
|
addEventListener: function(){}, appendChild: function(c){ this.children.push(c); return c; },
|
|
querySelector: function(){ return { addEventListener: function(){}, style: {}, textContent: '' }; },
|
|
querySelectorAll: function(){ return []; },
|
|
getBoundingClientRect: function(){ return {width:0,height:0}; },
|
|
contains: function(){ return false; }
|
|
};
|
|
},
|
|
getElementById: function() { return null; },
|
|
addEventListener: function() {},
|
|
removeEventListener: function() {},
|
|
body: { appendChild: function(){} },
|
|
querySelectorAll: function() { return []; }
|
|
};
|
|
ctx.setTimeout = function(fn) { fn(); };
|
|
ctx.window.innerWidth = 1024;
|
|
ctx.window.innerHeight = 768;
|
|
const pickerSrc = fs.readFileSync(__dirname + '/public/channel-color-picker.js', 'utf8');
|
|
vm.runInContext(pickerSrc, ctx);
|
|
// Should not throw when feed/table elements don't exist
|
|
ctx.window.ChannelColorPicker.install();
|
|
});
|
|
|
|
// Summary
|
|
console.log(`\n${passed} passed, ${failed} failed\n`);
|
|
process.exit(failed ? 1 : 0);
|