- Settings drawer: responsive width clamp(24rem, 45vw, 50rem) - System font detection: fc-list for UI fonts (preferred sans-serif starred) and mono fonts (Nerd Fonts starred), fallback to hardcoded lists - Scrollback: default 5000, min 1000, step 500 - Shell detection: system.shells RPC, pre-selects $SHELL login shell - Provider enablement: provider.scan gates toggle, unavailable shown as N/A - Session retention: count 0-100 (0=Keep all), age 0-365 (0=Forever) - Chord keybindings: Ctrl+K → Ctrl+S style multi-key sequences, 1s prefix wait, arrow separator display, 26 tests passing
254 lines
10 KiB
TypeScript
254 lines
10 KiB
TypeScript
// Tests for Electrobun keybinding-store — pure logic.
|
|
// Uses bun:test. Tests default bindings, chord serialization, conflict detection.
|
|
|
|
import { describe, it, expect, beforeEach } from 'bun:test';
|
|
|
|
// ── Replicated types ──────────────────────────────────────────────────────────
|
|
|
|
interface Keybinding {
|
|
id: string;
|
|
label: string;
|
|
category: 'Global' | 'Navigation' | 'Terminal' | 'Settings';
|
|
chord: string;
|
|
defaultChord: string;
|
|
}
|
|
|
|
// ── Default bindings (replicated from keybinding-store.svelte.ts) ───────────
|
|
|
|
const DEFAULTS: Keybinding[] = [
|
|
{ id: 'palette', label: 'Command Palette', category: 'Global', chord: 'Ctrl+K', defaultChord: 'Ctrl+K' },
|
|
{ id: 'settings', label: 'Open Settings', category: 'Global', chord: 'Ctrl+,', defaultChord: 'Ctrl+,' },
|
|
{ id: 'group1', label: 'Switch to Group 1', category: 'Navigation', chord: 'Ctrl+1', defaultChord: 'Ctrl+1' },
|
|
{ id: 'group2', label: 'Switch to Group 2', category: 'Navigation', chord: 'Ctrl+2', defaultChord: 'Ctrl+2' },
|
|
{ id: 'group3', label: 'Switch to Group 3', category: 'Navigation', chord: 'Ctrl+3', defaultChord: 'Ctrl+3' },
|
|
{ id: 'group4', label: 'Switch to Group 4', category: 'Navigation', chord: 'Ctrl+4', defaultChord: 'Ctrl+4' },
|
|
{ id: 'newTerminal', label: 'New Terminal Tab', category: 'Terminal', chord: 'Ctrl+Shift+T', defaultChord: 'Ctrl+Shift+T' },
|
|
{ id: 'closeTab', label: 'Close Terminal Tab', category: 'Terminal', chord: 'Ctrl+Shift+W', defaultChord: 'Ctrl+Shift+W' },
|
|
{ id: 'nextTab', label: 'Next Terminal Tab', category: 'Terminal', chord: 'Ctrl+]', defaultChord: 'Ctrl+]' },
|
|
{ id: 'prevTab', label: 'Previous Terminal Tab', category: 'Terminal', chord: 'Ctrl+[', defaultChord: 'Ctrl+[' },
|
|
{ id: 'search', label: 'Global Search', category: 'Global', chord: 'Ctrl+Shift+F', defaultChord: 'Ctrl+Shift+F' },
|
|
{ id: 'notifications', label: 'Notification Center', category: 'Global', chord: 'Ctrl+Shift+N', defaultChord: 'Ctrl+Shift+N' },
|
|
{ id: 'minimize', label: 'Minimize Window', category: 'Global', chord: 'Ctrl+M', defaultChord: 'Ctrl+M' },
|
|
{ id: 'toggleFiles', label: 'Toggle Files Tab', category: 'Navigation', chord: 'Ctrl+Shift+E', defaultChord: 'Ctrl+Shift+E' },
|
|
{ id: 'toggleMemory', label: 'Toggle Memory Tab', category: 'Navigation', chord: 'Ctrl+Shift+M', defaultChord: 'Ctrl+Shift+M' },
|
|
{ id: 'reload', label: 'Reload App', category: 'Settings', chord: 'Ctrl+R', defaultChord: 'Ctrl+R' },
|
|
];
|
|
|
|
// ── Chord serialization (replicated) ─────────────────────────────────────────
|
|
|
|
interface MockKeyboardEvent {
|
|
ctrlKey: boolean;
|
|
metaKey: boolean;
|
|
shiftKey: boolean;
|
|
altKey: boolean;
|
|
key: string;
|
|
}
|
|
|
|
function chordFromEvent(e: MockKeyboardEvent): string {
|
|
const parts: string[] = [];
|
|
if (e.ctrlKey || e.metaKey) parts.push('Ctrl');
|
|
if (e.shiftKey) parts.push('Shift');
|
|
if (e.altKey) parts.push('Alt');
|
|
const key = e.key === ' ' ? 'Space' : e.key;
|
|
if (!['Control', 'Shift', 'Alt', 'Meta'].includes(key)) {
|
|
parts.push(key.length === 1 ? key.toUpperCase() : key);
|
|
}
|
|
return parts.join('+');
|
|
}
|
|
|
|
// ── Store logic (replicated without runes) ──────────────────────────────────
|
|
|
|
function createKeybindingState() {
|
|
let bindings: Keybinding[] = DEFAULTS.map(b => ({ ...b }));
|
|
|
|
return {
|
|
getBindings: () => bindings,
|
|
setChord(id: string, chord: string): void {
|
|
bindings = bindings.map(b => b.id === id ? { ...b, chord } : b);
|
|
},
|
|
resetChord(id: string): void {
|
|
const def = DEFAULTS.find(b => b.id === id);
|
|
if (!def) return;
|
|
bindings = bindings.map(b => b.id === id ? { ...b, chord: def.defaultChord } : b);
|
|
},
|
|
findConflicts(chord: string, excludeId?: string): Keybinding[] {
|
|
return bindings.filter(b => b.chord === chord && b.id !== excludeId);
|
|
},
|
|
};
|
|
}
|
|
|
|
// ── Tests ───────────────────────────────────────────────────────────────────
|
|
|
|
describe('default bindings', () => {
|
|
it('has exactly 16 default bindings', () => {
|
|
expect(DEFAULTS).toHaveLength(16);
|
|
});
|
|
|
|
it('all bindings have unique ids', () => {
|
|
const ids = DEFAULTS.map(b => b.id);
|
|
expect(new Set(ids).size).toBe(ids.length);
|
|
});
|
|
|
|
it('all chords match defaultChord initially', () => {
|
|
for (const b of DEFAULTS) {
|
|
expect(b.chord).toBe(b.defaultChord);
|
|
}
|
|
});
|
|
|
|
it('covers all 4 categories', () => {
|
|
const categories = new Set(DEFAULTS.map(b => b.category));
|
|
expect(categories.has('Global')).toBe(true);
|
|
expect(categories.has('Navigation')).toBe(true);
|
|
expect(categories.has('Terminal')).toBe(true);
|
|
expect(categories.has('Settings')).toBe(true);
|
|
});
|
|
|
|
it('command palette is Ctrl+K', () => {
|
|
const palette = DEFAULTS.find(b => b.id === 'palette');
|
|
expect(palette?.chord).toBe('Ctrl+K');
|
|
});
|
|
});
|
|
|
|
describe('chordFromEvent', () => {
|
|
it('serializes Ctrl+K', () => {
|
|
expect(chordFromEvent({ ctrlKey: true, metaKey: false, shiftKey: false, altKey: false, key: 'k' })).toBe('Ctrl+K');
|
|
});
|
|
|
|
it('serializes Ctrl+Shift+F', () => {
|
|
expect(chordFromEvent({ ctrlKey: true, metaKey: false, shiftKey: true, altKey: false, key: 'f' })).toBe('Ctrl+Shift+F');
|
|
});
|
|
|
|
it('serializes Alt+1', () => {
|
|
expect(chordFromEvent({ ctrlKey: false, metaKey: false, shiftKey: false, altKey: true, key: '1' })).toBe('Alt+1');
|
|
});
|
|
|
|
it('maps space to Space', () => {
|
|
expect(chordFromEvent({ ctrlKey: true, metaKey: false, shiftKey: false, altKey: false, key: ' ' })).toBe('Ctrl+Space');
|
|
});
|
|
|
|
it('ignores pure modifier keys', () => {
|
|
expect(chordFromEvent({ ctrlKey: true, metaKey: false, shiftKey: false, altKey: false, key: 'Control' })).toBe('Ctrl');
|
|
});
|
|
|
|
it('metaKey treated as Ctrl', () => {
|
|
expect(chordFromEvent({ ctrlKey: false, metaKey: true, shiftKey: false, altKey: false, key: 'k' })).toBe('Ctrl+K');
|
|
});
|
|
|
|
it('preserves multi-char key names', () => {
|
|
expect(chordFromEvent({ ctrlKey: false, metaKey: false, shiftKey: false, altKey: false, key: 'Escape' })).toBe('Escape');
|
|
});
|
|
});
|
|
|
|
describe('setChord / resetChord', () => {
|
|
let state: ReturnType<typeof createKeybindingState>;
|
|
|
|
beforeEach(() => {
|
|
state = createKeybindingState();
|
|
});
|
|
|
|
it('setChord updates the binding', () => {
|
|
state.setChord('palette', 'Ctrl+P');
|
|
const b = state.getBindings().find(b => b.id === 'palette');
|
|
expect(b?.chord).toBe('Ctrl+P');
|
|
expect(b?.defaultChord).toBe('Ctrl+K'); // default unchanged
|
|
});
|
|
|
|
it('resetChord restores default', () => {
|
|
state.setChord('palette', 'Ctrl+P');
|
|
state.resetChord('palette');
|
|
const b = state.getBindings().find(b => b.id === 'palette');
|
|
expect(b?.chord).toBe('Ctrl+K');
|
|
});
|
|
|
|
it('resetChord ignores unknown id', () => {
|
|
const before = state.getBindings().length;
|
|
state.resetChord('nonexistent');
|
|
expect(state.getBindings().length).toBe(before);
|
|
});
|
|
});
|
|
|
|
describe('conflict detection', () => {
|
|
let state: ReturnType<typeof createKeybindingState>;
|
|
|
|
beforeEach(() => {
|
|
state = createKeybindingState();
|
|
});
|
|
|
|
it('detects conflict when two bindings share a chord', () => {
|
|
state.setChord('settings', 'Ctrl+K'); // same as palette
|
|
const conflicts = state.findConflicts('Ctrl+K', 'settings');
|
|
expect(conflicts).toHaveLength(1);
|
|
expect(conflicts[0].id).toBe('palette');
|
|
});
|
|
|
|
it('no conflict when chord is unique', () => {
|
|
state.setChord('palette', 'Ctrl+Shift+P');
|
|
const conflicts = state.findConflicts('Ctrl+Shift+P', 'palette');
|
|
expect(conflicts).toHaveLength(0);
|
|
});
|
|
|
|
it('excludes self from conflict check', () => {
|
|
const conflicts = state.findConflicts('Ctrl+K', 'palette');
|
|
expect(conflicts).toHaveLength(0);
|
|
});
|
|
|
|
it('finds multiple conflicts', () => {
|
|
state.setChord('search', 'Ctrl+K');
|
|
state.setChord('reload', 'Ctrl+K');
|
|
const conflicts = state.findConflicts('Ctrl+K', 'settings');
|
|
expect(conflicts).toHaveLength(3); // palette, search, reload
|
|
});
|
|
});
|
|
|
|
describe('capture mode', () => {
|
|
it('chordFromEvent records full chord for capture', () => {
|
|
const chord = chordFromEvent({
|
|
ctrlKey: true, metaKey: false, shiftKey: true, altKey: false, key: 'x',
|
|
});
|
|
expect(chord).toBe('Ctrl+Shift+X');
|
|
});
|
|
});
|
|
|
|
// ── Chord sequence helpers ──────────────────────────────────────────────────
|
|
|
|
function chordParts(chord: string): string[] {
|
|
return chord.split(' ').filter(Boolean);
|
|
}
|
|
|
|
function formatChord(chord: string): string {
|
|
return chordParts(chord).join(' \u2192 ');
|
|
}
|
|
|
|
describe('chord sequences', () => {
|
|
it('chordParts splits single chord', () => {
|
|
expect(chordParts('Ctrl+K')).toEqual(['Ctrl+K']);
|
|
});
|
|
|
|
it('chordParts splits multi-key chord', () => {
|
|
expect(chordParts('Ctrl+K Ctrl+S')).toEqual(['Ctrl+K', 'Ctrl+S']);
|
|
});
|
|
|
|
it('formatChord adds arrow separator', () => {
|
|
expect(formatChord('Ctrl+K Ctrl+S')).toBe('Ctrl+K \u2192 Ctrl+S');
|
|
});
|
|
|
|
it('formatChord passes through single chord', () => {
|
|
expect(formatChord('Ctrl+Shift+F')).toBe('Ctrl+Shift+F');
|
|
});
|
|
|
|
it('setChord stores chord sequence', () => {
|
|
const state = createKeybindingState();
|
|
state.setChord('palette', 'Ctrl+K Ctrl+P');
|
|
const b = state.getBindings().find(b => b.id === 'palette');
|
|
expect(b?.chord).toBe('Ctrl+K Ctrl+P');
|
|
});
|
|
|
|
it('conflict detection works with chord sequences', () => {
|
|
const state = createKeybindingState();
|
|
state.setChord('palette', 'Ctrl+K Ctrl+P');
|
|
state.setChord('settings', 'Ctrl+K Ctrl+P');
|
|
const conflicts = state.findConflicts('Ctrl+K Ctrl+P', 'palette');
|
|
expect(conflicts).toHaveLength(1);
|
|
expect(conflicts[0].id).toBe('settings');
|
|
});
|
|
});
|