Files
Jyotisha/frontend/tests/onboarding-facts-refresh.test.tsx
T
jesse-uxandGrok (agent loop) 4d63580708 补上类型检查和 lint 没过的几处。
测试里的类型标注补全。侧栏拖宽把手改用组件库按钮,样子仍是 6px 透明细条。报告列表换人时不再在渲染中改引用。删掉已经没人用的证据回执类名。

Co-Authored-By: Grok (agent loop)
2026-10-27 01:58:07 +08:00

244 lines
10 KiB
TypeScript

import "./install-test-document";
import assert from "node:assert/strict";
import test, { describe } from "node:test";
import { AppRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime";
import { SidebarProvider } from "../src/components/ui/sidebar.tsx";
import type { DataClient } from "../src/lib/db/data-client.ts";
import { emptyProfile, type Account, type Profile } from "../src/lib/home-types.ts";
import { readOnboardingReportPresence } from "../src/lib/onboarding-report-presence.ts";
import { loadOnboardingRemote, OnboardingJourneyProvider, useOnboardingJourney } from "../src/lib/onboarding-journey.tsx";
import { notifyOnboardingFactsChanged } from "../src/lib/onboarding-signals.ts";
import { invalidateAccountFlight, resetSubjectCatalogForTests } from "../src/lib/current-subject.ts";
import { SessionListProvider } from "../src/lib/session-list-context.tsx";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
const testAppRouter = {
back() {},
forward() {},
prefetch() {},
push() {},
refresh() {},
replace() {},
bfcacheId: "0",
};
const ACCOUNT_ID = "11111111-1111-4111-8111-111111111448";
const OTHER_ID = "22222222-2222-4222-8222-222222222448";
const DISMISSED_AT = "2026-10-01T00:00:00.000Z";
function completeProfile(name: string): Profile {
// `readProfile` keeps the snake_case birth fields the account route stores.
return {
...emptyProfile,
name,
date: "1990-01-02",
birthTimeSource: "unknown",
birth_time_source: "unknown",
birthPlaceLabel: "测试地点",
birth_place_label: "测试地点",
latitude: 31.2,
longitude: 121.5,
timezoneId: "Asia/Shanghai",
timezone_id: "Asia/Shanghai",
} as Profile;
}
const account: Account = {
user: { id: ACCOUNT_ID, email: null },
profile: completeProfile("Synthetic"),
avatar: null,
credits: 1,
isAdmin: false,
adminUrl: null,
rectificationPriceCredits: 0,
activeSubscription: null,
hasConfirmedBirthTime: false,
hasUsableBirthTime: false,
};
function requestUrl(input: RequestInfo | URL): string {
if (typeof input === "string") return input;
if (input instanceof URL) return input.toString();
return input.url;
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
test("presence stops at one id row for a personal report and for synastry", async () => {
const calls: string[] = [];
const client = {
from(table: string) {
return {
select(columns: string) {
const steps = [`${table} select ${columns}`];
const chain = {
eq(column: string, value: unknown) {
steps.push(`eq ${column}=${String(value)}`);
return chain;
},
neq(column: string, value: unknown) {
steps.push(`neq ${column}=${String(value)}`);
return chain;
},
is(column: string, value: unknown) {
steps.push(`is ${column}=${String(value)}`);
return chain;
},
limit(count: number) {
calls.push(`${steps.join(" ")} limit ${count}`);
const personal = steps.some((step) => step.startsWith("neq "));
const legacy = table === "synastry_reports";
return Promise.resolve({ data: personal || legacy ? [{ id: "row" }] : [], error: null });
},
};
return chain;
},
};
},
};
const presence = await readOnboardingReportPresence(client as unknown as DataClient, ACCOUNT_ID);
assert.equal(calls.length, 3);
assert.equal(calls.every((call) => call.includes("select id") && call.endsWith("limit 1")), true);
assert.equal(calls.some((call) => call.includes("report,")), false);
assert.deepEqual(presence, { personalReady: true, synastryReady: true });
});
test("a closed checklist does not ask for people, prices, or reports", async () => {
const urls: string[] = [];
const original = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = requestUrl(input);
urls.push(url);
assert.match(url, /\/api\/onboarding-state$/);
return jsonResponse({ state: { checklistDismissedAt: DISMISSED_AT } });
}) as typeof fetch;
try {
const snapshot = await loadOnboardingRemote(ACCOUNT_ID);
assert.equal(snapshot.status, "ready");
assert.equal(snapshot.state.checklistDismissedAt, DISMISSED_AT);
assert.deepEqual(urls, ["/api/onboarding-state"]);
} finally {
globalThis.fetch = original;
}
});
test("an open checklist asks once whether any report or synastry exists", async () => {
const urls: string[] = [];
const original = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = requestUrl(input);
urls.push(url);
if (url.endsWith("/api/onboarding-state")) return jsonResponse({ state: {} });
if (url.endsWith("/api/chart-profiles")) return jsonResponse({ profiles: [] });
if (url.endsWith("/api/product-access")) {
return jsonResponse({ compatibility: true, reportCenter: true, synastryCreditCost: 19, reportCreditCost: 29 });
}
if (url === "/api/reports?presence=1") return jsonResponse({ personalReady: true, synastryReady: false });
return jsonResponse({ error: "unexpected" }, 500);
}) as typeof fetch;
try {
const snapshot = await loadOnboardingRemote(ACCOUNT_ID);
assert.equal(snapshot.personalCount, 1);
assert.equal(snapshot.synCount, 0);
assert.deepEqual(urls.filter((url) => url.includes("/api/reports")), ["/api/reports?presence=1"]);
assert.equal(urls.some((url) => url.includes("/api/synastry-reports")), false);
assert.equal(urls.some((url) => url.includes("subject=")), false);
assert.equal(urls.some((url) => url.includes("before=")), false);
} finally {
globalThis.fetch = original;
}
});
function PersonProbe() {
const person = useOnboardingJourney().items.find((item) => item.id === "person");
return <span data-person={person ? String(person.done) : "missing"} />;
}
describe("checklist refresh inside the shell", { concurrency: 1 }, () => {
test("adding a person checks that row without a page reload", async () => {
resetSubjectCatalogForTests();
invalidateAccountFlight();
const urls: string[] = [];
let profiles: Array<{ id: string; profile: Profile; created_at: string }> = [];
const originalFetch = globalThis.fetch;
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
const storage = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
removeItem: (key: string) => { storage.delete(key); },
clear: () => storage.clear(),
key: (index: number) => Array.from(storage.keys())[index] ?? null,
get length() { return storage.size; },
} satisfies Storage,
});
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = requestUrl(input);
urls.push(url);
const path = url.split("?")[0] ?? url;
if (path.endsWith("/api/account")) return jsonResponse(account);
if (path.endsWith("/api/chart-profiles")) return jsonResponse({ profiles });
if (path.endsWith("/api/sessions")) return jsonResponse({ sessions: [], nextCursor: null });
if (path.endsWith("/api/models")) return jsonResponse({ defaultModelId: "test", models: [] });
if (path.endsWith("/api/onboarding-state")) return jsonResponse({ state: {} });
if (path.endsWith("/api/product-access")) {
return jsonResponse({ compatibility: true, reportCenter: true, synastryCreditCost: 19, reportCreditCost: 29 });
}
if (url.includes("/api/reports?presence=1")) return jsonResponse({ personalReady: false, synastryReady: false });
return jsonResponse({});
}) as typeof fetch;
const harness = createClientLifecycleHarness();
Object.assign(harness.document.defaultView ?? {}, {
location: { pathname: "/", origin: "https://example.test", href: "https://example.test/" },
dispatchEvent: () => true,
});
const readPerson = () => harness.elements().find((node) => node.hasAttribute("data-person"))?.getAttribute("data-person") ?? null;
try {
await harness.render(
<SessionListProvider>
<SidebarProvider>
<AppRouterContext.Provider value={testAppRouter}>
<OnboardingJourneyProvider>
<PersonProbe />
</OnboardingJourneyProvider>
</AppRouterContext.Provider>
</SidebarProvider>
</SessionListProvider>,
);
let seen = readPerson();
for (let i = 0; seen !== "false" && i < 40; i += 1) {
await harness.idle();
seen = readPerson();
}
assert.equal(seen, "false", harness.errors.map(String).join("\n"));
assert.equal(urls.filter((url) => url.includes("/api/reports")).length, 1);
assert.match(urls.find((url) => url.includes("/api/reports")) ?? "", /presence=1/);
profiles = [{ id: OTHER_ID, profile: completeProfile("小周"), created_at: "2026-10-02T00:00:00.000Z" }];
await harness.update(() => notifyOnboardingFactsChanged("person"));
for (let i = 0; seen !== "true" && i < 40; i += 1) {
await harness.idle();
seen = readPerson();
}
assert.equal(seen, "true", harness.errors.map(String).join("\n"));
assert.equal(urls.filter((url) => url.includes("/api/reports?presence=1")).length, 2);
assert.equal(urls.some((url) => url.includes("/api/reports") && url.includes("subject=")), false);
} finally {
await harness.close();
globalThis.fetch = originalFetch;
invalidateAccountFlight();
resetSubjectCatalogForTests();
if (originalStorage) Object.defineProperty(globalThis, "localStorage", originalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
}
});
});