Files
Jyotisha/frontend/tests/home-bootstrap-reveal.test.ts
T
jesse-uxandGrok (agent loop) 6998dc6ead 顺手收掉死样式、旧校正入口和整页跳转。
测试文档只在需要的测试里安装。提示和圆点按项分开。清单「去做」走客户端跳转。输入框高亮在离开页面时清掉。六个没有入口的旧校正组件删掉。

Co-Authored-By: Grok (agent loop)
2026-10-27 01:39:28 +08:00

252 lines
14 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 {
BOOTSTRAP_PREPARE_TIMEOUT_MS,
HOME_BOOTSTRAP_SLOW_MS,
SESSION_PREFETCH_COUNT,
bootstrapLoadingCopy,
bootstrapPrepareSettled,
bootstrapRevealDelayMs,
landingYieldsToRecovery,
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);
// 原值: resolveStarterHomeLandingSessionId(sessions, rectificationId, "replace-selected") === rectificationId。
// 新值: 该 urlAction 已删除,改为断言 lookup(`?c=` 查到后)同样保留所选会话。
// 原因: BUG-1052 D3 删除登录返回存根,replace-selected 只服务存根。
assert.equal(resolveStarterHomeLandingSessionId(sessions, rectificationId, "lookup"), rectificationId);
// 原值: resolveStarterHomeLandingSessionId(sessions, consultationId, "none") === consultationId(裸 `/` 打开最近普通对话)。
// 新值: === emptyConsultationId(裸 `/` 落空白首页,复用已有空草稿)。
// 原因: BUG-1052 D1 产品决策——裸 `/` 永远是空白首页,不落旧对话。
assert.equal(resolveStarterHomeLandingSessionId(sessions, consultationId, "none"), emptyConsultationId);
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 with a latest ordinary chat lands on a new blank starter home (BUG-1052)", () => {
// 原值: 标题 "bare home still opens the selected ordinary chat without creating another";
// none → consultationId,NeedsConsultation === false。
// 新值: none → 仍返回 consultationId(没有空草稿可复用),但 NeedsConsultation === true,启动会本地新建空对话落地。
// 原因: BUG-1052 D1——登录后 / 地址栏 / 书签 / 刷新裸 `/` 都落空白首页,不再打开最近一条旧对话。
const sessions = [{ id: consultationId, sessionType: "consultation", messages: [{ role: "user" }] }];
assert.equal(resolveStarterHomeLandingSessionId(sessions, consultationId, "none"), consultationId);
assert.equal(starterHomeLandingNeedsConsultation(sessions, consultationId, "none"), true);
});
test("a list row whose messages were not loaded is not mistaken for the blank home", () => {
// The session list can omit messages (messagesHydrated false). Such a row is
// a saved conversation, not an empty draft, so bare `/` must not land on it.
const sessions = [
{ id: consultationId, sessionType: "consultation", messages: [], messagesHydrated: false },
{ id: rectificationId, sessionType: "birth_time_rectification", messages: [] },
];
assert.equal(resolveStarterHomeLandingSessionId(sessions, consultationId, "none"), consultationId);
assert.equal(starterHomeLandingNeedsConsultation(sessions, consultationId, "none"), true);
assert.equal(resolveStarterHomeLandingSessionId(sessions, rectificationId, "none"), rectificationId);
assert.equal(starterHomeLandingNeedsConsultation(sessions, rectificationId, "none"), true);
// An archived empty row is not a draft either.
const archived = [{ id: emptyConsultationId, sessionType: "consultation", messages: [], archivedAt: "2026-09-01T00:00:00Z" }];
assert.equal(starterHomeLandingNeedsConsultation(archived, emptyConsultationId, "none"), true);
// A dead `?c=` (replace-clear) lands on the blank home too.
assert.equal(starterHomeLandingNeedsConsultation(sessions, consultationId, "replace-clear"), true);
});
test("a background answer takes the screen only when the address bar named a conversation", () => {
assert.equal(landingYieldsToRecovery("keep"), true);
assert.equal(landingYieldsToRecovery("lookup"), true);
assert.equal(landingYieldsToRecovery("none"), false);
assert.equal(landingYieldsToRecovery("replace-clear"), false);
assert.equal(landingYieldsToRecovery("new-chat"), 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\)/);
// 原值: 准备阶段预热 `void import("@/components/birth-time-rectification")`。
// 新值: 不再预热。页面不渲染这组旧校正组件。
// 原因: 这 6 个组件没有入口,按「多余入口宁可删除」去掉。
assert.doesNotMatch(page, /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("));
});