450 lines
18 KiB
TypeScript
450 lines
18 KiB
TypeScript
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<string, unknown> };
|
|
|
|
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<unknown> {
|
|
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<string, unknown>;
|
|
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<string, unknown> }> = [];
|
|
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<void>((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<Response>((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/,
|
|
);
|
|
});
|