251 lines
10 KiB
TypeScript
251 lines
10 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("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"/);
|
|
assert.match(pickerSource, /triggerRef\.current\?\.focus\(\)/);
|
|
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/);
|
|
});
|