feat: filtre de recherche dans la sidebar Historique IA (#98) + menus '@' et '/' depuis le menu '+' (BUG-048)
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate - JSDOM tests for the AI history sidebar filter (#98).
|
||||
*
|
||||
* Covers the client-side search over the AI conversation history rendered in
|
||||
* the left sidebar "Historique IA" tab:
|
||||
* - sessions are loaded and rendered through `loadAISessionList()`
|
||||
* - `filterAIHistory()` narrows the list by title / preview / directory /
|
||||
* context / mode label, case- and accent-insensitively
|
||||
* - an empty match shows a "no match" hint instead of the empty state
|
||||
*
|
||||
* Usage: node tests/frontend/ai-sidebar.test.mjs
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const JS_DIR = path.resolve(__dirname, "..", "..", "frontend", "js");
|
||||
|
||||
const dom = new JSDOM(
|
||||
`<!DOCTYPE html>
|
||||
<html><body>
|
||||
<aside class="sidebar">
|
||||
<input id="sidebar-filter-input" value="" />
|
||||
<button id="sidebar-filter-case-btn"></button>
|
||||
<button id="sidebar-filter-clear-btn"></button>
|
||||
<div id="ai-history-list" class="recent-list"></div>
|
||||
<div id="ai-history-empty" class="recent-empty hidden">
|
||||
<i data-lucide="messages-square"></i><span>Aucune conversation</span>
|
||||
</div>
|
||||
</aside>
|
||||
</body></html>`,
|
||||
{ url: "http://localhost/", pretendToBeVisual: true },
|
||||
);
|
||||
|
||||
const w = dom.window;
|
||||
globalThis.window = w;
|
||||
globalThis.document = w.document;
|
||||
globalThis.HTMLElement = w.HTMLElement;
|
||||
globalThis.Element = w.Element;
|
||||
globalThis.Node = w.Node;
|
||||
globalThis.Event = w.Event;
|
||||
globalThis.CustomEvent = w.CustomEvent;
|
||||
globalThis.KeyboardEvent = w.KeyboardEvent;
|
||||
globalThis.localStorage = w.localStorage;
|
||||
globalThis.sessionStorage = w.sessionStorage;
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: w.navigator,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
globalThis.MutationObserver = w.MutationObserver;
|
||||
globalThis.getComputedStyle = w.getComputedStyle.bind(w);
|
||||
globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0);
|
||||
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
|
||||
|
||||
let testCount = 0;
|
||||
let passCount = 0;
|
||||
|
||||
async function test(name, fn) {
|
||||
testCount++;
|
||||
try {
|
||||
await fn();
|
||||
passCount++;
|
||||
console.log(` ✓ ${name}`);
|
||||
} catch (err) {
|
||||
console.error(` ✗ ${name}\n ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const SESSIONS = [
|
||||
{ id: "s1", title: "Pizza maison", mode: "general", updatedAt: 1, preview: "La pâte à pizza se lève toute la nuit" },
|
||||
{ id: "s2", title: "Recette café au lait", mode: "directory", directory: "Recettes", updatedAt: 2 },
|
||||
{ id: "s3", title: "Plan du cours", mode: "documents", context: "documents-books", updatedAt: 3 },
|
||||
];
|
||||
|
||||
globalThis.fetch = async (url) => {
|
||||
if (String(url).includes("/api/ai/bookslm/history")) {
|
||||
return { ok: true, status: 200, json: async () => ({ sessions: SESSIONS }) };
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({}) };
|
||||
};
|
||||
|
||||
const mod = await import(
|
||||
pathToFileURL(path.join(JS_DIR, "config.js")).href
|
||||
);
|
||||
const { loadAISessionList, filterAIHistory } = mod;
|
||||
|
||||
const listEl = () => document.getElementById("ai-history-list");
|
||||
const items = () => Array.from(listEl().querySelectorAll(".recent-item"));
|
||||
|
||||
console.log("AI history sidebar filter (#98) — config.js");
|
||||
|
||||
await test("loadAISessionList renders every session", async () => {
|
||||
await loadAISessionList();
|
||||
assert.equal(items().length, 3, "three sessions rendered");
|
||||
});
|
||||
|
||||
await test("filter narrows by title, case-insensitively", async () => {
|
||||
filterAIHistory("pizza");
|
||||
assert.equal(items().length, 1, "one session matches 'pizza'");
|
||||
filterAIHistory("PLAN");
|
||||
assert.equal(items().length, 1, "case-insensitive match on 'PLAN'");
|
||||
});
|
||||
|
||||
await test("filter matches accents-insensitively", async () => {
|
||||
filterAIHistory("cafe");
|
||||
assert.equal(items().length, 1, "'cafe' matches 'café au lait'");
|
||||
const titles = items().map((el) => el.textContent);
|
||||
assert.ok(titles.some((t) => t.includes("café")), "the café session is the one kept");
|
||||
});
|
||||
|
||||
await test("filter matches the preview and the directory", async () => {
|
||||
filterAIHistory("pâte");
|
||||
assert.equal(items().length, 1, "'pâte' matches the preview text");
|
||||
filterAIHistory("recettes");
|
||||
assert.equal(items().length, 1, "'recettes' matches the session directory");
|
||||
});
|
||||
|
||||
await test("clearing the query restores the full list", async () => {
|
||||
filterAIHistory("");
|
||||
assert.equal(items().length, 3, "no query keeps everything");
|
||||
});
|
||||
|
||||
await test("an unmatched query shows a no-match hint, not the empty state", async () => {
|
||||
filterAIHistory("zzz-inexistant");
|
||||
assert.equal(items().length, 0, "no item left");
|
||||
assert.ok(listEl().querySelector(".sidebar-filter-empty"),
|
||||
"a 'no match' hint is rendered in the list box");
|
||||
const emptyEl = document.getElementById("ai-history-empty");
|
||||
assert.ok(emptyEl.classList.contains("hidden") === false, "empty state is also visible");
|
||||
});
|
||||
|
||||
// ── Summary ──
|
||||
console.log(`\n${passCount}/${testCount} tests passed`);
|
||||
if (passCount !== testCount) {
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1802,6 +1802,96 @@ async function main() {
|
||||
assert.equal(AuthManager.getToken(), null, "session cleared");
|
||||
});
|
||||
|
||||
// ── BUG-048: the "+" panel menus must stay open (contexts @ / skills /) ──
|
||||
await test("ext menu opens the @ mention menu and keeps it visible", async () => {
|
||||
localStorage.clear();
|
||||
const stateMod = await import(pathToFileURL(path.join(JS_DIR, "state.js")).href);
|
||||
const prevVaults = stateMod.state.allVaults;
|
||||
stateMod.state.allVaults = [{ name: "V" }];
|
||||
globalThis.fetch = async (url) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/api/ai/skills")) {
|
||||
return {
|
||||
ok: true, status: 200,
|
||||
json: async () => ({
|
||||
skills: [{ id: "research", label: "Recherche", type: "skill", description: "d" }],
|
||||
commands: [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (u.includes("/paths")) {
|
||||
return {
|
||||
ok: true, status: 200,
|
||||
json: async () => ({ vault: "V", count: 1, results: [
|
||||
{ vault: "V", path: "notes/a.md", name: "a.md", type: "file" },
|
||||
] }),
|
||||
};
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({}) };
|
||||
};
|
||||
const b = new BooksLM();
|
||||
b._panel = b._render();
|
||||
document.body.appendChild(b._panel);
|
||||
const ta = b._panel.querySelector("textarea");
|
||||
ta.value = ta.selectionStart = ta.selectionEnd = 0;
|
||||
b._toggleExtMenu();
|
||||
const mentionMenu = b._panel.querySelector(".bookslm-mention-menu");
|
||||
const contextBtn = b._panel.querySelector('.bookslm-ext-item[data-ext-id="context"]');
|
||||
assert.ok(contextBtn, "Contextes entry present in the + panel");
|
||||
contextBtn.click();
|
||||
await sleep(20);
|
||||
assert.ok(!mentionMenu.classList.contains("hidden"),
|
||||
"mention menu stays open after picking Contextes (BUG-048)");
|
||||
assert.ok(mentionMenu.querySelectorAll(".bookslm-menu-item").length >= 1,
|
||||
"mention menu lists entries");
|
||||
b._panel.remove();
|
||||
localStorage.clear();
|
||||
stateMod.state.allVaults = prevVaults;
|
||||
});
|
||||
|
||||
await test("ext menu opens the / command menu and keeps it visible", async () => {
|
||||
localStorage.clear();
|
||||
globalThis.fetch = async (url) => {
|
||||
if (String(url).includes("/api/ai/skills")) {
|
||||
return {
|
||||
ok: true, status: 200,
|
||||
json: async () => ({
|
||||
skills: [{ id: "research", label: "Recherche", type: "skill", description: "d" }],
|
||||
commands: [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({}) };
|
||||
};
|
||||
const b = new BooksLM();
|
||||
b._panel = b._render();
|
||||
document.body.appendChild(b._panel);
|
||||
const ta = b._panel.querySelector("textarea");
|
||||
ta.value = ta.selectionStart = ta.selectionEnd = 0;
|
||||
b._toggleExtMenu();
|
||||
const commandMenu = b._panel.querySelector(".bookslm-command-menu");
|
||||
const skillBtn = b._panel.querySelector('.bookslm-ext-item[data-ext-id="skill"]');
|
||||
assert.ok(skillBtn, "Skills entry present in the + panel");
|
||||
skillBtn.click();
|
||||
await sleep(20);
|
||||
assert.ok(!commandMenu.classList.contains("hidden"),
|
||||
"command menu stays open after picking Skills (BUG-048)");
|
||||
assert.ok(commandMenu.querySelectorAll(".bookslm-menu-item").length >= 1,
|
||||
"command menu lists skills");
|
||||
b._panel.remove();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
await test("the + button carries a '+' icon", async () => {
|
||||
const b = new BooksLM();
|
||||
b._panel = b._render();
|
||||
document.body.appendChild(b._panel);
|
||||
const plus = b._panel.querySelector(".bookslm-btn-plus i");
|
||||
assert.ok(plus, "the + button shows an icon");
|
||||
assert.equal(plus.getAttribute("data-lucide"), "plus", "the + button icon is a plus");
|
||||
b._panel.remove();
|
||||
});
|
||||
|
||||
// ── Summary ──
|
||||
console.log(`\n${passCount}/${testCount} tests passed`);
|
||||
if (passCount !== testCount) {
|
||||
|
||||
Reference in New Issue
Block a user