test(dashboard): add autocomplete trigger/filter tests
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { html, useState, useRef, useCallback, useMemo, useEffect } from '../lib/preact.js';
|
import { html, useState, useRef, useCallback, useMemo, useEffect } from '../lib/preact.js';
|
||||||
import { getStatusMeta } from '../utils/status.js';
|
import { getStatusMeta } from '../utils/status.js';
|
||||||
|
import { getTriggerInfo as _getTriggerInfo, filteredSkills as _filteredSkills } from '../utils/autocomplete.js';
|
||||||
|
|
||||||
export function SimpleInput({ sessionId, status, onRespond, autocompleteConfig = null }) {
|
export function SimpleInput({ sessionId, status, onRespond, autocompleteConfig = null }) {
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
@@ -13,48 +14,12 @@ export function SimpleInput({ sessionId, status, onRespond, autocompleteConfig =
|
|||||||
const autocompleteRef = useRef(null);
|
const autocompleteRef = useRef(null);
|
||||||
const meta = getStatusMeta(status);
|
const meta = getStatusMeta(status);
|
||||||
|
|
||||||
// Detect if cursor is at a trigger position for autocomplete
|
|
||||||
const getTriggerInfo = useCallback((value, cursorPos) => {
|
const getTriggerInfo = useCallback((value, cursorPos) => {
|
||||||
// No config means no autocomplete
|
return _getTriggerInfo(value, cursorPos, autocompleteConfig);
|
||||||
if (!autocompleteConfig) return null;
|
|
||||||
|
|
||||||
const { trigger } = autocompleteConfig;
|
|
||||||
|
|
||||||
// Find the start of the current "word" (after last whitespace before cursor)
|
|
||||||
let wordStart = cursorPos;
|
|
||||||
while (wordStart > 0 && !/\s/.test(value[wordStart - 1])) {
|
|
||||||
wordStart--;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if word starts with this agent's trigger character
|
|
||||||
if (value[wordStart] === trigger) {
|
|
||||||
return {
|
|
||||||
trigger,
|
|
||||||
filterText: value.slice(wordStart + 1, cursorPos).toLowerCase(),
|
|
||||||
replaceStart: wordStart,
|
|
||||||
replaceEnd: cursorPos,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}, [autocompleteConfig]);
|
}, [autocompleteConfig]);
|
||||||
|
|
||||||
// Filter skills based on user input after trigger
|
|
||||||
const filteredSkills = useMemo(() => {
|
const filteredSkills = useMemo(() => {
|
||||||
if (!autocompleteConfig || !triggerInfo) return [];
|
return _filteredSkills(autocompleteConfig, triggerInfo);
|
||||||
|
|
||||||
const { skills } = autocompleteConfig;
|
|
||||||
const { filterText } = triggerInfo;
|
|
||||||
|
|
||||||
let filtered = skills;
|
|
||||||
if (filterText) {
|
|
||||||
filtered = skills.filter(s =>
|
|
||||||
s.name.toLowerCase().includes(filterText)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server pre-sorts, but re-sort after filtering for stability
|
|
||||||
return filtered.sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
}, [autocompleteConfig, triggerInfo]);
|
}, [autocompleteConfig, triggerInfo]);
|
||||||
|
|
||||||
// Show/hide autocomplete based on trigger detection
|
// Show/hide autocomplete based on trigger detection
|
||||||
@@ -229,7 +194,7 @@ export function SimpleInput({ sessionId, status, onRespond, autocompleteConfig =
|
|||||||
<div class="px-3 py-2 text-sm text-dim">No matching skills</div>
|
<div class="px-3 py-2 text-sm text-dim">No matching skills</div>
|
||||||
` : filteredSkills.map((skill, i) => html`
|
` : filteredSkills.map((skill, i) => html`
|
||||||
<div
|
<div
|
||||||
key=${i}
|
key=${skill.name}
|
||||||
class="px-3 py-2 cursor-pointer text-sm transition-colors ${
|
class="px-3 py-2 cursor-pointer text-sm transition-colors ${
|
||||||
i === selectedIndex
|
i === selectedIndex
|
||||||
? 'bg-selection/50 text-bright'
|
? 'bg-selection/50 text-bright'
|
||||||
|
|||||||
162
dashboard/tests/autocomplete.test.js
Normal file
162
dashboard/tests/autocomplete.test.js
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { getTriggerInfo, filteredSkills } from '../utils/autocomplete.js';
|
||||||
|
|
||||||
|
const mockConfig = {
|
||||||
|
trigger: '/',
|
||||||
|
skills: [
|
||||||
|
{ name: 'commit', description: 'Create a git commit' },
|
||||||
|
{ name: 'review-pr', description: 'Review a pull request' },
|
||||||
|
{ name: 'comment', description: 'Add a comment' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('getTriggerInfo', () => {
|
||||||
|
it('returns null when no autocompleteConfig', () => {
|
||||||
|
const result = getTriggerInfo('/hello', 1, null);
|
||||||
|
assert.equal(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when autocompleteConfig is undefined', () => {
|
||||||
|
const result = getTriggerInfo('/hello', 1, undefined);
|
||||||
|
assert.equal(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects trigger at position 0', () => {
|
||||||
|
const result = getTriggerInfo('/', 1, mockConfig);
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
trigger: '/',
|
||||||
|
filterText: '',
|
||||||
|
replaceStart: 0,
|
||||||
|
replaceEnd: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects trigger after space', () => {
|
||||||
|
const result = getTriggerInfo('hello /co', 9, mockConfig);
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
trigger: '/',
|
||||||
|
filterText: 'co',
|
||||||
|
replaceStart: 6,
|
||||||
|
replaceEnd: 9,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects trigger after newline', () => {
|
||||||
|
const result = getTriggerInfo('line1\n/rev', 10, mockConfig);
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
trigger: '/',
|
||||||
|
filterText: 'rev',
|
||||||
|
replaceStart: 6,
|
||||||
|
replaceEnd: 10,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for non-trigger character', () => {
|
||||||
|
const result = getTriggerInfo('hello world', 5, mockConfig);
|
||||||
|
assert.equal(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for wrong trigger (! when config expects /)', () => {
|
||||||
|
const result = getTriggerInfo('!commit', 7, mockConfig);
|
||||||
|
assert.equal(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for trigger embedded in a word', () => {
|
||||||
|
const result = getTriggerInfo('path/to/file', 5, mockConfig);
|
||||||
|
assert.equal(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts filterText correctly', () => {
|
||||||
|
const result = getTriggerInfo('/commit', 7, mockConfig);
|
||||||
|
assert.equal(result.filterText, 'commit');
|
||||||
|
assert.equal(result.replaceStart, 0);
|
||||||
|
assert.equal(result.replaceEnd, 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filterText is lowercase', () => {
|
||||||
|
const result = getTriggerInfo('/CoMmIt', 7, mockConfig);
|
||||||
|
assert.equal(result.filterText, 'commit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaceStart and replaceEnd are correct for mid-input trigger', () => {
|
||||||
|
const result = getTriggerInfo('foo /bar', 8, mockConfig);
|
||||||
|
assert.equal(result.replaceStart, 4);
|
||||||
|
assert.equal(result.replaceEnd, 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with a different trigger character', () => {
|
||||||
|
const codexConfig = { trigger: '!', skills: [] };
|
||||||
|
const result = getTriggerInfo('!test', 5, codexConfig);
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
trigger: '!',
|
||||||
|
filterText: 'test',
|
||||||
|
replaceStart: 0,
|
||||||
|
replaceEnd: 5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filteredSkills', () => {
|
||||||
|
it('returns empty array without config', () => {
|
||||||
|
const info = { filterText: '' };
|
||||||
|
assert.deepEqual(filteredSkills(null, info), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array without triggerInfo', () => {
|
||||||
|
assert.deepEqual(filteredSkills(mockConfig, null), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when both are null', () => {
|
||||||
|
assert.deepEqual(filteredSkills(null, null), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns all skills with empty filter', () => {
|
||||||
|
const info = { filterText: '' };
|
||||||
|
const result = filteredSkills(mockConfig, info);
|
||||||
|
assert.equal(result.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters case-insensitively', () => {
|
||||||
|
const info = { filterText: 'com' };
|
||||||
|
const result = filteredSkills(mockConfig, info);
|
||||||
|
const names = result.map(s => s.name);
|
||||||
|
assert.ok(names.includes('commit'));
|
||||||
|
assert.ok(names.includes('comment'));
|
||||||
|
assert.ok(!names.includes('review-pr'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches anywhere in name', () => {
|
||||||
|
const info = { filterText: 'view' };
|
||||||
|
const result = filteredSkills(mockConfig, info);
|
||||||
|
assert.equal(result.length, 1);
|
||||||
|
assert.equal(result[0].name, 'review-pr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts alphabetically', () => {
|
||||||
|
const info = { filterText: '' };
|
||||||
|
const result = filteredSkills(mockConfig, info);
|
||||||
|
const names = result.map(s => s.name);
|
||||||
|
assert.deepEqual(names, ['comment', 'commit', 'review-pr']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when no matches', () => {
|
||||||
|
const info = { filterText: 'zzz' };
|
||||||
|
const result = filteredSkills(mockConfig, info);
|
||||||
|
assert.deepEqual(result, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mutate the original skills array', () => {
|
||||||
|
const config = {
|
||||||
|
trigger: '/',
|
||||||
|
skills: [
|
||||||
|
{ name: 'zebra', description: 'z' },
|
||||||
|
{ name: 'alpha', description: 'a' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const info = { filterText: '' };
|
||||||
|
filteredSkills(config, info);
|
||||||
|
assert.equal(config.skills[0].name, 'zebra');
|
||||||
|
assert.equal(config.skills[1].name, 'alpha');
|
||||||
|
});
|
||||||
|
});
|
||||||
48
dashboard/utils/autocomplete.js
Normal file
48
dashboard/utils/autocomplete.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// Pure logic for autocomplete trigger detection and skill filtering.
|
||||||
|
// Extracted from SimpleInput.js for testability.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect if cursor is at a trigger position for autocomplete.
|
||||||
|
* Returns trigger info object or null.
|
||||||
|
*/
|
||||||
|
export function getTriggerInfo(value, cursorPos, autocompleteConfig) {
|
||||||
|
if (!autocompleteConfig) return null;
|
||||||
|
|
||||||
|
const { trigger } = autocompleteConfig;
|
||||||
|
|
||||||
|
// Find the start of the current "word" (after last whitespace before cursor)
|
||||||
|
let wordStart = cursorPos;
|
||||||
|
while (wordStart > 0 && !/\s/.test(value[wordStart - 1])) {
|
||||||
|
wordStart--;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if word starts with this agent's trigger character
|
||||||
|
if (value[wordStart] === trigger) {
|
||||||
|
return {
|
||||||
|
trigger,
|
||||||
|
filterText: value.slice(wordStart + 1, cursorPos).toLowerCase(),
|
||||||
|
replaceStart: wordStart,
|
||||||
|
replaceEnd: cursorPos,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter and sort skills based on trigger info.
|
||||||
|
* Returns sorted array of matching skills.
|
||||||
|
*/
|
||||||
|
export function filteredSkills(autocompleteConfig, triggerInfo) {
|
||||||
|
if (!autocompleteConfig || !triggerInfo) return [];
|
||||||
|
|
||||||
|
const { skills } = autocompleteConfig;
|
||||||
|
const { filterText } = triggerInfo;
|
||||||
|
|
||||||
|
let filtered = filterText
|
||||||
|
? skills.filter(s => s.name.toLowerCase().includes(filterText))
|
||||||
|
: skills.slice();
|
||||||
|
|
||||||
|
// Server pre-sorts, but re-sort after filtering for stability
|
||||||
|
return filtered.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user