Bare `/` was treating the latest history session as active, so a returning account could auto-open a failed rectification Case and left-align the starter home. Co-authored-by: Cursor <cursoragent@cursor.com>
167 lines
8.2 KiB
TypeScript
167 lines
8.2 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
BOOTSTRAP_PREPARE_TIMEOUT_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/page.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,
|
|
);
|
|
assert.match(page, /resolveStarterHomeLandingSessionId\(/);
|
|
assert.match(page, /shouldAutoOpenRectificationSession\(/);
|
|
assert.match(page, /starterHomeLandingNeedsConsultation\(/);
|
|
});
|
|
|
|
test("prepare phase settles only when every applicable item has an answer", () => {
|
|
const base = {
|
|
profileComplete: true,
|
|
onboardingSettled: true,
|
|
dailyStarlanguageApplicable: true,
|
|
dailyStarlanguageSettled: true,
|
|
entrySummarySettled: true,
|
|
};
|
|
assert.equal(bootstrapPrepareSettled(base), true);
|
|
assert.equal(bootstrapPrepareSettled({ ...base, entrySummarySettled: false }), false);
|
|
assert.equal(bootstrapPrepareSettled({ ...base, onboardingSettled: false }), false);
|
|
assert.equal(bootstrapPrepareSettled({ ...base, dailyStarlanguageSettled: false }), false);
|
|
// Items that do not apply to this account never hold the reveal.
|
|
assert.equal(bootstrapPrepareSettled({ ...base, profileComplete: false, onboardingSettled: false }), true);
|
|
assert.equal(bootstrapPrepareSettled({ ...base, dailyStarlanguageApplicable: false, dailyStarlanguageSettled: false }), true);
|
|
});
|
|
|
|
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", () => {
|
|
assert.match(page, /if \(bootstrapFailed\) setHydrated\(true\);\n\s*else setBootstrapPhase\("prepare"\);/);
|
|
// The three secondary fetches start in the prepare phase, not after reveal.
|
|
const guards = page.match(/if \(bootstrapPhase === "account" \|\| !accountId/g) ?? [];
|
|
assert.equal(guards.length, 3, "onboarding, daily starlanguage and entry summary must all 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">/);
|
|
assert.match(page, /<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("));
|
|
});
|