Files
Jyotisha/frontend/tests/home-bootstrap-reveal.test.ts
T
Jesse_ChenandClaude Opus 5.5 d2551437c4 fix(home): land on the blank starter home after login and on bare / (BUG-1052)
Product decision 2026-09-27 (D1-D4), overriding the BUG-1038 login-return
stash and the "latest session" default landing:

- A full load of / without ?c= (login, typed address, bookmark, refresh of
  bare /) lands on the current person's blank starter home; an existing
  empty draft of that person is reused, otherwise one is created locally.
- ?c= and ?new=1 are unchanged; a refresh inside a conversation keeps its ?c=.
- The login-return stash is removed: redirectToLogin and sidebar links no
  longer write it, bootstrap no longer reads it and clears a leftover value
  once. replace-selected, the lookup origin/other-subject branch and the warm
  resumeRectification dependency go with it.
- A background answer being recovered no longer takes over the blank home
  (same rule BUG-1015 set for ?new=1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-27 00:51:51 +08:00

249 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\)/);
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("));
});