Files
Jyotisha/frontend/tests/chat-profile-picker.test.ts
T
jesse-uxandClaude Code 36a737616c
Independent Staging Quality Gate / validate (push) Failing after 12m24s
Independent Staging Quality Gate / publish (push) Skipped
fix(frontend): repair staging breakpoint and focus contracts
Merge entry overflow rules into the existing 480px breakpoint, restore picker focus without scrolling, and sync composer and entry contracts without changing page logic. Add AST generation-state guard and both picker close-path regressions.

Validation: targeted 33/33; Chrome isolated components 145 checks; tsc and lint zero errors. Node22 full suite 3706 tests, no lost names or new failures; three target failures fixed, 88 baseline environment/contract failures unchanged. Windows build remains blocked by symlink EPERM. Changed-file privacy check: 11 files, no findings, not a full-repository pass. BUG-1011 remains separate.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 16:31:10 +08:00

270 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/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>): 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=", "<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/);
});