spaxel/dashboard/js/command-palette.test.js
jedarden bc5ebc0028 feat(dashboard): implement command palette with fuzzy search and time navigation
Ctrl+K / Cmd+K universal search interface for expert mode with:
- Fuzzy matching (prefix, substring, word-level, subsequence)
- Time navigation via @ prefix (@3am, @yesterday 11pm, @this morning, @last night)
- 36 commands across navigation, view, system, security, appearance, actions, help
- Entity search across zones, people, nodes, events
- Recent history with localStorage persistence
- Expert-mode gating (disabled in simple/ambient modes)
- Keyboard navigation (arrow keys, Enter, Escape, Tab)
- Toolbar shortcut hint with platform-aware display (⌘K on Mac)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 17:36:14 -04:00

647 lines
25 KiB
JavaScript

/**
* Spaxel Dashboard — Command Palette Tests (Component 34)
*
* Tests for:
* - Fuzzy matching (fuzzyScore)
* - Time navigation parsing (parseTimeExpression)
* - Commands registry completeness
* - Keyboard navigation (arrow down, enter, escape)
* - Recent history (localStorage)
* - Expert-mode gating (palette unavailable in simple/ambient mode)
* - Viewport positioning (palette centred on screen)
*/
describe('CommandPaletteManager', function () {
// ── Setup ────────────────────────────────────────────────────────────────
beforeAll(function () {
// Mock localStorage
var _store = {};
global.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 () { _store = {}; }
};
// jsdom does not implement scrollIntoView — stub it
if (typeof HTMLElement !== 'undefined' && !HTMLElement.prototype.scrollIntoView) {
HTMLElement.prototype.scrollIntoView = function () {};
}
// Load the module (if not already loaded in this env)
if (typeof window.CommandPaletteManager === 'undefined') {
require('./command-palette.js');
}
});
beforeEach(function () {
// Reset body classes and history before each test
document.body.classList.remove('simple-mode', 'ambient-mode');
localStorage.clear();
// Close palette if open
if (window.CommandPaletteManager && window.CommandPaletteManager.isOpen) {
window.CommandPaletteManager.close();
}
// Remove palette DOM if present
var root = document.getElementById('cp-root');
if (root) root.parentNode.removeChild(root);
});
// ── Fuzzy matching ───────────────────────────────────────────────────────
describe('fuzzyScore', function () {
var fuzzyScore;
beforeAll(function () {
fuzzyScore = window.CommandPaletteManager._fuzzyScore;
});
it('returns 1 for exact match', function () {
expect(fuzzyScore('Kitchen', 'Kitchen')).toBe(1);
});
it('"kit" → "Kitchen" scores > 0.7 (prefix)', function () {
expect(fuzzyScore('kit', 'Kitchen')).toBeGreaterThan(0.7);
});
it('"kitch" → "Kitchen" scores > 0.7 (prefix)', function () {
expect(fuzzyScore('kitch', 'Kitchen')).toBeGreaterThan(0.7);
});
it('"ktchn" → "Kitchen" scores >= 0.3 (subsequence)', function () {
expect(fuzzyScore('ktchn', 'Kitchen')).toBeGreaterThanOrEqual(0.3);
});
it('"livig rm" → "Living Room" scores > 0.5 (multi-word typo)', function () {
expect(fuzzyScore('livig rm', 'Living Room')).toBeGreaterThan(0.5);
});
it('"xyz" → "Kitchen" scores < 0.3 (excluded)', function () {
expect(fuzzyScore('xyz', 'Kitchen')).toBeLessThan(0.3);
});
it('"living room" → "Living Room" scores >= 0.8 (case insensitive substring)', function () {
expect(fuzzyScore('living room', 'Living Room')).toBeGreaterThanOrEqual(0.8);
});
it('empty needle returns 1 (matches everything)', function () {
expect(fuzzyScore('', 'Anything')).toBe(1);
});
it('"bedrm" → "Bedroom" scores >= 0.3', function () {
expect(fuzzyScore('bedrm', 'Bedroom')).toBeGreaterThanOrEqual(0.3);
});
it('"flr pln" → "Toggle floor plan" scores >= 0.3 (fuzzy multi-word)', function () {
expect(fuzzyScore('flr pln', 'Toggle floor plan')).toBeGreaterThanOrEqual(0.3);
});
it('"brth" → "Help: breathing detection" scores >= 0.3 (subsequence)', function () {
expect(fuzzyScore('brth', 'Help: breathing detection')).toBeGreaterThanOrEqual(0.3);
});
});
// ── Time parsing ─────────────────────────────────────────────────────────
describe('parseTimeExpression', function () {
var parse;
beforeAll(function () {
parse = window.CommandPaletteManager._parseTimeExpression;
});
it('@3am → today at 03:00', function () {
var d = parse('@3am');
expect(d).not.toBeNull();
expect(d.getHours()).toBe(3);
expect(d.getMinutes()).toBe(0);
});
it('@3:15am → today at 03:15', function () {
var d = parse('@3:15am');
expect(d).not.toBeNull();
expect(d.getHours()).toBe(3);
expect(d.getMinutes()).toBe(15);
});
it('@11pm → today at 23:00', function () {
var d = parse('@11pm');
expect(d).not.toBeNull();
expect(d.getHours()).toBe(23);
expect(d.getMinutes()).toBe(0);
});
it('@12am → today at 00:00 (midnight)', function () {
var d = parse('@12am');
expect(d).not.toBeNull();
expect(d.getHours()).toBe(0);
});
it('@12pm → today at 12:00 (noon)', function () {
var d = parse('@12pm');
expect(d).not.toBeNull();
expect(d.getHours()).toBe(12);
});
it('@-30min → 30 minutes ago', function () {
var before = new Date();
var d = parse('@-30min');
var after = new Date();
expect(d).not.toBeNull();
var expectedMin = before.getTime() - 30 * 60 * 1000;
var expectedMax = after.getTime() - 30 * 60 * 1000;
expect(d.getTime()).toBeGreaterThanOrEqual(expectedMin - 1000);
expect(d.getTime()).toBeLessThanOrEqual(expectedMax + 1000);
});
it('@-2h → 2 hours ago', function () {
var before = new Date();
var d = parse('@-2h');
var after = new Date();
expect(d).not.toBeNull();
var expectedMin = before.getTime() - 2 * 3600 * 1000;
var expectedMax = after.getTime() - 2 * 3600 * 1000;
expect(d.getTime()).toBeGreaterThanOrEqual(expectedMin - 1000);
expect(d.getTime()).toBeLessThanOrEqual(expectedMax + 1000);
});
it('@2026-03-27 14:23 → specific datetime', function () {
var d = parse('@2026-03-27 14:23');
expect(d).not.toBeNull();
expect(d.getFullYear()).toBe(2026);
expect(d.getMonth()).toBe(2); // March = 2 (0-indexed)
expect(d.getDate()).toBe(27);
expect(d.getHours()).toBe(14);
expect(d.getMinutes()).toBe(23);
});
it('@yesterday 11pm → yesterday at 23:00', function () {
var d = parse('@yesterday 11pm');
expect(d).not.toBeNull();
var yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
expect(d.getDate()).toBe(yesterday.getDate());
expect(d.getHours()).toBe(23);
});
it('returns null for unparseable expression', function () {
var d = parse('@not-a-time');
expect(d).toBeNull();
});
it('returns null for empty @', function () {
var d = parse('@');
expect(d).toBeNull();
});
it('@this morning → today at 06:00', function () {
var d = parse('@this morning');
expect(d).not.toBeNull();
expect(d.getHours()).toBe(6);
expect(d.getMinutes()).toBe(0);
});
it('@last night → yesterday at 22:00 (after 6am)', function () {
var d = parse('@last night');
expect(d).not.toBeNull();
expect(d.getHours()).toBe(22);
expect(d.getMinutes()).toBe(0);
});
});
// ── Commands registry completeness ───────────────────────────────────────
describe('Commands registry', function () {
var COMMANDS;
beforeAll(function () {
COMMANDS = window.CommandPaletteManager._COMMANDS;
});
var REQUIRED_COMMANDS = [
'Open settings',
'Open fleet page',
'Open automations',
'Open simulator',
'Toggle Fresnel overlay',
'Toggle flow map',
'Toggle dwell heatmap',
'Toggle zone volumes',
'Reset camera',
'Enter away mode',
'Enter home mode',
'Enter sleep mode',
'Trigger fleet OTA',
'Add a person',
'Add a zone',
'Add a portal',
'Export all events CSV',
'Show link health table',
'Run diagnostics',
'Check firmware updates',
// Security commands
'Arm security',
'Disarm security',
// Appearance commands
'Dark mode',
'Light mode',
// Actions
'Add node',
'Re-baseline all links',
'Export config',
// View presets
'Camera: Top view',
'Camera: Front view',
'Camera: Perspective',
'Toggle trails',
'Toggle link lines',
'Toggle floor plan',
'Toggle coverage overlay',
// Help
'Help: fall detection',
'Help: breathing detection',
'Help: how does prediction work',
'Help: why false positive',
'Help: troubleshoot detection',
'Help: keyboard shortcuts'
];
REQUIRED_COMMANDS.forEach(function (label) {
it('contains "' + label + '"', function () {
var found = COMMANDS.some(function (c) { return c.label === label; });
expect(found).toBe(true);
});
});
it('all commands have an id, label, category, and action', function () {
COMMANDS.forEach(function (cmd) {
expect(typeof cmd.id).toBe('string');
expect(typeof cmd.label).toBe('string');
expect(cmd.category).toBe('command');
expect(typeof cmd.action).toBe('function');
});
});
});
// ── Keyboard navigation ──────────────────────────────────────────────────
describe('Keyboard navigation', function () {
it('arrow down increments selectedIndex', function () {
var mgr = window.CommandPaletteManager;
if (!mgr) return;
mgr.open();
// Populate with some items via search
var items = mgr._search('open');
mgr._showItems(items);
mgr.selectedIndex = 0;
// Simulate ArrowDown keydown on the input
var input = document.querySelector('.cp-input');
if (!input) return;
var event = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true });
input.dispatchEvent(event);
expect(mgr.selectedIndex).toBe(1);
mgr.close();
});
it('arrow up decrements selectedIndex (not below 0)', function () {
var mgr = window.CommandPaletteManager;
if (!mgr) return;
mgr.open();
var items = mgr._search('open');
mgr._showItems(items);
mgr.selectedIndex = 0;
var input = document.querySelector('.cp-input');
if (!input) return;
var event = new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true });
input.dispatchEvent(event);
expect(mgr.selectedIndex).toBe(0); // clamped at 0
mgr.close();
});
it('Escape closes the palette', function () {
var mgr = window.CommandPaletteManager;
if (!mgr) return;
mgr.open();
expect(mgr.isOpen).toBe(true);
var event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true });
document.dispatchEvent(event);
expect(mgr.isOpen).toBe(false);
});
it('Enter executes selected item and closes palette', function () {
var mgr = window.CommandPaletteManager;
if (!mgr) return;
var executed = false;
mgr.open();
// Inject a synthetic item with a trackable action
mgr._showItems([{
id: 'test-enter',
label: 'Test Action',
category: 'command',
icon: '•',
score: 1,
action: function () { executed = true; }
}]);
mgr.selectedIndex = 0;
var input = document.querySelector('.cp-input');
if (!input) return;
var event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true });
input.dispatchEvent(event);
expect(executed).toBe(true);
expect(mgr.isOpen).toBe(false);
});
});
// ── Recent history ───────────────────────────────────────────────────────
describe('Recent history', function () {
it('addToHistory saves item to localStorage', function () {
var addToHistory = window.CommandPaletteManager._addToHistory;
var loadHistory = window.CommandPaletteManager._loadHistory;
if (!addToHistory || !loadHistory) return;
addToHistory({ id: 'test-cmd', label: 'Test', category: 'command', icon: '⚙' });
var hist = loadHistory();
expect(hist.length).toBe(1);
expect(hist[0].id).toBe('test-cmd');
});
it('addToHistory excludes time navigation entries', function () {
var addToHistory = window.CommandPaletteManager._addToHistory;
var loadHistory = window.CommandPaletteManager._loadHistory;
if (!addToHistory || !loadHistory) return;
addToHistory({ id: 'time:@3am', label: 'Jump to 3am', category: 'time', icon: '🕐' });
var hist = loadHistory();
expect(hist.length).toBe(0);
});
it('stores at most 5 recent items', function () {
var addToHistory = window.CommandPaletteManager._addToHistory;
var loadHistory = window.CommandPaletteManager._loadHistory;
if (!addToHistory || !loadHistory) return;
for (var i = 0; i < 7; i++) {
addToHistory({ id: 'cmd-' + i, label: 'Command ' + i, category: 'command', icon: '•' });
}
var hist = loadHistory();
expect(hist.length).toBeLessThanOrEqual(5);
});
it('most recently added item is first in history', function () {
var addToHistory = window.CommandPaletteManager._addToHistory;
var loadHistory = window.CommandPaletteManager._loadHistory;
if (!addToHistory || !loadHistory) return;
addToHistory({ id: 'first', label: 'First', category: 'command', icon: '•' });
addToHistory({ id: 'second', label: 'Second', category: 'command', icon: '•' });
var hist = loadHistory();
expect(hist[0].id).toBe('second');
});
it('empty query shows recent items', function () {
var addToHistory = window.CommandPaletteManager._addToHistory;
var search = window.CommandPaletteManager._search;
if (!addToHistory || !search) return;
addToHistory({ id: 'recent-a', label: 'Recent A', category: 'command', icon: '•' });
addToHistory({ id: 'recent-b', label: 'Recent B', category: 'command', icon: '•' });
var results = search('');
expect(results.length).toBeGreaterThan(0);
var cats = results.map(function (r) { return r.category; });
expect(cats.every(function (c) { return c === 'recent'; })).toBe(true);
});
it('open palette with 5 prior actions shows 5 recent items on empty query', function () {
var addToHistory = window.CommandPaletteManager._addToHistory;
var search = window.CommandPaletteManager._search;
if (!addToHistory || !search) return;
for (var i = 0; i < 5; i++) {
addToHistory({ id: 'hist-' + i, label: 'Hist ' + i, category: 'command', icon: '•' });
}
var results = search('');
expect(results.length).toBe(5);
});
});
// ── Expert-mode gating ───────────────────────────────────────────────────
describe('Expert-mode gating', function () {
it('isExpertMode() returns true by default (no class on body)', function () {
var isExpert = window.CommandPaletteManager._isExpertMode;
if (!isExpert) return;
document.body.classList.remove('simple-mode', 'ambient-mode');
expect(isExpert()).toBe(true);
});
it('isExpertMode() returns false when body has simple-mode class', function () {
var isExpert = window.CommandPaletteManager._isExpertMode;
if (!isExpert) return;
document.body.classList.add('simple-mode');
expect(isExpert()).toBe(false);
document.body.classList.remove('simple-mode');
});
it('isExpertMode() returns false when body has ambient-mode class', function () {
var isExpert = window.CommandPaletteManager._isExpertMode;
if (!isExpert) return;
document.body.classList.add('ambient-mode');
expect(isExpert()).toBe(false);
document.body.classList.remove('ambient-mode');
});
it('open() does nothing in simple mode', function () {
var mgr = window.CommandPaletteManager;
if (!mgr) return;
document.body.classList.add('simple-mode');
mgr.open();
expect(mgr.isOpen).toBe(false);
document.body.classList.remove('simple-mode');
});
it('open() does nothing in ambient mode (window.currentMode)', function () {
var mgr = window.CommandPaletteManager;
if (!mgr) return;
window.currentMode = 'ambient';
mgr.open();
expect(mgr.isOpen).toBe(false);
delete window.currentMode;
});
it('Ctrl+K does not open palette in simple mode', function () {
var mgr = window.CommandPaletteManager;
if (!mgr) return;
document.body.classList.add('simple-mode');
var ev = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true });
document.dispatchEvent(ev);
expect(mgr.isOpen).toBe(false);
document.body.classList.remove('simple-mode');
});
});
// ── Viewport positioning ─────────────────────────────────────────────────
describe('Viewport positioning', function () {
it('palette container has position:absolute and transform:translate(-50%,-50%)', function () {
var mgr = window.CommandPaletteManager;
if (!mgr) return;
// Open in expert mode to create DOM
mgr.open();
expect(mgr.isOpen).toBe(true);
var container = document.querySelector('.cp-container');
expect(container).not.toBeNull();
// The CSS class sets the centering rules.
// In jsdom, computed styles aren't fully calculated, but we can
// verify the class is present on the element.
expect(container.className).toContain('cp-container');
mgr.close();
});
it('overlay covers the viewport (cp-overlay present when open)', function () {
var mgr = window.CommandPaletteManager;
if (!mgr) return;
mgr.open();
var overlay = document.getElementById('cp-root');
expect(overlay).not.toBeNull();
expect(overlay.classList.contains('cp-visible')).toBe(true);
mgr.close();
});
it('overlay is hidden when palette is closed', function () {
var mgr = window.CommandPaletteManager;
if (!mgr) return;
mgr.open();
mgr.close();
var overlay = document.getElementById('cp-root');
// After close, cp-visible should be removed
if (overlay) {
expect(overlay.classList.contains('cp-visible')).toBe(false);
}
});
});
// ── Search results ───────────────────────────────────────────────────────
describe('Search', function () {
it('search for "@3am" returns a time navigation result', function () {
var search = window.CommandPaletteManager._search;
if (!search) return;
var results = search('@3am');
expect(results.length).toBeGreaterThan(0);
expect(results[0].category).toBe('time');
});
it('search for "open" returns command results', function () {
var search = window.CommandPaletteManager._search;
if (!search) return;
var results = search('open');
expect(results.length).toBeGreaterThan(0);
var cmdResults = results.filter(function (r) { return r.category === 'command'; });
expect(cmdResults.length).toBeGreaterThan(0);
});
it('results are capped at 8', function () {
var search = window.CommandPaletteManager._search;
if (!search) return;
// A broad query should still not return more than 8
var results = search('a');
expect(results.length).toBeLessThanOrEqual(8);
});
it('@unparseable returns empty array', function () {
var search = window.CommandPaletteManager._search;
if (!search) return;
var results = search('@zzznottimestamp');
expect(results.length).toBe(0);
});
it('search for "arm" finds "Arm security"', function () {
var search = window.CommandPaletteManager._search;
if (!search) return;
var results = search('arm');
expect(results.length).toBeGreaterThan(0);
var labels = results.map(function (r) { return r.label; });
expect(labels).toContain('Arm security');
});
it('search for "disarm" finds "Disarm security"', function () {
var search = window.CommandPaletteManager._search;
if (!search) return;
var results = search('disarm');
expect(results.length).toBeGreaterThan(0);
var labels = results.map(function (r) { return r.label; });
expect(labels).toContain('Disarm security');
});
it('search for "help" returns help topic commands', function () {
var search = window.CommandPaletteManager._search;
if (!search) return;
var results = search('help');
expect(results.length).toBeGreaterThan(0);
var helpResults = results.filter(function (r) {
return r.category === 'command' && r.label.indexOf('Help:') === 0;
});
expect(helpResults.length).toBeGreaterThan(0);
});
it('search for "dark mode" finds the command', function () {
var search = window.CommandPaletteManager._search;
if (!search) return;
var results = search('dark mode');
expect(results.length).toBeGreaterThan(0);
var labels = results.map(function (r) { return r.label; });
expect(labels).toContain('Dark mode');
});
it('search for "@this morning" returns a time result', function () {
var search = window.CommandPaletteManager._search;
if (!search) return;
var results = search('@this morning');
expect(results.length).toBeGreaterThan(0);
expect(results[0].category).toBe('time');
});
it('command results include hint field', function () {
var search = window.CommandPaletteManager._search;
if (!search) return;
var results = search('settings');
var settingsResult = results.find(function (r) { return r.id === 'nav-settings'; });
if (settingsResult) {
expect(typeof settingsResult.hint).toBe('string');
}
});
});
});