Files
Jyotisha/frontend/tests/chat-profile-picker.test.ts
T
jesse-uxandGrok (agent loop) 352d45b2ef
Independent Staging Quality Gate / validate (push) Failing after 9m12s
Independent Staging Quality Gate / publish (push) Skipped
外壳换成组件库:侧栏可拖宽,会话可排序(#105)。
桌面侧栏可在 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)
2026-10-25 01:41:19 +08:00

279 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/);
});