#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:
co-authored by
Claude Opus 5.5
parent
75cf7ad0e0
commit
de9d4826c1
@@ -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\}/);
|
||||
});
|
||||
@@ -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\(\)/);
|
||||
});
|
||||
Reference in New Issue
Block a user