import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { NEW_CHAT_SUBJECT_ID, chatProfileChoice, chatProfileDisplayName, chatProfilePickerRows, chatProfileSelectedId, chatSubjectSendBlocked, emptySessionBindingWrite, } from "../src/lib/chat-profile-picker-model.ts"; import { emptyProfile, type ChartLibraryRecord, type ChatSession, type Profile } from "../src/lib/home-types.ts"; const pickerSource = readFileSync(new URL("../src/components/chat-profile-picker.tsx", import.meta.url), "utf8"); const hookSource = readFileSync(new URL("../src/hooks/use-session-management.ts", import.meta.url), "utf8"); const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); const starterSource = readFileSync(new URL("../src/components/starter-home.tsx", import.meta.url), "utf8"); function sourceBetween(source: string, start: string, end: string): string { const from = source.indexOf(start); const to = source.indexOf(end, from + start.length); assert.notEqual(from, -1, start); assert.notEqual(to, -1, end); return source.slice(from, to); } function person(name: string, complete = true): Profile { return { ...emptyProfile, name, date: complete ? "1990-01-02" : "", birthTimeSource: complete ? "family_exact" : "", birthTimeStatus: complete ? "confirmed" : "", time: complete ? "08:30" : "", birthPlaceLabel: complete ? "示例城市" : "", latitude: complete ? 31.2 : null, longitude: complete ? 121.5 : null, timezoneId: complete ? "Asia/Shanghai" : "", timezoneOffset: complete ? 8 : null, }; } function record(id: string, role: "self" | "other", name: string, complete = true): ChartLibraryRecord { return { id, role, relationship: role === "self" ? "self" : "friend", updatedAt: 1, profile: person(name, complete), }; } function session(patch: Partial): ChatSession { return { id: "11111111-1111-4111-8111-111111111111", title: "新对话", theme: "general", modelId: "m", messages: [], updatedAt: 1, sessionType: "consultation", rectificationCaseId: null, chartProfileId: "self", chartProfileName: "本人", chartProfileRole: "self", pinned: false, archivedAt: null, messagesHydrated: true, ...patch, }; } const self = record("self", "self", "本人"); const other = record("other-1", "other", "对方"); const incomplete = record("other-2", "other", "未写完", false); test("the picker lists self and owned others, with self selected by default", () => { assert.equal(NEW_CHAT_SUBJECT_ID, "self"); assert.equal(chatProfileSelectedId(session({ chartProfileId: null, chartProfileRole: null, chartProfileName: null })), "self"); const rows = chatProfilePickerRows({ status: "ready", library: [self, other, incomplete], selectedId: "self" }); assert.deepEqual(rows.map((row) => row.id), ["self", "other-1", "other-2"]); assert.equal(rows[0]?.selected, true); assert.equal(rows[0]?.detail, "本人"); assert.equal(rows[1]?.detail, "朋友"); assert.equal(rows[2]?.complete, false); assert.equal(rows[2]?.detail, "这份资料还不完整"); }); test("an empty consultation binds the chosen person and does not open another session", () => { const empty = session({ messages: [] }); assert.deepEqual(chatProfileChoice({ session: empty, chartProfileId: "other-1", requestInFlight: false, libraryReady: true, selectable: true, }), { action: "bind", chartProfileId: "other-1" }); assert.deepEqual(chatProfileChoice({ session: empty, chartProfileId: "self", requestInFlight: false, libraryReady: true, selectable: true, }), { action: "ignore" }); }); test("a session with messages offers a new session and does not rebind", () => { const locked = session({ chartProfileId: "self", messages: [{ role: "user", text: "想看事业" }], }); assert.deepEqual(chatProfileChoice({ session: locked, chartProfileId: "other-1", requestInFlight: false, libraryReady: true, selectable: true, }), { action: "open-new", chartProfileId: "other-1" }); assert.deepEqual(chatProfileChoice({ session: locked, chartProfileId: "self", requestInFlight: false, libraryReady: true, selectable: true, }), { action: "ignore" }); assert.deepEqual(chatProfileChoice({ session: locked, chartProfileId: "other-1", requestInFlight: true, libraryReady: true, selectable: true, }), { action: "ignore" }); }); test("a library that is still loading or failed cannot create a binding", () => { assert.deepEqual(chatProfilePickerRows({ status: "failed", library: [self, other], selectedId: "self" }), []); assert.deepEqual(chatProfilePickerRows({ status: "loading", library: [self, other], selectedId: "other-1" }), []); assert.deepEqual(chatProfileChoice({ session: session({}), chartProfileId: "other-1", requestInFlight: false, libraryReady: false, selectable: true, }), { action: "ignore" }); assert.deepEqual(chatProfileChoice({ session: session({}), chartProfileId: "other-2", requestInFlight: false, libraryReady: true, selectable: false, }), { action: "ignore" }); }); test("a deleted person stays visible and blocks sending without falling back to self", () => { const deleted = session({ chartProfileId: "other-1", chartProfileName: "对方", chartProfileRole: "other", messages: [{ role: "user", text: "想看事业" }], }); assert.equal(chatProfileDisplayName({ session: deleted, status: "loading", library: [self] }), "对方"); assert.equal(chatProfileDisplayName({ session: deleted, status: "failed", library: [self] }), "对方"); assert.equal(chatSubjectSendBlocked({ session: deleted, status: "loading", library: [self] }), false); assert.equal(chatSubjectSendBlocked({ session: deleted, status: "failed", library: [self] }), false); assert.equal(chatProfileDisplayName({ session: deleted, status: "ready", library: [self] }), "资料已删除 · 对方"); assert.equal(chatSubjectSendBlocked({ session: deleted, status: "ready", library: [self] }), true); assert.equal(chatProfileSelectedId(deleted), "other-1"); assert.notEqual(chatProfileSelectedId(deleted), NEW_CHAT_SUBJECT_ID); }); test("a renamed living profile shows the current name and leaves the stored snapshot alone", () => { const stored = session({ chartProfileId: "other-1", chartProfileName: "对方", chartProfileRole: "other", }); const renamed = record("other-1", "other", "新名字"); assert.equal(chatProfileDisplayName({ session: stored, status: "ready", library: [self, renamed] }), "新名字"); assert.equal(stored.chartProfileName, "对方"); }); test("opening a stored session keeps its subject instead of the active chart", () => { const opened = session({ chartProfileId: "other-1", chartProfileName: "对方", chartProfileRole: "other", }); const activeChartId = "self"; assert.equal(chatProfileSelectedId(opened), "other-1"); assert.notEqual(chatProfileSelectedId(opened), activeChartId); const selectSession = sourceBetween(hookSource, "function selectSession(", "function activateFallbackSession("); assert.doesNotMatch(selectSession, /setActiveChartId/); assert.doesNotMatch(selectSession, /chartProfileId\s*:/); assert.doesNotMatch(selectSession, /activeChartId/); }); test("the empty-session write sends an id and role, not a client-forged name", () => { const write = emptySessionBindingWrite("other-1", "other"); assert.deepEqual(write, { chart_profile_id: "other-1", chart_profile_role: "other" }); assert.equal("chart_profile_name" in write, false); assert.deepEqual(emptySessionBindingWrite("forged", "self"), { chart_profile_id: "self", chart_profile_role: "self", }); const bind = sourceBetween(hookSource, "async function bindEmptyChatSubject(", "async function openChatBoundToProfile("); assert.match(bind, /chatSessionSubjectLocked/); assert.match(bind, /emptySessionBindingWrite/); assert.doesNotMatch(bind, /chart_profile_name/); assert.doesNotMatch(bind, /setActiveChartId/); }); test("a new session for another person does not patch the old one or send during a request", () => { const open = sourceBetween(hookSource, "async function openChatBoundToProfile(", "\n return {"); assert.doesNotMatch(open, /writeChatSession\(/); assert.match(open, /pendingSessionId \|\| cancellationPending/); assert.match(open, /chartSnapshotForSession\(requestedId/); const startNewChat = sourceBetween(hookSource, "async function startNewChat(", "function selectSession("); assert.match(startNewChat, /NEW_CHAT_SUBJECT_ID/); assert.doesNotMatch(startNewChat, /activeChartId/); assert.match(startNewChat, /setDraft\(""\)/); assert.doesNotMatch(startNewChat, /persistSession\(/); assert.doesNotMatch(startNewChat, /writeSessionUrl\(nextSession\.id, "push"\)/); }); test("both picker close paths return focus to the trigger without scrolling", () => { const close = sourceBetween(pickerSource, "function closePicker()", "async function choose("); assert.match(close, /setOpen\(false\)/); assert.match(close, /triggerRef\.current\?\.focus\(\{ preventScroll: true \}\)/); const openChange = sourceBetween(pickerSource, "onOpenChange=", ""); assert.match(trigger, /ref=\{triggerRef\}/); const choose = sourceBetween(pickerSource, "async function choose(", " { assert.match(pickerSource, /type="button"/); assert.match(pickerSource, /aria-expanded=\{open\}/); assert.match(pickerSource, /aria-controls=\{listId\}/); assert.match(pickerSource, /aria-label=\{`当前星盘:\$\{label\}`\}/); assert.match(pickerSource, /event\.key !== "Escape"/); // 原值:triggerRef.current?.focus()。 // 新值:triggerRef.current?.focus({ preventScroll: true })。 // 原因:BUG-1013 保留回到触发按钮的焦点,不让浏览器连带滚动聊天面板。 assert.match(pickerSource, /triggerRef\.current\?\.focus\(\{ preventScroll: true \}\)/); assert.match(pickerSource, /Popover\.Root/); assert.match(pickerSource, /新建会话并使用此人物/); assert.match(pickerSource, /这段回答结束后再换/); assert.match(pickerSource, /人物列表还在读/); assert.match(pickerSource, /人物列表暂时读不出来/); assert.doesNotMatch(pickerSource, /正在加载/); assert.match(styles, /\.chat-profile-picker-trigger\s*\{[^}]*min-height:\s*44px/); assert.match(styles, /\.chat-profile-picker-option\s*\{[^}]*min-height:\s*44px/); assert.match(styles, /\.chat-profile-picker-popup\s*\{[^}]*transition:\s*opacity 120ms ease-out/); assert.match(styles, /\.chat-profile-picker-popup\[data-starting-style\][\s\S]*transform:\s*none/); assert.match(pageSource, /