测试里的类型标注补全。侧栏拖宽把手改用组件库按钮,样子仍是 6px 透明细条。报告列表换人时不再在渲染中改引用。删掉已经没人用的证据回执类名。 Co-Authored-By: Grok (agent loop)
244 lines
10 KiB
TypeScript
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");
|
|
}
|
|
});
|
|
});
|