A 401 for the current person was stored, then hidden because the subject stamp only happened on success. Session, report, and birth contracts now follow the subject query and the shared birth lookup. Admin reset fixtures can no longer insert a retired self mirror.
212 lines
11 KiB
TypeScript
212 lines
11 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
import test from "node:test";
|
||
|
||
import {
|
||
BOOTSTRAP_PREPARE_TIMEOUT_MS,
|
||
HOME_BOOTSTRAP_SLOW_MS,
|
||
SESSION_PREFETCH_COUNT,
|
||
bootstrapLoadingCopy,
|
||
bootstrapPrepareSettled,
|
||
bootstrapRevealDelayMs,
|
||
resolveStarterHomeLandingSessionId,
|
||
sessionIdsToPrefetch,
|
||
shouldAutoOpenRectificationSession,
|
||
starterHomeLandingNeedsConsultation,
|
||
} from "../src/lib/home-bootstrap.ts";
|
||
import { homeSurface } from "./home-surface.ts";
|
||
|
||
// Product rule (2026-09-02): the loading screen owns every wait. After the home
|
||
// page is revealed there is no component-level spinner left — only the streaming
|
||
// answer keeps a live marker, because that is content being generated, not data
|
||
// being loaded.
|
||
|
||
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||
const bootstrapRun = readFileSync(new URL("../src/lib/home-bootstrap-run.ts", import.meta.url), "utf8");
|
||
const bootstrapError = readFileSync(new URL("../src/components/home-bootstrap-error.tsx", import.meta.url), "utf8");
|
||
const starterHome = readFileSync(new URL("../src/components/starter-home.tsx", import.meta.url), "utf8");
|
||
const globalsCss = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||
const staleClientRecovery = readFileSync(new URL("../src/components/stale-client-recovery.tsx", import.meta.url), "utf8");
|
||
|
||
const rectificationId = "11111111-1111-4111-8111-111111111111";
|
||
const consultationId = "22222222-2222-4222-8222-222222222222";
|
||
const emptyConsultationId = "33333333-3333-4333-8333-333333333333";
|
||
|
||
test("bare / after login does not auto-open the latest rectification session", () => {
|
||
assert.equal(shouldAutoOpenRectificationSession({
|
||
sessionType: "birth_time_rectification",
|
||
sessionId: rectificationId,
|
||
search: "",
|
||
}), false);
|
||
assert.equal(shouldAutoOpenRectificationSession({
|
||
sessionType: "birth_time_rectification",
|
||
sessionId: rectificationId,
|
||
search: `?c=${rectificationId}`,
|
||
}), true);
|
||
assert.equal(shouldAutoOpenRectificationSession({
|
||
sessionType: "birth_time_rectification",
|
||
sessionId: rectificationId,
|
||
search: `?c=${consultationId}`,
|
||
}), false);
|
||
assert.equal(shouldAutoOpenRectificationSession({
|
||
sessionType: "consultation",
|
||
sessionId: consultationId,
|
||
search: `?c=${consultationId}`,
|
||
}), false);
|
||
});
|
||
|
||
test("default landing replaces a latest rectification session with an empty consultation", () => {
|
||
const sessions = [
|
||
{ id: rectificationId, sessionType: "birth_time_rectification", messages: [] },
|
||
{ id: emptyConsultationId, sessionType: "consultation", messages: [] },
|
||
{ id: consultationId, sessionType: "consultation", messages: [{ role: "user" }] },
|
||
];
|
||
assert.equal(resolveStarterHomeLandingSessionId(sessions, rectificationId, "none"), emptyConsultationId);
|
||
assert.equal(resolveStarterHomeLandingSessionId(sessions, rectificationId, "replace-clear"), emptyConsultationId);
|
||
assert.equal(resolveStarterHomeLandingSessionId(sessions, rectificationId, "keep"), rectificationId);
|
||
assert.equal(resolveStarterHomeLandingSessionId(sessions, rectificationId, "replace-selected"), rectificationId);
|
||
assert.equal(resolveStarterHomeLandingSessionId(sessions, consultationId, "none"), consultationId);
|
||
assert.equal(
|
||
starterHomeLandingNeedsConsultation(
|
||
[{ id: rectificationId, sessionType: "birth_time_rectification", messages: [] }],
|
||
rectificationId,
|
||
"none",
|
||
),
|
||
true,
|
||
);
|
||
assert.equal(
|
||
starterHomeLandingNeedsConsultation(sessions, emptyConsultationId, "none"),
|
||
false,
|
||
);
|
||
assert.equal(
|
||
starterHomeLandingNeedsConsultation(sessions, rectificationId, "keep"),
|
||
false,
|
||
);
|
||
// 原值: page.tsx 直接调用 resolveStarterHomeLandingSessionId / starterHomeLandingNeedsConsultation。
|
||
// 新值: 这两个调用在 home-bootstrap-run.ts;page.tsx 仍调用 shouldAutoOpenRectificationSession。
|
||
// 原因: 启动落点搬进 runHomeBootstrap,揭幕阶段的校正会话判断留在首页。
|
||
assert.match(bootstrapRun, /resolveStarterHomeLandingSessionId\(/);
|
||
assert.match(page, /shouldAutoOpenRectificationSession\(/);
|
||
assert.match(bootstrapRun, /starterHomeLandingNeedsConsultation\(/);
|
||
});
|
||
|
||
test("new chat landing requests a fresh consultation even with an existing ordinary chat", () => {
|
||
const sessions = [
|
||
{ id: consultationId, sessionType: "consultation", messages: [{ role: "user" }] },
|
||
{ id: emptyConsultationId, sessionType: "consultation", messages: [] },
|
||
{ id: rectificationId, sessionType: "birth_time_rectification", messages: [] },
|
||
];
|
||
for (const session of sessions) {
|
||
assert.equal(resolveStarterHomeLandingSessionId(sessions, session.id, "new-chat"), session.id);
|
||
assert.equal(starterHomeLandingNeedsConsultation(sessions, session.id, "new-chat"), true);
|
||
}
|
||
assert.equal(starterHomeLandingNeedsConsultation([], consultationId, "new-chat"), true);
|
||
});
|
||
|
||
test("bare home still opens the selected ordinary chat without creating another", () => {
|
||
const sessions = [{ id: consultationId, sessionType: "consultation", messages: [{ role: "user" }] }];
|
||
assert.equal(resolveStarterHomeLandingSessionId(sessions, consultationId, "none"), consultationId);
|
||
assert.equal(starterHomeLandingNeedsConsultation(sessions, consultationId, "none"), false);
|
||
});
|
||
|
||
test("prepare phase settles only when every applicable item has an answer", () => {
|
||
// 原值: BootstrapPrepareState 含 profileComplete + onboardingSettled,未完成建议问题不得揭幕。
|
||
// 新值: 揭幕只等入口摘要、今日星语(若适用)与校正会话(若适用)。
|
||
// 原因: 主题卡与 /api/onboarding 已下线,建议问题不再挡首页。
|
||
const base = {
|
||
dailyStarlanguageApplicable: true,
|
||
dailyStarlanguageSettled: true,
|
||
entrySummarySettled: true,
|
||
};
|
||
assert.equal(bootstrapPrepareSettled(base), true);
|
||
assert.equal(bootstrapPrepareSettled({ ...base, entrySummarySettled: false }), false);
|
||
assert.equal(bootstrapPrepareSettled({ ...base, dailyStarlanguageSettled: false }), false);
|
||
assert.equal(bootstrapPrepareSettled({ ...base, dailyStarlanguageApplicable: false, dailyStarlanguageSettled: false }), true);
|
||
});
|
||
|
||
test("home bootstrap keeps one slow-network budget before the fatal screen", () => {
|
||
// 原值: 首页账户阶段使用 8000ms 内联总超时。
|
||
// 新值: 账户、模型与状态等待共享 20000ms,超时后只显示错误屏。
|
||
// 原因: 慢网串行链需要一次等待一次揭幕,不能半揭幕或提前放弃。
|
||
assert.equal(HOME_BOOTSTRAP_SLOW_MS, 20_000);
|
||
// 原值: page.tsx 引用 HOME_BOOTSTRAP_SLOW_MS。
|
||
// 新值: 20 秒预算在 runHomeBootstrap 里。
|
||
// 原因: 超时属于启动流程,首页只调用这个函数。
|
||
assert.match(bootstrapRun, /HOME_BOOTSTRAP_SLOW_MS/);
|
||
assert.doesNotMatch(page, /\}, 8000\);/);
|
||
assert.doesNotMatch(bootstrapRun, /\}, 8000\);/);
|
||
});
|
||
|
||
test("reveal delay is zero once settled and counts down from the phase start otherwise", () => {
|
||
assert.equal(BOOTSTRAP_PREPARE_TIMEOUT_MS, 4000);
|
||
assert.equal(bootstrapRevealDelayMs(10_000, 9_000, true), 0);
|
||
assert.equal(bootstrapRevealDelayMs(10_000, 9_000, false), 3000);
|
||
assert.equal(bootstrapRevealDelayMs(20_000, 9_000, false), 0);
|
||
assert.equal(bootstrapRevealDelayMs(10_000, null, false), BOOTSTRAP_PREPARE_TIMEOUT_MS);
|
||
});
|
||
|
||
test("loading copy changes per phase so the single loading screen narrates progress", () => {
|
||
const account = bootstrapLoadingCopy("account");
|
||
const prepare = bootstrapLoadingCopy("prepare");
|
||
assert.notEqual(account.title, prepare.title);
|
||
assert.notEqual(account.detail, prepare.detail);
|
||
assert.match(page, /const loadingCopy = bootstrapLoadingCopy\(bootstrapPhase\);/);
|
||
assert.match(page, /<AppLoadingIndicator title=\{loadingCopy\.title\} detail=\{loadingCopy\.detail\} \/>/);
|
||
});
|
||
|
||
test("bootstrap enters the prepare phase instead of revealing after the account fetch", () => {
|
||
// 原值: `if (bootstrapFailed) setHydrated(true); else setBootstrapPhase("prepare");` 写在 page.tsx。
|
||
// 新值: 同一分支写在 runHomeBootstrap,setter 经 deps.commit 注入。
|
||
// 原因: 失败仍揭幕错误屏,成功仍进入 prepare,不半揭幕。
|
||
assert.match(bootstrapRun, /if \(bootstrapFailed\) deps\.commit\.setHydrated\(true\);\n\s*else deps\.commit\.setBootstrapPhase\("prepare"\);/);
|
||
// 原值: 3 个 prepare 守卫(onboarding / 今日星语 / 入口摘要)。
|
||
// 新值: 2 个(今日星语 / 入口摘要)。
|
||
// 原因: 建议问题请求已删除。
|
||
const binder = readFileSync(new URL("../src/components/daily-starlanguage-binder.tsx", import.meta.url), "utf8");
|
||
const guards = `${page}\n${binder}`.match(/if \(bootstrapPhase === "account" \|\| !accountId/g) ?? [];
|
||
assert.equal(guards.length, 2, "daily starlanguage and entry summary must both key off bootstrapPhase");
|
||
assert.match(page, /setRectificationEntrySummarySettled\(true\);/);
|
||
assert.match(page, /bootstrapRevealDelayMs\(Date\.now\(\), prepareStartedAt\.current, bootstrapPrepareReady\)/);
|
||
assert.match(page, /window\.setTimeout\(\(\) => setHydrated\(true\), delay\)/);
|
||
assert.match(page, /void import\("@\/components\/birth-time-rectification"\);/);
|
||
});
|
||
|
||
test("revealed home carries no component loading state", () => {
|
||
assert.doesNotMatch(page, /starter-loading/);
|
||
assert.doesNotMatch(starterHome, /starter-loading|dailyStarlanguageBusy|aria-busy=\{/);
|
||
assert.doesNotMatch(globalsCss, /\.starter-loading/);
|
||
assert.doesNotMatch(page, /InlineSpinner/);
|
||
assert.doesNotMatch(page, /dailyStarlanguageBusy|正在写下今天的星语/);
|
||
// Switching to a session whose messages are not cached keeps a silent placeholder.
|
||
assert.match(page, /className="message-list session-messages-loading" role="status" aria-busy="true" aria-live="polite">\n\s*<span className="sr-only">正在加载聊天记录<\/span>/);
|
||
});
|
||
|
||
test("recent sessions are prefetched after reveal so switches do not wait", () => {
|
||
assert.equal(SESSION_PREFETCH_COUNT, 5);
|
||
const sessions = [
|
||
{ id: "active", sessionType: "consultation" },
|
||
{ id: "a", sessionType: "consultation" },
|
||
{ id: "b", sessionType: "consultation", messagesHydrated: true },
|
||
{ id: "r", sessionType: "birth_time_rectification" },
|
||
{ id: "c", sessionType: "consultation" },
|
||
{ id: "d", sessionType: "consultation" },
|
||
{ id: "e", sessionType: "consultation" },
|
||
{ id: "f", sessionType: "consultation" },
|
||
{ id: "g", sessionType: "consultation" },
|
||
];
|
||
assert.deepEqual(sessionIdsToPrefetch(sessions, "active"), ["a", "c", "d", "e", "f"]);
|
||
assert.match(page, /sessionIdsToPrefetch\(sessionsRef\.current, activeSessionIdRef\.current\)/);
|
||
assert.match(page, /await ensureSessionMessages\(sessionId\);/);
|
||
});
|
||
|
||
test("the loading screen DOM contract stale-client recovery depends on is untouched", () => {
|
||
assert.match(page, /<main className="app-loading" aria-busy="true" aria-live="polite">/);
|
||
// 原值: 错误屏 main 写在 page.tsx。
|
||
// 新值: 同一段 main 在 home-bootstrap-error.tsx。
|
||
// 原因: 错误屏抽成组件,class 与 role 不变,僵死恢复脚本仍能找到它。
|
||
assert.match(bootstrapError, /<main className="app-loading app-loading-error">/);
|
||
assert.match(staleClientRecovery, /main\.app-loading\[aria-busy="true"\]/);
|
||
assert.match(staleClientRecovery, /main\.app-loading-error/);
|
||
assert.ok(homeSurface.includes("bootstrapPrepareSettled("));
|
||
});
|