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:
co-authored by
Claude Opus 5.5
parent
856dab6c55
commit
873fac8a5b
@@ -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"\]/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user