#111 第 3 轮:建档不退回加载、右栏能拖宽、清单不迟到、月亮问句与别人的上升句(BUG-1455~1459)

- 建档重读人物名单时保留旧名单,失败也不卡在加载画面(BUG-1455)
- 对话右栏改用自己的拖宽把手,侧栏与右栏都能用方向键(BUG-1456/1457)
- 启程清单胶囊和首页一起出来,读状态时用上次的样子占位(BUG-1458)
- 起步第一问与别人的天空过场读一次已算好的星盘取星座,上升只在不会换座时用(BUG-1459,产品 10-27 同意)
- 补回删减的断言与三栏说明;登录代码改回原样,替身放进测试;删旧校正画面的无用样式
- 首屏 +7.54% 产品已接受,写进 DESIGN

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
This commit is contained in:
Jesse_Chen
2026-10-27 10:24:57 +08:00
co-authored by Claude Opus 5.5
parent 75cf7ad0e0
commit de9d4826c1
35 changed files with 1005 additions and 183 deletions
@@ -128,6 +128,12 @@ test("ready completion is explicit and terminal low has no finish mutation", ()
assert.doesNotMatch(hookSource, /completeCandidate:/);
});
// 原值(365a623fc 之前):test("unconfirmed terminal candidates preserve the range without offering direct adoption")
// 核对 birth-time-candidate-result / birth-time-choice-question / birth-time-rectification /
// birth-time-legacy-rectification 四个文件的源码(候选未达采用条件的文案、childOwnsError 分工、ErrorToast 只一处)
// 新值:整条删除
// 原因:这四个旧校正组件已经没有页面入口,随 #111 一起删除(按「多余入口宁可删除」)。
// 现行校正在对话里,候选不直接采用由 rectification-agentic 的测试守住;本文件其余条目继续守引导 hook。
test("terminal CJK copy stays intact while homepage candidates remain unconfirmed in v3", () => {
const pageSource = homeSurface;
@@ -127,6 +127,11 @@ test("the type sheet closes when a cell is chosen or Escape is pressed", async (
} finally {
await h.close();
}
// 原值(365a623fc):删掉了 doesNotMatch(source, /<dialog|<button|ToggleGroup|role="tablist"/)
// 新值:补回这条源码守卫
// 原因:上面的点按测试证明浮层能关,但拦不住有人退回手写原生按钮或对话框;两条守的是不同的事(第 2 轮验收第 4 条)
const source = readFileSync(new URL("../src/components/chart-page/chart-type-picker.tsx", import.meta.url), "utf8");
assert.doesNotMatch(source, /<dialog|<button|ToggleGroup|role="tablist"/);
});
test("the varga layer asks the engine for D40, D45 and D60 and the page draws them", async () => {
@@ -62,7 +62,10 @@ test("the chat page loads the natal split only when the panel opens, and phones
assert.match(frame, /收起本命盘/);
assert.match(frame, /if \(!ready \|\| isMobile\) return null/);
assert.match(frame, /import\("@\/components\/chat-chart-split"\)/);
assert.match(split, /ResizablePanelGroup/);
assert.match(split, /collapsible/);
// 原值:split 里要有 ResizablePanelGroup 和 collapsible
// 新值:split 用 useDragWidth,拖到最小以下调 onOpenChange(false) 收起
// 原因:组里只剩右栏一个面板时把手拖不动(BUG-1456);聊天列不能进组(BUG-1448),所以改成不依赖分组的把手
assert.match(split, /useDragWidth\(/);
assert.match(split, /onCollapse: \(\) => onOpenChange\(false\)/);
assert.doesNotMatch(split, /data-onboarding-draw/);
});
@@ -61,8 +61,10 @@ const knownUnstyled = new Set([
// 新值:删掉。chart-page-dasha-panel、chat-chart-resize-handle 加入。
// 原因:启程进度条已经给 li.is-done 上色。下面这几个类名落在自己会画样式的
// 折叠内容、分栏、侧栏导航和手机侧栏上,类名只是钩子。
// 原值:chat-chart-resize-handle 在白名单里(落在组件库分栏把手上,由组件自己画)
// 新值:删掉这一项
// 原因:对话右栏改成自己的拖宽把手(BUG-1456),样式表里有了这个类的规则
"chart-page-dasha-panel",
"chat-chart-resize-handle",
"sidebar-nav-item",
"sidebar-sheet",
"is-live",
@@ -0,0 +1,136 @@
/**
* BUG-1456 / BUG-1457: the chat chart edge and the sidebar edge drag, and take
* the arrow keys, through one hook. Drag below the minimum closes the panel.
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { ChatChartSplit } from "../src/components/chat-chart-split.tsx";
import { dragWidthFromKey, dragWidthFromPointer, useDragWidth, type DragWidthEdge } from "../src/hooks/use-drag-width.ts";
import { CHAT_CHART_WIDTH_MAX, CHAT_CHART_WIDTH_MIN } from "../src/lib/chat-chart-panel-state.ts";
type Handlers = ReturnType<typeof useDragWidth>;
function captureHandlers(props: Parameters<typeof useDragWidth>[0]): Handlers {
let captured: Handlers | null = null;
function Probe() {
captured = useDragWidth(props);
return null;
}
renderToStaticMarkup(<Probe />);
assert.ok(captured);
return captured;
}
function fakeWindow() {
const buckets = new Map<string, Set<(event: { clientX: number }) => void>>();
const original = (globalThis as { window?: unknown }).window;
(globalThis as { window?: unknown }).window = {
addEventListener(type: string, listener: (event: { clientX: number }) => void) {
const set = buckets.get(type) ?? new Set();
set.add(listener);
buckets.set(type, set);
},
removeEventListener(type: string, listener: (event: { clientX: number }) => void) {
buckets.get(type)?.delete(listener);
},
};
return {
fire(type: string, clientX = 0) {
for (const listener of [...(buckets.get(type) ?? [])]) listener({ clientX });
},
count(type: string) {
return buckets.get(type)?.size ?? 0;
},
restore() {
(globalThis as { window?: unknown }).window = original;
},
};
}
function pointerDown(clientX: number) {
return { button: 0, clientX, preventDefault() {} } as unknown as Parameters<Handlers["onPointerDown"]>[0];
}
function keyDown(key: string) {
let prevented = false;
const event = { key, preventDefault() { prevented = true; } } as unknown as Parameters<Handlers["onKeyDown"]>[0];
return { event, prevented: () => prevented };
}
test("pointer and key widths follow the moving edge", () => {
const cases: Array<[DragWidthEdge, number, number]> = [["right", 40, 340], ["left", 40, 260]];
for (const [edge, delta, expected] of cases) assert.equal(dragWidthFromPointer(300, delta, edge), expected);
const range = { min: 280, max: 560 };
assert.equal(dragWidthFromKey(360, "ArrowLeft", range, "left"), 376);
assert.equal(dragWidthFromKey(360, "ArrowRight", range, "left"), 344);
assert.equal(dragWidthFromKey(360, "ArrowRight", range, "right"), 376);
assert.equal(dragWidthFromKey(284, "ArrowRight", range, "left"), 280, "keys clamp instead of collapsing");
assert.equal(dragWidthFromKey(360, "End", range, "left"), 560);
assert.equal(dragWidthFromKey(360, "Enter", range, "left"), null);
});
test("dragging the chat chart edge reports widths and closes below the minimum", () => {
const win = fakeWindow();
const widths: number[] = [];
let collapsed = 0;
try {
const handlers = captureHandlers({
width: 360,
min: CHAT_CHART_WIDTH_MIN,
max: CHAT_CHART_WIDTH_MAX,
edge: "left",
onWidth: (width) => widths.push(width),
onCollapse: () => { collapsed += 1; },
});
handlers.onPointerDown(pointerDown(1000));
win.fire("pointermove", 960);
win.fire("pointermove", 700);
win.fire("pointermove", 1100);
win.fire("pointerup");
assert.deepEqual(widths, [400, CHAT_CHART_WIDTH_MAX]);
assert.equal(collapsed, 1);
assert.equal(win.count("pointermove"), 0, "listeners removed after the drag");
} finally {
win.restore();
}
});
test("arrow keys move the edge and are announced as a separator", () => {
const widths: number[] = [];
const handlers = captureHandlers({
width: 360,
min: CHAT_CHART_WIDTH_MIN,
max: CHAT_CHART_WIDTH_MAX,
edge: "left",
onWidth: (width) => widths.push(width),
onCollapse: () => {},
});
const left = keyDown("ArrowLeft");
handlers.onKeyDown(left.event);
assert.equal(left.prevented(), true);
const other = keyDown("a");
handlers.onKeyDown(other.event);
assert.equal(other.prevented(), false);
assert.deepEqual(widths, [376]);
assert.equal(handlers.role, "separator");
assert.equal(handlers["aria-valuenow"], 360);
});
test("the open chat chart renders a focusable separator on its edge", () => {
const html = renderToStaticMarkup(
<ChatChartSplit open={true} width={360} subjectId="self" onOpenChange={() => {}} onWidth={() => {}} />,
);
assert.match(html, /<button[^>]*class="[^"]*chat-chart-resize-handle/);
assert.match(html, /role="separator"/);
assert.match(html, /aria-valuenow="360"/);
assert.match(html, /aria-label="调整本命盘宽度"/);
});
test("the sidebar edge uses the same hook, so it takes the arrow keys too", () => {
const source = readFileSync(new URL("../src/components/shell-frame.tsx", import.meta.url), "utf8");
assert.match(source, /useDragWidth\(\{/);
assert.match(source, /\{\.\.\.resize\}/);
});
+21 -1
View File
@@ -1,13 +1,32 @@
import "./install-test-document";
import assert from "node:assert/strict";
import test from "node:test";
import { createRequire, register } from "node:module";
import { pathToFileURL } from "node:url";
import React from "react";
import { EmailOtpLogin } from "../src/components/email-otp-login.tsx";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
Object.assign(globalThis, { React });
// Test doubles only; the login page itself is unchanged (第 2 轮验收第 5 条).
// 1. Next hands `next/image` to the page as the image component. Under the test
// runner the page gets the package object instead, so a loader hook gives
// the page a module whose default is the component.
// 2. better-auth keeps the fetch it finds when the identity client loads, so the
// page module is imported only after the fetch stub below is in place.
const imagePackageUrl = pathToFileURL(createRequire(import.meta.url).resolve("next/image")).href;
const imageShim = `import pkg from ${JSON.stringify(imagePackageUrl)};
export default pkg.default ?? pkg;
export const getImageProps = pkg.getImageProps;`;
const imageHooks = `export async function resolve(specifier, context, next) {
if (specifier === "next/image" && context.parentURL && context.parentURL.includes("/src/components/")) {
return { url: ${JSON.stringify(`data:text/javascript,${encodeURIComponent(imageShim)}`)}, shortCircuit: true };
}
return next(specifier, context);
}`;
register(`data:text/javascript,${encodeURIComponent(imageHooks)}`);
test("login types an email, sends a code, then types that code", async () => {
// 原值:K5 验收要的这条交互没有测试,登录页只核对源码里的文案
// 新值:输入邮箱、勾选同意、发送验证码,再把六位码打进格子,然后验证
@@ -26,6 +45,7 @@ test("login types an email, sends a code, then types that code", async () => {
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
const { EmailOtpLogin } = await import("../src/components/email-otp-login.tsx");
Object.assign(window, {
location: {
href: "https://staging.jyotisha.chat/login",
@@ -281,18 +281,19 @@ test("checklist and hint wiring stay inside the existing shell", () => {
const journey = read("../src/lib/onboarding-journey.tsx");
const home = page.slice(page.indexOf("export default function Home("));
assert.match(page, /<SidebarTrigger placement="inset" \/>/);
assert.match(page, /<OnboardingChecklistEntry \/>/);
// 原值:首页静态引入启程清单。
// 新值:清单、提示层、动效宿主都是页面出来后再载入,且不给占位。
// 原因:首屏 gzip-9 比基线 0f66d92d4 大 11.05%,任务书要求这三块不进首屏。
assert.match(page, /import\("@\/components\/onboarding-checklist"\)[\s\S]{0,200}ssr: false/);
assert.doesNotMatch(page, /import \{ OnboardingChecklistEntry \}/);
// 原值:首页 `<OnboardingChecklistEntry />`,并在 page.tsx 里 dynamic 引入 onboarding-checklist(ssr: false,不给占位)
// 新值:首页 `<OnboardingChecklistSlot />`;dynamic 引入移到 onboarding-checklist-slot.tsx,载入时先显示清单的外观
// 原因:不给占位时清单在揭幕后才冒出来(第 2 轮验收第 6 条,BUG-1458)。浮层仍不进首屏
assert.match(page, /<OnboardingChecklistSlot \/>/);
const slot = read("../src/components/onboarding-checklist-slot.tsx");
assert.match(slot, /import\("@\/components\/onboarding-checklist"\)[\s\S]{0,200}ssr: false, loading:/);
assert.doesNotMatch(page, /import \{ OnboardingChecklistEntry \}|@\/components\/onboarding-checklist"/);
assert.match(layout, /import\("@\/components\/onboarding-hint-layer"\)[\s\S]{0,200}ssr: false/);
assert.match(layout, /import\("@\/components\/onboarding-motion-host"\)[\s\S]{0,200}ssr: false/);
assert.doesNotMatch(layout, /import \{ OnboardingHintLayer \}/);
assert.doesNotMatch(layout, /import \{ OnboardingMotionHost \}/);
const header = page.slice(page.indexOf('<header className="chat-header">'), page.indexOf("composer-wrap"));
assert.match(header, /<OnboardingChecklistEntry \/>/);
assert.match(header, /<OnboardingChecklistSlot \/>/);
assert.equal(header.includes("composer-wrap"), false);
assert.match(layout, /list\.account\?\.compatibility === true/);
assert.doesNotMatch(layout, /\/api\/product-access/);
@@ -0,0 +1,92 @@
/**
* BUG-1458: the checklist face shows with the page instead of popping in after
* reveal. While the account's answer is being read, the face last seen in this
* browser holds the spot; once the answer says no checklist, it goes away.
*/
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { OnboardingChecklistSlot, readChecklistFace } from "../src/components/onboarding-checklist-slot.tsx";
import { OnboardingJourneyContext, type OnboardingJourney } from "../src/lib/onboarding-journey.tsx";
Object.assign(globalThis, { React });
const USER = "11111111-1111-4111-8111-111111111111";
function memoryStorage() {
const values = new Map<string, string>();
return {
values,
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => { values.set(key, value); },
removeItem: (key: string) => { values.delete(key); },
};
}
function journey(patch: Partial<OnboardingJourney>): OnboardingJourney {
return {
showChecklist: false,
checklistPending: false,
userId: USER,
items: [],
label: "",
celebration: false,
activeHint: null,
hintText: "",
showChartDot: false,
tabHasDot: () => false,
dismissChecklist: () => {},
dismissHint: async () => true,
go: () => {},
setChartTab: () => {},
markChartVisited: () => {},
setChecklistOpen: () => {},
setLive: () => {},
registerHome: () => {},
hintWatch: false,
motionEnabled: false,
...patch,
};
}
function render(value: OnboardingJourney) {
return renderToStaticMarkup(
<OnboardingJourneyContext.Provider value={value}>
<OnboardingChecklistSlot />
</OnboardingJourneyContext.Provider>,
);
}
test("while the answer is read, the last face holds the spot", () => {
const storage = memoryStorage();
const original = (globalThis as { localStorage?: unknown }).localStorage;
(globalThis as { localStorage?: unknown }).localStorage = storage;
try {
assert.equal(render(journey({ checklistPending: true })), "", "nothing held for a browser that never saw the checklist");
storage.setItem(`onboarding_checklist_face:${USER}`, JSON.stringify({ label: "启程 2/5", done: 2, total: 5 }));
const held = render(journey({ checklistPending: true }));
assert.match(held, /onboarding-checklist-trigger/);
assert.match(held, /启程 2\/5/);
assert.match(held, /disabled/);
assert.equal(render(journey({ checklistPending: false })), "", "an answer of no checklist shows nothing");
assert.deepEqual(readChecklistFace(USER), { label: "启程 2/5", done: 2, total: 5 });
assert.equal(readChecklistFace(""), null);
} finally {
(globalThis as { localStorage?: unknown }).localStorage = original;
}
});
test("a shown checklist renders its face while the panel code loads", () => {
const html = render(journey({
showChecklist: true,
label: "启程 1/2",
items: [
{ id: "ask", label: "问一个问题", done: true },
{ id: "chart", label: "看看星盘", done: false },
] as unknown as OnboardingJourney["items"],
}));
assert.match(html, /onboarding-checklist-trigger/);
assert.match(html, /启程 1\/2/);
});
@@ -29,6 +29,8 @@ function facts(patch: Partial<HintFacts> = {}): HintFacts {
function journey(hintWatch: boolean): OnboardingJourney {
return {
showChecklist: false,
checklistPending: false,
userId: "",
items: [],
label: "",
celebration: false,
@@ -12,6 +12,8 @@ import { createClientLifecycleHarness } from "./react-client-lifecycle-test-supp
function closedJourney(): OnboardingJourney {
return {
showChecklist: false,
checklistPending: false,
userId: "",
items: [],
label: "",
celebration: false,
@@ -273,6 +273,11 @@ test("mounted drawer closes from Escape and the overlay, and restores focus", as
fireListeners(elementListeners.bag(overlay), "click", click, false);
});
assert.equal(closes, 2);
// 原值(365a623fc):删掉了点「关闭导出」关一次的断言
// 新值:补回,点「关闭导出」再关一次
// 原因:按钮还在页面上,是用户最常用的关法;只测 Esc 和遮罩会漏掉它(第 2 轮验收第 4 条)
await h.event(allNodes(h).find(node => node.getAttribute("aria-label") === "关闭导出")!);
assert.equal(closes, 3);
await h.render(null); assert.equal(document.activeElement, previous);
} finally { elementListeners.restore(); await h.close(); }
});
@@ -0,0 +1,123 @@
/**
* BUG-1459: the 「只有月亮」 starter question and the 「那一刻,{名字}的上升在……」
* sky line read the already-computed chart once (product decision 2026-10-27).
* The ascendant is used only when it cannot change inside the reported time.
* Golden engine response, fictional profile.
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { assembleChartView } from "../src/lib/chart-view-load.ts";
import type { ChartViewOk } from "../src/lib/chart-view-contract.ts";
import { CHART_VIEW_LAYERS } from "../src/lib/chart-view-engine.ts";
import { bindCurrentSubjectAccount, resetSubjectCatalogForTests, setCurrentSubject } from "../src/lib/current-subject.ts";
import { resetSecondaryPageDataForTests } from "../src/lib/secondary-page-data.ts";
import { loadStarterSignsFromChart, resetStarterChartSignsForTests, starterSignsFromChartView } from "../src/lib/starter-chart-signs.ts";
import { otherSkySubjectLine, readStarterSigns, resetStarterSignsForTests, starterQuestions } from "../src/lib/starter-questions.ts";
const golden = JSON.parse(
readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"),
) as { chart: Record<string, unknown>; varga_full: Record<string, unknown>; chara: Record<string, unknown> };
const profile = {
name: "示例",
date: "1990-06-15",
time: "12:00",
placeLabel: "北京",
latitude: 39.9042,
longitude: 116.4074,
timezoneOffset: 8,
timezoneId: "Asia/Shanghai",
ayanamsa: "raman",
birthTimeStatus: "confirmed",
};
async function goldenView(): Promise<ChartViewOk> {
const result = await assembleChartView({
userId: "user-1",
profile,
asOf: "2026-09-15",
layers: CHART_VIEW_LAYERS,
postEngine: async (path) => {
if (path === "/api/chart") return { status: "ok", payload: golden.chart };
if (path === "/api/varga_full") return { status: "ok", payload: golden.varga_full };
if (path === "/api/dasha/chara") return { status: "ok", payload: golden.chara };
return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 };
},
});
assert.equal(result.body.status, "ok");
return result.body as ChartViewOk;
}
const FIXED = { state: "fixed" as const, signs: ["x"], windowMinutes: 31, precisionKind: "minutes" as const };
const MAY_CHANGE = { state: "may_change" as const, signs: ["x", "y"], changeAt: ["12:01"], windowMinutes: 31, precisionKind: "minutes" as const };
test("signs come from the computed chart; the ascendant only when it is fixed", async () => {
const view = await goldenView();
const moon = view.vedic.planets.find((row) => row.name === "Moon")?.sign;
const ascendant = view.vedic.ascendant?.sign;
assert.ok(moon && ascendant);
assert.deepEqual(starterSignsFromChartView({ ...view, lagnaPrecheck: FIXED }, "self"), { ascendantSign: ascendant, moonSign: moon });
assert.deepEqual(starterSignsFromChartView({ ...view, lagnaPrecheck: MAY_CHANGE }, "self"), { ascendantSign: null, moonSign: moon });
assert.deepEqual(starterSignsFromChartView({ ...view, lagnaPrecheck: undefined }, "self"), { ascendantSign: null, moonSign: moon });
assert.equal(starterSignsFromChartView({ ...view, subjectId: "person-1" }, "self"), null, "another person's chart is never used");
assert.equal(starterSignsFromChartView({ status: "chart_unavailable", billed: false, message: "x" } as never, "self"), null);
const moonOnly = starterQuestions({ kind: "self" }, { ascendantSign: null, moonSign: moon })[0];
assert.match(moonOnly, /^月亮在.+,我是个什么样的人$/);
assert.match(otherSkySubjectLine("小舟", ascendant), /^那一刻,小舟的上升在.+$/);
});
test("one read through the chart page request; a failure keeps today's wording", async () => {
const view = await goldenView();
const originalFetch = globalThis.fetch;
const urls: string[] = [];
resetSubjectCatalogForTests();
resetSecondaryPageDataForTests();
resetStarterSignsForTests();
resetStarterChartSignsForTests();
bindCurrentSubjectAccount("11111111-1111-4111-8111-111111111111", ["person-1"]);
setCurrentSubject("person-1");
try {
globalThis.fetch = (async (input: unknown) => {
urls.push(String(input));
return new Response(JSON.stringify({ ...view, accountId: "user-1", profileFingerprint: "fp-1", subjectId: "person-1", lagnaPrecheck: FIXED }), { status: 200 });
}) as typeof fetch;
const signs = await loadStarterSignsFromChart("person-1");
assert.ok(signs?.ascendantSign && signs.moonSign);
assert.deepEqual(readStarterSigns("person-1"), signs);
assert.equal(urls.length, 1);
assert.match(urls[0], /^\/api\/chart-view\?subject=person-1$/);
await loadStarterSignsFromChart("person-1");
assert.equal(urls.length, 1, "the cached chart is reused");
resetSecondaryPageDataForTests();
resetStarterSignsForTests();
resetStarterChartSignsForTests();
globalThis.fetch = (async (input: unknown) => {
urls.push(String(input));
return new Response("{}", { status: 500 });
}) as typeof fetch;
const before = urls.length;
assert.equal(await loadStarterSignsFromChart("person-1"), null);
assert.deepEqual(readStarterSigns("person-1"), { ascendantSign: null, moonSign: null });
assert.equal(await loadStarterSignsFromChart("person-1"), null);
assert.equal(urls.length, before + 1, "a failed read is not retried in a loop");
} finally {
globalThis.fetch = originalFetch;
resetSubjectCatalogForTests();
resetSecondaryPageDataForTests();
resetStarterSignsForTests();
resetStarterChartSignsForTests();
}
});
test("the starter questions and the sky reveal ask for the chart signs, loaded on demand", () => {
const questions = readFileSync(new URL("../src/components/starter-questions.tsx", import.meta.url), "utf8");
const reveal = readFileSync(new URL("../src/hooks/use-birth-sky-reveal.ts", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
assert.match(questions, /import\("@\/lib\/starter-chart-signs"\)/);
assert.match(reveal, /if \(id !== "self"\) \{\s*void import\("@\/lib\/starter-chart-signs"\)/);
assert.doesNotMatch(page, /starter-chart-signs|secondary-page-data/);
});
@@ -0,0 +1,92 @@
/**
* BUG-1455: after the page has revealed with a people list, a reload must not
* drop the list (that put the whole chat back on 「正在载入账户」), and a failed
* reload keeps the old list. Synthetic account and person only.
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
currentSubjectCatalogReady,
loadSubjectCatalog,
peekSubjectCatalog,
refreshSubjectCatalog,
resetSubjectCatalogForTests,
subscribeSubjectCatalog,
} from "../src/lib/current-subject.ts";
import { emptyProfile, type Account } from "../src/lib/home-types.ts";
const account: Account = {
user: { id: "11111111-1111-4111-8111-111111111111", email: null },
profile: { ...emptyProfile, name: "Synthetic" },
avatar: null, credits: 10, isAdmin: false, adminUrl: null,
rectificationPriceCredits: 0, activeSubscription: null,
hasConfirmedBirthTime: false, hasUsableBirthTime: false,
};
const person = {
id: "person-1",
role: "other",
profile: { ...emptyProfile, name: "Synthetic Friend" },
updated_at: "2026-10-27T00:00:00.000Z",
};
function stubFetch(libraryResponses: Array<() => Response>) {
const original = globalThis.fetch;
let libraryCalls = 0;
globalThis.fetch = (async (input: unknown) => {
const url = String(input);
if (url === "/api/account") return new Response(JSON.stringify(account), { status: 200 });
if (url === "/api/chart-profiles") {
const next = libraryResponses[Math.min(libraryCalls, libraryResponses.length - 1)];
libraryCalls += 1;
return next();
}
return new Response("{}", { status: 404 });
}) as typeof fetch;
return () => { globalThis.fetch = original; };
}
const ok = () => new Response(JSON.stringify({ profiles: [person] }), { status: 200 });
const failed = () => new Response("{}", { status: 500 });
test("a refresh after reveal keeps the list ready while it reloads", async () => {
resetSubjectCatalogForTests();
const restore = stubFetch([ok]);
try {
await loadSubjectCatalog();
assert.equal(currentSubjectCatalogReady(), true);
const readiness: boolean[] = [];
const off = subscribeSubjectCatalog(() => readiness.push(currentSubjectCatalogReady()));
const pending = refreshSubjectCatalog();
assert.equal(currentSubjectCatalogReady(), true, "the page must not fall back to the loading screen");
assert.ok(peekSubjectCatalog()?.some((row) => row.id === "person-1"));
await pending;
off();
assert.ok(readiness.every(Boolean), `ready never drops: ${readiness.join(",")}`);
} finally {
restore();
resetSubjectCatalogForTests();
}
});
test("a failed refresh keeps the full list instead of a degraded one", async () => {
resetSubjectCatalogForTests();
const restore = stubFetch([ok, failed]);
try {
await loadSubjectCatalog();
await assert.rejects(refreshSubjectCatalog());
assert.equal(currentSubjectCatalogReady(), true);
assert.ok(peekSubjectCatalog()?.some((row) => row.id === "person-1"));
} finally {
restore();
resetSubjectCatalogForTests();
}
});
test("onboarding reloads the list without invalidating it", () => {
const source = readFileSync(new URL("../src/hooks/use-profile-onboarding.ts", import.meta.url), "utf8");
assert.doesNotMatch(source, /invalidateSubjectCatalog/);
assert.match(source, /refreshSubjectCatalog\(\)/);
});