Files
KrisHD_AI/test/composer.test.js
Fretux 7be654af11
CI / check (22) (push) Successful in 9m35s
CI / check (24) (push) Successful in 9m34s
Enable automatic web search by default; improve toggle behavior and search planning.
2026-10-06 08:50:03 +02:00

134 lines
7.0 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import {createComposerController} from "../public/js/composerController.js";
import {createConversationState} from "../public/js/conversationState.js";
import {createWebSearchToggle} from "../public/js/webSearch.js";
const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done; }); return {promise, resolve}; };
const noop = () => {};
const element = () => ({value: "Hello", style: {}, scrollHeight: 0, scrollTop: 0, clientHeight: 0,
classList: {toggle: noop, add: noop}, setAttribute: noop, focus: noop, querySelectorAll: () => []});
function fixture(overrides = {}) {
const state = createConversationState();
const elements = {msgInput: element(), sendBtn: element(), messagesDiv: element(), modelSelect: {value: "original-model"}};
const calls = {create: 0, streams: [], refresh: [], notices: []};
const webSearchButton = {...element(), addEventListener: (_event, handler) => { webSearchButton.click = handler; }};
const webSearchToggle = createWebSearchToggle(webSearchButton);
const controller = createComposerController({state, elements,
attachmentComposer: {pending: false, attachments: [], setBusy: noop, clear: noop, clearSentDraft: noop},
webSearchButton, reasoningToggle: {enabled: true, setBusy: noop, reset: noop}, webSearchToggle,
setChatActivity: noop, updateComposerPlaceholder: noop, setNotice: message => calls.notices.push(message),
createNewChat: async () => {
calls.create++;
const chat = overrides.creation ? await overrides.creation : {id: 1, title: "New chat"};
if (chat?.id) { state.activeChatId = chat.id; state.chatSessions.push(chat); }
return chat;
},
getChatById: id => state.chatSessions.find(chat => chat.id === id),
updateWorkspaceCopy: noop, renderChatList: noop, updateChatActionState: noop, scrollMessagesToBottom: noop,
addMessage: () => ({isConnected: true, querySelector: () => element(), remove: noop, replaceWith: noop}),
setMessageContent: noop, setActiveChat: async id => { calls.refresh.push(id); return overrides.refreshResult; }, loadSummary: noop, markOnboardingCompleted: noop, maybeShowPersonaNudge: noop,
api: {put: async () => overrides.rename ? await overrides.rename : {}, post: async () => assert.fail("Send cancellation must abort its own request"),
stream: async (_url, payload, handlers) => {
calls.streams.push(payload);
if (overrides.failure) throw new Error(overrides.failure);
handlers.onDone({reply: "Done"});
}}
});
return {state, elements, calls, webSearchButton, webSearchToggle, ...controller};
}
test("search stays enabled across sends and honors a persistent manual opt-out", async () => {
const s = fixture();
for (const message of ["First question", "Follow-up question"]) {
s.elements.msgInput.value = message;
await s.sendMessage();
}
s.webSearchButton.click();
for (const message of ["Offline question", "Another offline question"]) {
s.elements.msgInput.value = message;
await s.sendMessage();
}
assert.deepEqual(s.calls.streams.map(payload => payload.webSearch), [true, true, false, false]);
assert.equal(s.webSearchToggle.enabled, false);
s.webSearchButton.click();
s.elements.msgInput.value = "Search is allowed again";
await s.sendMessage();
assert.equal(s.calls.streams.at(-1).webSearch, true);
});
test("rapid sends lock before chat creation and keep the captured chat/model after navigation during rename", async () => {
const creation = deferred(), rename = deferred();
const s = fixture({creation: creation.promise, rename: rename.promise});
const first = s.sendMessage();
await s.sendMessage();
assert.equal(s.state.isProcessing, true);
assert.equal(s.calls.create, 1);
creation.resolve({id: 7, title: "New chat"});
await new Promise(resolve => setImmediate(resolve));
await s.sendMessage();
s.state.activeChatId = 8;
s.elements.modelSelect.value = "changed-model";
s.elements.msgInput.value = "Draft in another conversation";
rename.resolve({});
await first;
assert.equal(s.calls.streams.length, 1);
assert.equal(s.calls.streams[0].chatId, 7);
assert.equal(s.calls.streams[0].model, "original-model");
assert.equal(s.calls.streams[0].reasoning, true);
assert.deepEqual(s.calls.refresh, []);
assert.deepEqual(s.state.currentMessages, []);
assert.equal(s.elements.msgInput.value, "Draft in another conversation");
assert.equal(s.state.isProcessing, false);
});
test("creation, rename and stream failures unlock sending and retain the draft", async () => {
for (const options of [{creation: Promise.resolve({error: "Creation failed"})}, {rename: Promise.resolve({error: "Rename failed"})}, {failure: "Stream failed"}]) {
const s = fixture(options);
await s.sendMessage();
assert.equal(s.state.isProcessing, false);
assert.equal(s.elements.msgInput.disabled, false);
assert.equal(s.elements.msgInput.value, "Hello");
assert.match(s.calls.notices.at(-1), /failed/);
if (options.failure) assert.equal(s.state.currentMessages[0].deliveryStatus, "failed");
else assert.equal(s.calls.streams.length, 0);
}
});
test("Stop during chat creation prevents a later stream and releases the send lock", async () => {
const creation = deferred();
const s = fixture({creation: creation.promise});
const pending = s.sendMessage();
await s.stopGeneration();
creation.resolve({id: 4, title: "New chat"});
await pending;
assert.equal(s.calls.streams.length, 0);
assert.equal(s.state.isProcessing, false);
assert.equal(s.elements.msgInput.value, "Hello");
});
test("a completed reply and draft survive a failed history refresh without a false success notice", async () => {
const s = fixture({refreshResult: {error: "History unavailable", status: 503}});
await s.sendMessage();
assert.equal(s.state.currentMessages.at(-1).content, "Done");
assert.equal(s.state.currentMessages.filter(message => message.role === "bot").length, 1);
assert.equal(s.elements.msgInput.value, "Hello");
assert.match(s.calls.notices.at(-1), /could not be refreshed: History unavailable/);
assert.ok(!s.calls.notices.includes("Reply received."));
assert.equal(s.state.isProcessing, false);
});
test("navigation during refresh clears only the sent chat's saved draft", async () => {
const refresh = deferred();
const s = fixture({refreshResult: refresh.promise});
const sending = s.sendMessage();
await new Promise(resolve => setImmediate(resolve));
s.state.chatDrafts.set(1, "Hello");
s.state.activeChatId = 2;
s.elements.msgInput.value = "Other chat draft";
refresh.resolve({ok: true});
await sending;
assert.equal(s.state.chatDrafts.has(1), false);
assert.equal(s.elements.msgInput.value, "Other chat draft");
});