import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import vm from "node:vm"; // Execute the panels' actual JS methods and binding expressions against synthetic // services. This checks state/action logic, not Qt focus, rendering or input delivery. const read = name => readFileSync(new URL(name, import.meta.url), "utf8"); const util = vm.createContext({}); vm.runInContext(read("Util.js").replace(/^\.pragma library\s*/, ""), util); function panel(name, service, sessionState = { query: "", selectedId: "", scrollY: 0 }) { const source = read(`${name}Panel.qml`); const launcher = name === "Launcher"; const rowsName = launcher ? "results" : "rows"; const calls = []; const context = vm.createContext({ sessionState, stateReady: false, Util: util, launcherService: launcher ? service : null, clipboardService: launcher ? null : service, rowHeight: launcher ? 52 : 56, rowStride: launcher ? 56 : 62, Qt: { ControlModifier: 1 }, surfaces: { closePopout: reason => calls.push(["close", reason]) }, pasteRequested: id => calls.push(["paste", id]), sessionStateChanged() {}, detailContent: {}, detailFlick: { contentY: 0, height: 120 }, }); const start = source.indexOf(" function updateState("); const end = source.indexOf(" width: parent ?", start); assert(start >= 0 && end > start, "method block exists"); vm.runInContext(source.slice(start, end), context); const expressionStart = source.indexOf("readonly property var panelState:") + "readonly property var panelState:".length; const expressionEnd = source.indexOf(` readonly property var ${rowsName}:`, expressionStart); const expression = source.slice(expressionStart, expressionEnd).trim(); Object.defineProperties(context, { query: { get: () => String(sessionState.query || "") }, panelState: { get: () => vm.runInContext(`(${expression})`, context) }, [rowsName]: { get: () => context.panelState[rowsName] || [] }, selectedIndex: { get: () => context.selectionIndex() }, selectedRow: { get: () => context[rowsName][context.selectedIndex] || {} }, selectedActions: { get: () => context.selectedRow.actions || [] }, }); const viewport = { height: 120 }; Object.defineProperty(viewport, "contentY", { get: () => Math.max(0, Math.min(sessionState.scrollY, context[rowsName].length * context.rowStride - 4 - viewport.height)), }); context[launcher ? "resultsFlick" : "list"] = viewport; return { context, calls, source, viewport }; } const entries = Array.from({ length: 80 }, (_, i) => ({ id: `item-${i}`, title: `match ${i}`, pinned: false, actions: Array.from({ length: 8 }, (_, n) => ({ id: `action-${n}` })), })); for (const name of ["Launcher", "Clipboard"]) { const key = name === "Launcher" ? "results" : "rows"; const service = { entries: entries.slice(), search(query) { return JSON.stringify({ [key]: this.entries.filter(row => row.title.includes(query)) }); }, }; const state = { query: "match", selectedId: "item-20", scrollY: 1000 }; const { context: first } = panel(name, service, state); first.reconcileSelection(); assert.equal(first.selectedRow.id, "item-20"); assert.equal(state.scrollY, 1000, "initial reconciliation preserves saved scroll"); const viewportName = name === "Launcher" ? "resultsFlick" : "list"; const viewport = first[viewportName]; first[viewportName] = { contentY: 37, height: 120 }; first.rememberScroll(); assert.equal(state.scrollY, 1000, "initial layout cannot overwrite saved scroll"); first.stateReady = true; first.rememberScroll(); assert.equal(state.scrollY, 37, "user scrolling updates the retained owner"); first[viewportName] = viewport; first.select(79); assert.equal(state.selectedId, "item-79", "all rows remain selectable, beyond the old 60-row cap"); assert.equal(state.scrollY, 79 * first.rowStride + first.rowHeight - 120); first.select(-20); assert.equal(state.selectedId, "item-0"); assert.equal(state.scrollY, 0); first.select(20); const saved = { ...state }; const { context: reopened } = panel(name, service, state); reopened.reconcileSelection(); assert.equal(reopened.query, saved.query); assert.equal(reopened.selectedRow.id, saved.selectedId); assert.equal(state.scrollY, saved.scrollY, "recreated panel uses owner scroll"); service.entries = service.entries.toReversed(); reopened.reconcileSelection(); assert.equal(reopened.selectedRow.id, "item-20", "service reorder preserves selected ID"); service.entries = service.entries.filter(row => row.id !== "item-20"); reopened.reconcileSelection(); assert.equal(reopened.selectedRow.id, "item-79", "removed selection falls back to first match"); reopened[name === "Launcher" ? "setQuery" : "search"]("absent"); reopened.reconcileSelection(); assert.equal(reopened.selectedIndex, -1); assert.equal(state.selectedId, ""); assert.equal(state.query, "absent", "zero matches do not clear the query"); reopened[name === "Launcher" ? "setQuery" : "search"](""); reopened.reconcileSelection(); assert.equal(state.query, "", "explicit clear remains available"); } const writes = []; const clipboard = { entries: entries.slice(0, 3), privateMode: false, lastAction: {}, search(query) { return JSON.stringify({ rows: this.entries.filter(row => row.title.includes(query)), privateMode: this.privateMode, lastAction: this.lastAction }); }, copy(id) { writes.push(["copy", id]); this.lastAction = { kind: "copy", id }; return this.search(""); }, togglePin(id) { writes.push(["pin", id]); this.entries = this.entries.map(row => row.id === id ? { ...row, pinned: !row.pinned } : row); return this.search(""); }, deleteRow(id) { writes.push(["delete", id]); this.entries = this.entries.filter(row => row.id !== id); return this.search(""); }, setPrivateMode(value) { writes.push(["private", value]); this.privateMode = value; return this.search(""); }, paste() { assert.fail("panel must not dispatch service paste"); }, }; const { context: clip, calls: clipCalls } = panel("Clipboard", clipboard, { query: "match 1", selectedId: "item-1", scrollY: 0 }); clip.copy("item-1"); clip.togglePin("item-1"); clip.setPrivateMode(true); assert.equal(clip.rows.length, 1, "unfiltered command return values never replace the search binding"); assert.equal(clip.rows[0].pinned, true); assert.equal(clip.panelState.privateMode, true); assert.equal(clip.panelState.lastAction.kind, "copy"); clip.paste("item-1"); clip.paste("absent"); assert.deepEqual(clipCalls, [["paste", "item-1"]], "paste is intent only and rejects missing rows"); clipboard.entries.push({ id: "new", title: "match 1 new" }); assert.equal(clip.rows.length, 2, "later captures remain reactive after actions"); clip.deleteRow("item-1"); clip.reconcileSelection(); assert.equal(clip.query, "match 1"); assert.equal(clip.selectedRow.id, "new"); assert.deepEqual(writes, [["copy", "item-1"], ["pin", "item-1"], ["private", true], ["delete", "item-1"]]); const launchWrites = []; const launcher = { search: () => JSON.stringify({ results: entries }), activate: row => launchWrites.push(["activate", JSON.parse(row).id]), activateAction: (row, index) => launchWrites.push(["action", JSON.parse(row).id, index]), }; const { context: launch, calls: launchCalls } = panel("Launcher", launcher); launch.select(4); launch.activateSelected(); launch.activateAction(7); launch.activateAction(8); launch.keyAction({ modifiers: 1, text: "2", isAutoRepeat: true }); launch.keyAction({ modifiers: 1, text: "2", isAutoRepeat: false }); assert.deepEqual(launchWrites, [["activate", "item-4"], ["action", "item-4", 7], ["action", "item-4", 1]]); assert.equal(launchCalls.length, 3, "successful dispatch closes once"); launch.revealAction({ mapToItem: () => ({ y: 400 }), height: 42 }); assert.equal(launch.detailFlick.contentY, 322, "lower action is revealed completely"); launch.revealAction({ mapToItem: () => ({ y: 10 }), height: 42 }); assert.equal(launch.detailFlick.contentY, 10, "reverse traversal reveals upper action"); for (const name of ["Launcher", "Clipboard"]) { const { context } = panel(name, null); context.reconcileSelection(); assert.equal(context.selectedIndex, -1); context.select(0); if (name === "Launcher") { context.activateSelected(); context.activateAction(0); } else { context.copy("missing"); context.paste("missing"); context.togglePin("missing"); context.deleteRow("missing"); context.setPrivateMode(true); } } console.log("search-panels.test.mjs: state and command logic pass; Qt interaction and visual validation not run");