feat(settings,reports): six ayanamsa options; report-scope solid button colours (T6/T7, BUG-1078)

T6: offer True Citra and Fagan-Bradley beside Raman/Lahiri/KP/True Pushya,
engine-supported values only; widen profiles_ayanamsa_check by an additive
migration; ephemeris note names; path tests so no new value falls back to
Raman.

T7: the unlayered `button { color: inherit; }` beats Tailwind's
text-primary-foreground, so solid report buttons showed page ink (light
(light 4.85:1, dark 5.77:1), grey disabled without fade, neutral selected
preset. button.tsx and theme tokens unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-09-28 15:54:53 +08:00
co-authored by Claude Opus 5.5
parent 856dab6c55
commit 873fac8a5b
15 changed files with 483 additions and 8 deletions
@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { accountProfilePatchSchema } from "../src/lib/account-profile-patch.ts";
import { AYANAMSA_VALUES } from "../src/lib/ayanamsa.ts";
import { assembleChartView } from "../src/lib/chart-view-load.ts";
import { libraryProfileFromSubjectRow, parseChartSubjectWrite } from "../src/lib/chart-subject-write.ts";
import { ephemerisAyanamsaNote } from "../src/lib/ephemeris-view.ts";
import { globalBirthProfileFromAccountRow } from "../src/lib/server-owned-birth-profile.ts";
// TASK-chart-types-and-report-buttons-20260928 T6. `resolveAyanamsa` falls
// back to Raman for anything it does not know, so each new value is walked
// through the paths that store it or send it to the engine. Fictional data only.
const NEW_VALUES = ["true_citra", "fagan_bradley"] as const;
test("the account PATCH accepts the six values and still rejects the ones not offered", () => {
for (const value of AYANAMSA_VALUES) {
assert.equal(accountProfilePatchSchema.safeParse({ ayanamsa: value }).success, true, value);
}
for (const value of ["djwhal_khul", "sassanian", "fagan", "yukteshwar"]) {
assert.equal(accountProfilePatchSchema.safeParse({ ayanamsa: value }).success, false, value);
}
});
test("the profiles CHECK is widened to exactly the six values by an additive migration", () => {
const sql = readFileSync(
new URL("../supabase/migrations/20260928010000_profile_ayanamsa_six.sql", import.meta.url),
"utf8",
);
assert.match(sql, /drop constraint if exists profiles_ayanamsa_check/);
assert.match(
sql,
/check \(ayanamsa in \('raman', 'lahiri', 'kp', 'true_citra', 'true_pushya', 'fagan_bradley'\)\)/,
);
assert.doesNotMatch(sql, /djwhal_khul|sassanian/);
assert.doesNotMatch(sql, /drop column|alter column|rename|set not null|update public\.|set default/i);
});
test("the account row keeps a new ayanamsa instead of turning it into Raman", () => {
for (const value of NEW_VALUES) {
const profile = globalBirthProfileFromAccountRow({
name: "虚构甲",
birth_date: "1990-01-01",
reported_birth_time: "08:00",
latitude: 22.3,
longitude: 114.1,
timezone_offset: 8,
ayanamsa: value,
});
assert.equal(profile.ayanamsa, value);
}
});
test("a saved person keeps a new ayanamsa on write and on read", () => {
for (const value of NEW_VALUES) {
const parsed = parseChartSubjectWrite({ name: "虚构乙", date: "1990-01-01", reportedTime: "08:00", ayanamsa: value });
assert.equal(parsed.ok, true);
if (!parsed.ok) continue;
assert.equal(parsed.write.columns.ayanamsa, value);
assert.equal(libraryProfileFromSubjectRow({ id: "p-1", name: "虚构乙", ayanamsa: value }).ayanamsa, value);
}
});
test("the chart page sends the new ayanamsa to the engine unchanged", async () => {
for (const value of NEW_VALUES) {
const bodies: Record<string, unknown>[] = [];
await assembleChartView({
userId: "user-1",
profile: {
date: "1990-01-01",
time: "08:00",
latitude: 22.3,
longitude: 114.1,
timezoneOffset: 8,
ayanamsa: value,
},
postEngine: async (path, body) => {
bodies.push(body);
return { status: "http_error", path, elapsedMs: 0, httpStatus: 500 };
},
asOf: "2026-09-28",
});
assert.equal(bodies[0]?.ayanamsa, value);
}
});
test("the ephemeris note names the new ayanamsa rather than calling it unknown", () => {
assert.equal(ephemerisAyanamsaNote("true_citra"), "五要素按 Lahiri 岁差算,和星盘页的 True Citra 不同。");
assert.equal(ephemerisAyanamsaNote("fagan_bradley"), "五要素按 Lahiri 岁差算,和星盘页的 Fagan-Bradley 不同。");
});
test("the report generator already has a display name for both new values", () => {
const source = readFileSync(new URL("../src/lib/personal-report-generation.ts", import.meta.url), "utf8");
assert.match(source, /\["true_citra", "True Citra"\]/);
assert.match(source, /\["fagan_bradley", "Fagan-Bradley"\]/);
});
+29 -2
View File
@@ -9,9 +9,9 @@ import {
resolveAyanamsa,
} from "../src/lib/ayanamsa.ts";
test("resolveAyanamsa defaults to Raman and accepts the four product values", () => {
test("resolveAyanamsa defaults to Raman and accepts the six product values", () => {
assert.equal(DEFAULT_AYANAMSA, "raman");
assert.deepEqual([...AYANAMSA_VALUES], ["raman", "lahiri", "kp", "true_pushya"]);
assert.deepEqual([...AYANAMSA_VALUES], ["raman", "lahiri", "kp", "true_citra", "true_pushya", "fagan_bradley"]);
assert.equal(resolveAyanamsa(), "raman");
assert.equal(resolveAyanamsa(null), "raman");
assert.equal(resolveAyanamsa({}), "raman");
@@ -20,8 +20,35 @@ test("resolveAyanamsa defaults to Raman and accepts the four product values", ()
assert.equal(resolveAyanamsa({ ayanamsa: "krishnamurti" }), "kp");
assert.equal(resolveAyanamsa({ ayanamsa: "true-pushya" }), "true_pushya");
assert.equal(resolveAyanamsa({ ayanamsa: "fagan" }), "raman");
assert.equal(resolveAyanamsa({ ayanamsa: "djwhal_khul" }), "raman");
assert.equal(isAyanamsaName("raman"), true);
assert.equal(isAyanamsaName("sidereal"), false);
assert.match(AYANAMSA_SWITCH_HINT, /咨询、报告、星盘库和每日星语/);
assert.match(AYANAMSA_SWITCH_HINT, /生时校正目前仍固定按 Raman/);
});
// TASK-chart-types-and-report-buttons-20260928 T6: every offered value must
// come back unchanged — `resolveAyanamsa` silently falls back to Raman for an
// unknown value, so a value missing from AYANAMSA_VALUES would quietly turn a
// user's True Citra chart into a Raman one on every path.
test("each of the six ayanamsa values resolves to itself, never to the Raman fallback", () => {
for (const value of ["raman", "lahiri", "kp", "true_citra", "true_pushya", "fagan_bradley"]) {
assert.equal(resolveAyanamsa({ ayanamsa: value }), value);
assert.equal(resolveAyanamsa({ ayanamsa: value.toUpperCase().replace(/_/g, "-") }), value);
}
// Same alias the engine accepts (`scripts/ayanamsa_utils.py`).
assert.equal(resolveAyanamsa({ ayanamsa: "true_chitra" }), "true_citra");
assert.equal(resolveAyanamsa({ ayanamsa: "True-Chitra" }), "true_citra");
});
test("the settings list is exactly the six values in product order, only engine-supported ones", async () => {
const { AYANAMSA_OPTIONS } = await import("../src/lib/ayanamsa.ts");
assert.deepEqual(AYANAMSA_OPTIONS.map((option) => option.value), [...AYANAMSA_VALUES]);
assert.deepEqual(AYANAMSA_OPTIONS.map((option) => option.label), [
"Raman(默认)", "Lahiri", "KP", "True Citra", "True Pushya", "Fagan-Bradley",
]);
const { readFileSync } = await import("node:fs");
const engine = readFileSync(new URL("../../scripts/ayanamsa_utils.py", import.meta.url), "utf8");
for (const value of AYANAMSA_VALUES) assert.match(engine, new RegExp(`'${value}': getattr\\(swe`));
for (const option of AYANAMSA_OPTIONS) assert.ok(option.description.length > 0);
});
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { startPostgresFixture, type PostgresFixture } from "./helpers/postgres-fixture.ts";
// TASK-chart-types-and-report-buttons-20260928 T6. Fictional users only.
// Runs in the gate's DB job (needs Docker).
const runner = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url));
const docker = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { stdio: "ignore" }).status === 0;
const SERVICE = ["service_runtime", "service-runtime-test-password"] as const;
function applyMigrations(fixture: PostgresFixture): void {
const result = spawnSync(process.execPath, [runner], {
encoding: "utf8",
env: { ...process.env, SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password") },
});
assert.equal(result.status, 0, result.stderr || result.stdout);
}
test("profiles.ayanamsa accepts the six offered values and rejects the rest", {
skip: docker ? false : "docker unavailable",
}, () => {
const fixture = startPostgresFixture();
try {
applyMigrations(fixture);
const owner = randomUUID();
fixture.psql(`insert into identity.users (id, name, email, email_verified, email_verified_at)
values ('${owner}', 'Fictional ayanamsa owner', '${owner}@example.invalid', true, now());`);
assert.equal(fixture.psql(`select ayanamsa from public.profiles where id = '${owner}'`), "raman");
// The account PATCH writes through service_role.
for (const value of ["raman", "lahiri", "kp", "true_citra", "true_pushya", "fagan_bradley"]) {
fixture.psqlAs(...SERVICE, `set role service_role; update public.profiles set ayanamsa = '${value}' where id = '${owner}';`);
assert.equal(fixture.psql(`select ayanamsa from public.profiles where id = '${owner}'`), value);
}
for (const value of ["djwhal_khul", "sassanian", "fagan"]) {
assert.throws(
() => fixture.psql(`update public.profiles set ayanamsa = '${value}' where id = '${owner}'`),
/profiles_ayanamsa_check/,
);
}
} finally {
fixture.stop();
}
});
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
// BUG-1078 (TASK-chart-types-and-report-buttons-20260928 T7). The report
// surfaces get their own button colours; the shared button and the theme
// tokens must stay exactly as other pages use them.
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const buttonSource = readFileSync(new URL("../src/components/ui/button.tsx", import.meta.url), "utf8");
const SCOPE = ":is(.report-center-message, .report-center-body, .personal-report-state, .personal-report-reader)";
function blockTokens(opener: string): Map<string, string> {
const start = css.indexOf(opener);
assert.notEqual(start, -1, `missing block: ${opener}`);
const body = css.slice(start, css.indexOf("\n}", start));
return new Map([...body.matchAll(/--([\w-]+)\s*:\s*([^;]+);/g)].map((hit) => [hit[1], hit[2].trim()]));
}
function luminance(hex: string): number {
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrast(a: string, b: string): number {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
}
test("the shared button keeps its variants, disabled fade and data-slot hook", () => {
assert.match(buttonSource, /default: "bg-primary text-primary-foreground hover:bg-primary\/80"/);
assert.match(buttonSource, /disabled:pointer-events-none disabled:opacity-50/);
assert.match(buttonSource, /data-slot="button"/);
});
test("the theme tokens the fix reads are unchanged", () => {
const light = blockTokens(":root {\n color-scheme: light;");
const preferred = blockTokens(':root:not([data-theme="light"]) {');
const pinned = blockTokens(':root[data-theme="dark"] {');
assert.equal(light.get("color-action"), "#a9583e");
assert.equal(light.get("color-on-dark"), "#fbfaf7");
assert.equal(light.get("color-action-on-dark"), "#d78064");
for (const dark of [preferred, pinned]) {
assert.equal(dark.get("color-action"), "#d78064");
assert.equal(dark.get("color-on-dark"), "#241f1c");
assert.equal(dark.get("color-action-on-dark"), "#8f4a33");
assert.equal(dark.get("color-ink"), "#f2f0ea");
}
});
test("button overrides exist only inside the report scope", () => {
const rules = [...css.matchAll(/([^{}]*\[data-slot="button"\][^{}]*)\{/g)].map((hit) => hit[1].trim());
assert.ok(rules.length >= 3, "solid, hover and disabled rules are present");
assert.ok(rules.some((selector) => selector.endsWith('[data-slot="button"][class~="bg-primary"]')), "solid rule matches the default variant");
// Comments stripped, as class-name-definition-contract does.
assert.doesNotMatch(css.replace(/\/\*[\s\S]*?\*\//g, " "), /\.bg-primary\b/, "the sheet must not define the Tailwind utility itself");
for (const selector of rules) assert.ok(selector.startsWith(SCOPE), `unscoped button rule: ${selector}`);
assert.match(css, new RegExp(`${SCOPE.replace(/[().]/g, "\\$&")} \\[data-slot="button"\\]:disabled \\{\\n opacity: 1;`));
// Both dark paths (OS preference and pinned) switch the fill.
assert.equal(css.split(`:root:not([data-theme="light"]) ${SCOPE}`).length - 1, 1);
assert.equal(css.split(`:root[data-theme="dark"] ${SCOPE}`).length - 1, 1);
});
test("solid report buttons read at least 4.5:1 in both themes, up from inherited ink", () => {
// Before: the global unlayered `button { color: inherit; }` beat
// `text-primary-foreground`, so the label was the page ink.
assert.ok(contrast("#1d1d1f", "#a9583e") < 4.5, "light before: near-black on clay");
assert.ok(contrast("#f2f0ea", "#d78064") < 4.5, "dark before: ink on lifted coral");
assert.ok(contrast("#fbfaf7", "#a9583e") >= 4.5, "light after");
assert.ok(contrast("#f2f0ea", "#8f4a33") >= 4.5, "dark after");
// The override must set `color` itself, or the global inherit wins again.
const solid = css.match(/\[data-slot="button"\]\[class~="bg-primary"\] \{([^}]*)\}/);
assert.ok(solid);
assert.match(solid[1], /color: var\(--report-button-ink\);/);
assert.match(css, /\nbutton \{ color: inherit; \}/, "the global rule this works around is unchanged");
});
test("the selected export preset no longer uses the dark red-brown action-soft fill", () => {
const rule = css.match(/\.report-export-presets \[aria-pressed="true"\] \{([^}]*)\}/);
assert.ok(rule);
assert.doesNotMatch(rule[1], /--color-action-soft|--color-action\b/);
assert.match(rule[1], /--color-ink/);
});