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
249 lines
14 KiB
TypeScript
249 lines
14 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,
|
||
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("));
|
||
});
|