桌面侧栏可在 200 到 360 像素之间拖宽,拖到最左收起,宽度记在本机。手机从左边滑出。会话在同一组里拖动排序,顺序写入可空的 sidebar_order,收藏仍置顶。账户菜单、设置、反馈和退出确认改用组件库。顶栏点数只显示图标和数字。启程清单还在。菜单、拖宽、拖动排序和翻页在首屏之后再载入。
自测:tsc 退出码 0;npm run lint 0 error、121 warning;改到的前端测试 205 项全过;npm run build 后首页仍是 static;首屏 gzip 630350,相对 8f7c0f7d 的 621226 为 +1.47%。test:db 92 项里 79 过、13 败,失败名与 Windows 已知清单一致,排序列在通过的 ok 37。没有跑全量 npm test,没有跑 Python 快速门。没有登录态,没有在浏览器里拖侧栏。
Co-Authored-By: Grok (agent loop)
279 lines
12 KiB
TypeScript
279 lines
12 KiB
TypeScript
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/subject-switcher.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>): 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", () => {
|
||
// 原值: NEW_CHAT_SUBJECT_ID === "self".
|
||
// 新值: NEW_CHAT_SUBJECT_ID() reads the current-person store, which defaults to self.
|
||
// 原因: D5 overturns BUG-1000's fixed self for a new chat.
|
||
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, "1990-01-02");
|
||
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 \}\)/);
|
||
// 原值:`Popover.Trigger` / `Popover.Root` / `Popover.Title`(直接用 base-ui)
|
||
// 新值:shadcn `PopoverTrigger` / `Popover` / `PopoverTitle`
|
||
// 原因:K1 人物切换改用组件库 Popover。关合、焦点和 Escape 不变
|
||
const openChange = sourceBetween(pickerSource, "onOpenChange=", "<PopoverTrigger");
|
||
assert.match(openChange, /if \(!next\) triggerRef\.current\?\.focus\(\{ preventScroll: true \}\)/);
|
||
const trigger = sourceBetween(pickerSource, "<PopoverTrigger", "</PopoverTrigger>");
|
||
assert.match(trigger, /ref=\{triggerRef\}/);
|
||
const choose = sourceBetween(pickerSource, "async function choose(", "<Popover");
|
||
assert.match(choose, /if \(row\.selected\) closePicker\(\)/);
|
||
assert.match(choose, /if \(ok\) closePicker\(\)/);
|
||
const keyboard = sourceBetween(pickerSource, "onKeyDown=", "<PopoverTitle");
|
||
assert.match(keyboard, /event\.key !== "Escape"/);
|
||
assert.match(keyboard, /closePicker\(\)/);
|
||
});
|
||
|
||
test("the header control is a button with popover keyboard, focus return, and a 44px target", () => {
|
||
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 \}\)/);
|
||
// 原值:源码含 `Popover.Root`
|
||
// 新值:源码含 shadcn `<Popover`、`PopoverTrigger`、`PopoverContent`、`PopoverTitle`
|
||
// 原因:K1 改用组件库 Popover,不再直接写 base-ui 的 Root
|
||
assert.match(pickerSource, /<Popover[\s\S]*<PopoverTrigger[\s\S]*<PopoverContent[\s\S]*<PopoverTitle/);
|
||
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, /<ChatProfilePicker/);
|
||
assert.match(pageSource, /activeSession\?\.sessionType === "consultation"/);
|
||
assert.match(pageSource, /className="chat-header-chart"/);
|
||
assert.match(pageSource, /sessionChartLabel\(activeSession, chartLibrary\)/);
|
||
assert.doesNotMatch(starterSource, /ChatProfilePicker/);
|
||
});
|