import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { applyAdoptedBirthDateGuard, chartBirthAfterAccountWrite, CHART_BIRTH_FAILURE_REASONS, resolveServerOwnedChartBirth, } from "../src/lib/chart-birth-truth.ts"; import { resolveAccountBirthTimeApplicationPatch } from "../src/lib/account-profile-patch.ts"; import { BirthProfileTimezoneError } from "../src/lib/birth-profile-timezone.ts"; import { assembleChartView, type ChartViewEnginePost } from "../src/lib/chart-view-load.ts"; import { chartViewMessageSchema, chartViewOkSchema } from "../src/lib/chart-view-contract.ts"; import { chartViewEngineCacheKey } from "../src/lib/chart-view-engine.ts"; import { chartViewFailureResponse, chartViewFailureStatus, logChartViewFailure, } from "../src/lib/chart-view-failure.ts"; import { CHART_VIEW_COPY } from "../src/lib/chart-view-labels.ts"; import { prepareChartViewProfile } from "../src/lib/chart-view-profile-read.ts"; import { fetchChartView } from "../src/lib/chart-view-client.ts"; import { beginChartCacheAccountRead, accountChartPinStillCurrent, peekChartPage, pinChartSnapshotIdentity, refreshChartPage, resetSecondaryPageDataForTests, writeChartPage, } from "../src/lib/secondary-page-data.ts"; import type { ChartViewResponse } from "../src/lib/chart-view-contract.ts"; const golden = JSON.parse( readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"), ) as { chart: Record }; const crossMidnight = { name: "Synthetic", birth_date: "2000-06-15", reported_birth_time: "00:10", active_birth_date: "2000-06-14", active_birth_time: "23:55", active_birth_timezone_offset: -5, active_birth_provenance: { contract: "dated-v1", candidate_id: "synthetic-candidate" }, birth_time_status: "accepted", birth_time_source: "family_exact", birth_time_period: null, declared_window_start: null, declared_window_end: null, birth_time_clue: null, uncertainty_before_minutes: 15, uncertainty_after_minutes: 15, latitude: 40, longitude: -74, timezone_id: "America/New_York", timezone_offset: -4, ayanamsa: "raman", birth_place_label: "Synthetic place", rectification_case_id: null, birth_time: null, }; function identityResolver(row: unknown): Promise { return Promise.resolve(row); } test("account and chart-view share server-owned date, time, offset, and provenance", async () => { const reported = { ...crossMidnight, birth_time_status: "reported" }; const accepted = crossMidnight; const confirmed = { ...crossMidnight, birth_time_status: "confirmed" }; for (const row of [reported, accepted, confirmed]) { const fromAccount = resolveServerOwnedChartBirth(row); const prepared = await prepareChartViewProfile({ row, resolveTimezone: identityResolver }); assert.equal(prepared.ok, fromAccount.chartable); assert.equal(prepared.birth.date, fromAccount.date); assert.equal(prepared.birth.time, fromAccount.time); assert.equal(prepared.birth.timezoneOffset, fromAccount.timezoneOffset); assert.equal(prepared.birth.fingerprint, fromAccount.fingerprint); assert.deepEqual(prepared.birth.provenance, fromAccount.provenance); assert.equal(prepared.birth.failure, fromAccount.failure); if (prepared.ok) { assert.equal(prepared.profile.date, fromAccount.date); assert.equal(prepared.profile.time, fromAccount.time); assert.equal(prepared.profile.timezoneOffset, fromAccount.timezoneOffset); } } const reportedBirth = resolveServerOwnedChartBirth(reported); assert.equal(reportedBirth.date, "2000-06-15"); assert.equal(reportedBirth.time, "00:10"); assert.equal(reportedBirth.timezoneOffset, -4); assert.equal(reportedBirth.adoption, "declared"); const adoptedBirth = resolveServerOwnedChartBirth(accepted); assert.equal(adoptedBirth.date, "2000-06-14"); assert.equal(adoptedBirth.time, "23:55"); assert.equal(adoptedBirth.timezoneOffset, -5); assert.equal(adoptedBirth.adoption, "adopted"); assert.equal(adoptedBirth.failure, null); }); test("confirmed ordinary edits keep the active minute and clear a complete adopted tuple together", () => { const confirmed = { ...crossMidnight, birth_time_status: "confirmed" as const }; const patch = { birth_date: "2000-06-16", reported_birth_time: "00:20", birth_time_source: "approximate" as const, birth_time_period: null, birth_time_clue: null, uncertainty_before_minutes: 30, uncertainty_after_minutes: 30, }; assert.deepEqual(resolveAccountBirthTimeApplicationPatch(confirmed, patch), {}); const stored = applyAdoptedBirthDateGuard(confirmed, { ...confirmed, ...patch }); assert.equal(stored.active_birth_time, "23:55"); assert.equal(stored.active_birth_date, null); assert.equal(stored.active_birth_timezone_offset, null); assert.equal(stored.active_birth_provenance, null); const birth = resolveServerOwnedChartBirth(stored); assert.equal(birth.status, "confirmed"); assert.equal(birth.time, "23:55"); assert.equal(birth.date, "2000-06-16"); assert.equal(birth.timezoneOffset, -4); assert.equal(birth.adoption, "legacy"); assert.equal(birth.failure, null); const echoed = chartBirthAfterAccountWrite({ current: confirmed, written: { ...confirmed, ...patch }, returned: stored, }); assert.equal(echoed.fingerprint, birth.fingerprint); assert.equal(echoed.date, birth.date); assert.equal(echoed.time, birth.time); assert.equal(echoed.timezoneOffset, birth.timezoneOffset); }); test("an accepted declaration edit does not stay chartable with a partial adopted tuple", () => { const patch = { birth_date: "2000-06-15", reported_birth_time: "01:10", birth_time_source: "approximate" as const, birth_time_period: null, birth_time_clue: null, uncertainty_before_minutes: 30, uncertainty_after_minutes: 30, }; const application = resolveAccountBirthTimeApplicationPatch(crossMidnight, patch); assert.deepEqual(application, { active_birth_time: null, birth_time_status: "reported", rectification_case_id: null, }); const current = { ...crossMidnight } as Record; const stored = applyAdoptedBirthDateGuard(current, { ...current, ...patch, ...application }); assert.equal(stored.active_birth_time, null); assert.equal(stored.active_birth_date, null); assert.equal(stored.active_birth_timezone_offset, null); const birth = resolveServerOwnedChartBirth(stored); assert.equal(birth.status, "reported"); assert.equal(birth.date, "2000-06-15"); assert.equal(birth.time, "01:10"); assert.equal(birth.timezoneOffset, -4); assert.notEqual(birth.adoption, "incomplete"); assert.equal(birth.failure, null); }); test("a missing adopted offset is not charted and is not called a profile query or incomplete profile", async () => { let resolverCalls = 0; const partial = { ...crossMidnight, active_birth_timezone_offset: null }; const birth = resolveServerOwnedChartBirth(partial); assert.equal(birth.failure, "adopted_calculation_incomplete"); assert.equal(birth.chartable, false); assert.equal(birth.date, null); assert.equal(birth.timezoneOffset, null); assert.equal(birth.activeTime, "23:55"); const prepared = await prepareChartViewProfile({ row: partial, resolveTimezone: async () => { resolverCalls += 1; throw new Error("must not resolve an incomplete adopted tuple"); }, }); assert.equal(prepared.ok, false); if (prepared.ok) return; assert.equal(prepared.failure, "adopted_calculation_incomplete"); assert.equal(resolverCalls, 0); assert.equal(chartViewFailureStatus("adopted_calculation_incomplete"), "chart_unavailable"); assert.notEqual(chartViewFailureResponse("adopted_calculation_incomplete").status, "birth_profile_incomplete"); }); test("chart-view classifies query, timezone, engine, and schema failures separately", async () => { const query = await prepareChartViewProfile({ row: crossMidnight, queryError: { message: "connection reset" }, resolveTimezone: async () => { throw new Error("must not read a failed profile query"); }, }); assert.equal(query.ok, false); if (!query.ok) { assert.equal(query.failure, "profile_query_error"); assert.notEqual(query.failure, "profile_incomplete"); assert.equal(chartViewFailureResponse("profile_query_error").status, "chart_unavailable"); assert.match(chartViewFailureResponse("profile_query_error").message, /读不出来/); } const missingOffset = { ...crossMidnight, birth_time_status: "reported", active_birth_date: null, active_birth_timezone_offset: null, timezone_offset: null, }; const timezone = await prepareChartViewProfile({ row: missingOffset, resolveTimezone: async () => { throw new BirthProfileTimezoneError(); }, }); assert.equal(timezone.ok, false); if (!timezone.ok) assert.equal(timezone.failure, "timezone_resolver_failure"); const messages = [ "profile_query_error", "profile_incomplete", "adopted_calculation_incomplete", "timezone_resolver_failure", "response_schema_failure", ].map((reason) => chartViewFailureResponse(reason as typeof CHART_BIRTH_FAILURE_REASONS[number]).message); assert.equal(new Set(messages).size, messages.length); for (const reason of CHART_BIRTH_FAILURE_REASONS) { const body = chartViewMessageSchema.parse(chartViewFailureResponse(reason)); assert.equal(body.reason, reason); assert.doesNotMatch(body.message, /没有可显示内容|过一会儿再打开/); } assert.equal(chartViewFailureResponse("engine_busy").message, CHART_VIEW_COPY.busy); assert.equal(chartViewFailureResponse("engine_timeout").message, CHART_VIEW_COPY.unavailable); assert.equal(chartViewFailureResponse("engine_bad_payload").message, CHART_VIEW_COPY.unavailable); const warnings: unknown[] = []; const original = console.warn; console.warn = (...args: unknown[]) => { warnings.push(args); }; try { logChartViewFailure({ reason: "profile_query_error", elapsedMs: 4, httpStatus: null }); } finally { console.warn = original; } const line = JSON.stringify(warnings); assert.match(line, /profile_query_error/); assert.match(line, /\/api\/chart-view/); assert.doesNotMatch(line, /2000-06-15|23:55|Synthetic|-74/); }); test("cross-midnight chart-view posts the adopted date, minute, and offset and still returns D1", async () => { const prepared = await prepareChartViewProfile({ row: crossMidnight, resolveTimezone: identityResolver }); assert.equal(prepared.ok, true); if (!prepared.ok) return; const bodies: Array<{ path: string; body: Record }> = []; const postEngine: ChartViewEnginePost = async (path, body) => { bodies.push({ path, body }); if (path === "/api/chart") return { status: "ok", payload: golden.chart }; return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 }; }; const result = await assembleChartView({ userId: "synthetic-user", accountId: "synthetic-user", profileFingerprint: prepared.birth.fingerprint, profile: prepared.profile, postEngine, asOf: "2026-09-22", }); const view = chartViewOkSchema.parse(result.body); assert.equal(view.profile.date, "2000-06-14"); assert.equal(view.profile.time.slice(0, 5), "23:55"); assert.equal(view.profile.timezoneOffset, -5); assert.equal(view.profileFingerprint, prepared.birth.fingerprint); assert.equal(view.accountId, "synthetic-user"); const d1 = view.vedic.vargas.find((item) => item.id === "D1"); assert.ok(d1); assert.equal(d1?.chart.houses.length, 12); assert.ok(view.vedic.planets.length >= 9); const natal = bodies.find((item) => item.path === "/api/chart"); assert.equal(natal?.body.day, 14); assert.equal(natal?.body.hour, 23); assert.equal(natal?.body.minute, 55); assert.equal(natal?.body.tz, -5); assert.deepEqual(bodies.map((item) => item.path), ["/api/chart"]); }); test("engine cache keys still include date, time, offset, ayanamsa, and node mode", () => { const base = { userId: "synthetic-user", date: "2000-06-14", time: "23:55", latitude: 40, longitude: -74, timezoneOffset: -5, ayanamsa: "raman", nodeMode: "mean", path: "/api/chart", }; assert.notEqual(chartViewEngineCacheKey(base), chartViewEngineCacheKey({ ...base, date: "2000-06-15" })); assert.notEqual(chartViewEngineCacheKey(base), chartViewEngineCacheKey({ ...base, time: "23:56" })); assert.notEqual(chartViewEngineCacheKey(base), chartViewEngineCacheKey({ ...base, timezoneOffset: -4 })); assert.notEqual(chartViewEngineCacheKey(base), chartViewEngineCacheKey({ ...base, ayanamsa: "lahiri" })); assert.notEqual(chartViewEngineCacheKey(base), chartViewEngineCacheKey({ ...base, nodeMode: "true" })); }); test("a saved profile pin drops the previous chart snapshot and ignores an in-flight old response", async () => { resetSecondaryPageDataForTests(); const oldView = { status: "ok", billed: false, accountId: "acct-a", profileFingerprint: "fingerprint-old", profile: { date: "1990-01-01", time: "01:00", timezoneOffset: 8 }, } as ChartViewResponse; writeChartPage({ kind: "view", view: oldView }); assert.equal(peekChartPage()?.kind, "view"); pinChartSnapshotIdentity({ accountId: "acct-a", fingerprint: "fingerprint-new" }); assert.equal(peekChartPage(), null); pinChartSnapshotIdentity({ accountId: "acct-b", fingerprint: "fingerprint-new" }); writeChartPage({ kind: "view", view: { ...oldView, accountId: "acct-a", profileFingerprint: "fingerprint-new" }, }); const foreign = peekChartPage(); assert.notEqual(foreign?.kind === "view" && foreign.view.status === "ok", true); if (foreign?.kind === "view") { assert.equal(foreign.view.accountId, "acct-b"); assert.doesNotMatch(JSON.stringify(foreign.view), /1990-01-01/); } resetSecondaryPageDataForTests(); let releaseFirst: (response: Response) => void = () => {}; let markStarted: () => void = () => {}; const started = new Promise((resolve) => { markStarted = resolve; }); let calls = 0; const originalFetch = globalThis.fetch; globalThis.fetch = (async (input: RequestInfo | URL) => { const url = String(input); if (!url.includes("/api/chart-view")) throw new Error(`unexpected fetch ${url}`); calls += 1; if (calls === 1) { markStarted(); return new Promise((resolve) => { releaseFirst = resolve; }); } return new Response(JSON.stringify({ status: "chart_unavailable", billed: false, message: "新资料还不能排。", reason: "profile_incomplete", accountId: "acct-a", profileFingerprint: "fingerprint-new", }), { status: 200, headers: { "content-type": "application/json" } }); }) as typeof fetch; try { const pending = refreshChartPage(); await started; pinChartSnapshotIdentity({ accountId: "acct-a", fingerprint: "fingerprint-new" }); releaseFirst(new Response(JSON.stringify(oldView), { status: 200, headers: { "content-type": "application/json" }, })); const snapshot = await pending; assert.equal(calls, 2); assert.equal(snapshot.kind, "view"); if (snapshot.kind !== "view") return; assert.equal(snapshot.view.profileFingerprint, "fingerprint-new"); assert.notEqual(snapshot.view.profileFingerprint, "fingerprint-old"); const visible = peekChartPage(); assert.equal(visible?.kind, "view"); if (visible?.kind === "view") { assert.equal(visible.view.profileFingerprint, "fingerprint-new"); assert.doesNotMatch(JSON.stringify(visible.view), /1990-01-01/); } } finally { globalThis.fetch = originalFetch; resetSecondaryPageDataForTests(); } }); test("an account read that overlaps a profile save does not pin the stale fingerprint", () => { resetSecondaryPageDataForTests(); const started = beginChartCacheAccountRead(); pinChartSnapshotIdentity({ accountId: "acct-a", fingerprint: "fingerprint-new" }); assert.equal(accountChartPinStillCurrent(started), false); const reread = beginChartCacheAccountRead(); assert.equal(accountChartPinStillCurrent(reread), true); resetSecondaryPageDataForTests(); }); test("chart-view schema failures stay on the page as a named format error", async () => { resetSecondaryPageDataForTests(); const originalFetch = globalThis.fetch; globalThis.fetch = (async () => new Response("not-json", { status: 200, headers: { "content-type": "application/json" }, })) as typeof fetch; try { const result = await fetchChartView({}); const body = result.body; if (!body || body.status === "ok") { assert.fail("schema failure should stay a chart-view message"); } assert.equal(body.reason, "response_schema_failure"); assert.match(body.message, /格式对不上/); assert.doesNotMatch(body.message, /没有可显示内容|过一会儿再打开/); } finally { globalThis.fetch = originalFetch; resetSecondaryPageDataForTests(); } }); test("the adopted-date trigger clears the whole tuple and chart-view does not read chart_profiles", () => { const migration = readFileSync( new URL("../supabase/migrations/20260920020000_adopted_birth_date.sql", import.meta.url), "utf8", ); const service = readFileSync(new URL("../src/lib/chart-view-service.ts", import.meta.url), "utf8"); const account = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8"); const onboarding = readFileSync(new URL("../src/hooks/use-profile-onboarding.ts", import.meta.url), "utf8"); const guard = migration.slice( migration.indexOf("function public.guard_adopted_birth_date"), migration.indexOf("drop trigger if exists zz_guard_adopted_birth_date"), ); assert.match(guard, /new\.active_birth_date := null/); assert.match(guard, /new\.active_birth_timezone_offset := null/); assert.match(guard, /new\.active_birth_provenance := null/); assert.doesNotMatch(guard, /new\.active_birth_time := null/); assert.match(service, /const \{ data: row, error \}/); assert.match(service, /profile_query_error/); assert.doesNotMatch(service, /chart_profiles/); assert.match(account, /chartBirth: resolveServerOwnedChartBirth\(profile\)/); assert.match(account, /chartBirthAfterAccountWrite/); assert.match(onboarding, /pinChartSnapshotIdentity/); assert.doesNotMatch( readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"), /pinChartSnapshotIdentity|chartBirth/, ); });