Files
Jyotisha/frontend/tests/chat-profile-picker.test.ts
T
jesse-ux ab6c55f822 feat: keep up to five chart subjects on typed columns
Chart, ephemeris, reports, daily language, consult, and synastry read birth data only through resolveSubjectBirth. The current person is a request parameter outside Home(). /people replaces the settings chart pane, and deleting someone removes that person's chats and reports.
2026-09-25 04:19:05 +08:00

273 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 \}\)/);
const openChange = sourceBetween(pickerSource, "onOpenChange=", "<Popover.Trigger");
assert.match(openChange, /if \(!next\) triggerRef\.current\?\.focus\(\{ preventScroll: true \}\)/);
const trigger = sourceBetween(pickerSource, "<Popover.Trigger", "</Popover.Trigger>");
assert.match(trigger, /ref=\{triggerRef\}/);
const choose = sourceBetween(pickerSource, "async function choose(", "<Popover.Root");
assert.match(choose, /if \(row\.selected\) closePicker\(\)/);
assert.match(choose, /if \(ok\) closePicker\(\)/);
const keyboard = sourceBetween(pickerSource, "onKeyDown=", "<Popover.Title");
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 \}\)/);
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, /<ChatProfilePicker/);
assert.match(pageSource, /activeSession\?\.sessionType === "consultation"/);
assert.match(pageSource, /className="chat-header-chart"/);
assert.match(pageSource, /sessionChartLabel\(activeSession, chartLibrary\)/);
assert.doesNotMatch(starterSource, /ChatProfilePicker/);
});