Files
Jyotisha/frontend/tests/chart-profile-update-consistency.test.ts
T

466 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
// 原值(测试名与断言):confirmed ordinary edits keep the active minute…;application = {},排盘仍 confirmed 23:55(legacy)
// 新值:confirmed declaration edits drop the minute…;application 回到 reported 并显式清空跨午夜三列,排盘按新声明 00:20(declared)
// 原因:产品 2026-09-29 推翻 BUG-264 约定——改了出生日期 / 时间 / 地点就按新资料排盘(TASK-mobile-chart-and-confirmed-edit M2)
test("confirmed declaration edits drop the 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,
};
const application = resolveAccountBirthTimeApplicationPatch(confirmed, patch);
assert.deepEqual(application, {
active_birth_time: null,
birth_time_status: "reported",
rectification_case_id: null,
active_birth_date: null,
active_birth_timezone_offset: null,
active_birth_provenance: null,
});
const current = { ...confirmed } 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);
assert.equal(stored.active_birth_provenance, null);
const birth = resolveServerOwnedChartBirth(stored);
assert.equal(birth.status, "reported");
assert.equal(birth.time, "00:20");
assert.equal(birth.date, "2000-06-16");
assert.equal(birth.timezoneOffset, -4);
assert.equal(birth.adoption, "declared");
assert.equal(birth.failure, null);
const echoed = chartBirthAfterAccountWrite({
current: confirmed,
written: { ...confirmed, ...patch, ...application },
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",
subjectId: "self",
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" }));
assert.notEqual(chartViewEngineCacheKey(base), chartViewEngineCacheKey({ ...base, subjectId: "other-1" }));
});
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/);
// 原值: chart-view selected profiles inline and never mentioned chart_profiles.
// 新值: chart-view calls loadSubjectBirth, which reads profiles for self and chart_profiles for someone else.
// 原因: D5/D13. Household truth stays on profiles; other people are typed chart_profiles rows.
assert.match(service, /loadSubjectBirth/);
assert.match(service, /profile_query_error/);
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/,
);
});