Files
Jyotisha/frontend/tests/chat-profile-picker.test.ts
T

287 lines
13 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);
// 原值:detail 是「本人」/「1990-01-02」/「这份资料还不完整」
// 新值:身份挪进 badge;detail 是「日期 时间 · 城市」,不完整的说明补全后才能看盘
// 原因:issue #150 第 2 条,每一行写关系和出生时间、城市
assert.equal(rows[0]?.badge, "本人");
assert.equal(rows[0]?.detail, "1990-01-02 08:30 · 示例城市");
assert.equal(rows[1]?.badge, "其他");
assert.equal(rows[1]?.detail, "1990-01-02 08:30 · 示例城市");
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\}/);
// 原值:aria-label 只有称呼
// 新值:称呼后接「,出生日期时间 · 城市」(选中的资料完整时)
// 原因:issue #150 第 1 条,按钮上不只写名字
assert.match(pickerSource, /aria-label=\{`当前星盘:\$\{label\}\$\{triggerDetail \? `,\$\{triggerDetail\}` : ""\}`\}/);
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/);
});