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
+4
-3
@@ -304,8 +304,8 @@ Both chat surfaces pin a new turn at its head instead of following the last stre
|
||||
|
||||
The self-profile editor in 星盘资料 (`ChartLibraryPanel`) owns the ayanamsa radios. Personal 个人资料 stays account basics only.
|
||||
|
||||
- **Structure:** four 44px stacked radios reused from `.theme-preference-option`, plus a caption under the group. Raman is marked 默认. Each option has a one-line description; selected rows show the same `Check` as the theme panel.
|
||||
- **Values:** `raman` (default) / `lahiri` / `kp` / `true_pushya`. Copy lives in `frontend/src/lib/ayanamsa.ts`.
|
||||
- **Structure:** six 44px stacked radios reused from `.theme-preference-option`, plus a caption under the group. Raman is marked 默认. Each option has a one-line description; selected rows show the same `Check` as the theme panel.
|
||||
- **Values (2026-09-28):** `raman` (default) / `lahiri` / `kp` / `true_citra` / `true_pushya` / `fagan_bradley`, shown as Raman(默认)/ Lahiri / KP / True Citra / True Pushya / Fagan-Bradley. Only values the engine already supports (`scripts/ayanamsa_utils.py`); Djwhal Khul and Sassanian are supported by the engine but deliberately not offered. The label is spelled True Citra, matching the engine's `ayanamsa_display` that the chart page parameter table shows. Copy lives in `frontend/src/lib/ayanamsa.ts`; `profiles_ayanamsa_check` lists the same six.
|
||||
- **Hint:** “之后的咨询、报告、星盘库和每日星语会按新岁差算。生时校正目前仍固定按 Raman,之后的版本才会跟着这项设置走。”
|
||||
- **Placement:** after `ProfileFields` on the self-profile form only. Not on other-chart save, not on `page.tsx`.
|
||||
- **States:** default, hover, selected (`aria-checked` only), keyboard focus. Switching does not clear a confirmed birth minute.
|
||||
@@ -543,6 +543,7 @@ one skeleton, none of them carrying the sidebar.
|
||||
|
||||
### Personal report reader
|
||||
|
||||
- **Buttons on the report surfaces (2026-09-28, BUG-1078):** the list, the reader with its export dialog, and the loading / state / error / not-found screens restyle the shared `Button` locally; `components/ui/button.tsx` and the theme tokens are untouched, so other pages keep the shared primary. Cause: the unlayered `button { color: inherit; }` beats Tailwind's layered `text-primary-foreground`, so a solid `<button>` shows the page ink — #1d1d1f on #a9583e in light (3.33:1, the reported black-on-dark-red) and #f2f0ea on #d78064 in dark (2.57:1); `<Button render={<Link/>}>` renders an `<a>` and was never affected. Solid (`variant="default"`) now sets its own label colour: light #fbfaf7 on `--color-action` #a9583e (4.85:1); dark `--color-ink` #f2f0ea on `--color-action-on-dark` #8f4a33 (5.77:1), hover mixes 6% ink in (5.1:1). Disabled, any variant: `--color-canvas-strong` fill + `--color-ink-tertiary`, opacity 1 instead of the global 45% fade. The selected export preset is `--color-canvas-muted` + `--color-ink` + a `--color-ink-secondary` edge, not the `--color-action-soft` fill (a red-brown block in dark mode).
|
||||
- **分块导出对话框 (2026-09-24):** native modal `<dialog>`, bottom-aligned below 860px and centered at ≥860px, app colours only. Desktop is viewport-centered (`inset: 0; margin: auto`) regardless of sidebar state, width `min(720px, 92vw)`, max-height 80vh, with full `--radius-lg` corners. Its flex panel is capped at `calc(80vh - 2px)` to reserve the dialog border; desktop rules follow the narrow-screen dvh upgrade so 70dvh cannot override them. Below 860px the content-column offset, bottom alignment and at most 70vh/70dvh remain unchanged. Header and single export footer stay visible; the block list scrolls, two columns at the existing 860px breakpoint. Escape, backdrop or 关闭 dismisses; focus returns without scrolling. All controls have 44px targets. No spinner, clipboard or extra fetch.
|
||||
- **Export selection:** default 常用 selects every body block from the single outline's lossless source slices (lead = 摘要); 全部分盘 and 仅核对表 replace the selection. Manual toggles clear preset highlighting. 全选 / 取消全选 works across all groups; zero selected disables export. Counts are actual Unicode code points in serialized block text, including SVG markup, not approximate prose counts.
|
||||
- **Export content:** full body alone remains byte-identical to the retired ordinary Markdown export. Selected chart/table blocks append new content; selecting all groups is not byte-identical to body-only. D13 requires safe SVG in each chart, reusing the projected engine SVG or the existing hook-free Vedic drawing; offline fallback uses readable monochrome glyphs/legend and presentation attributes. No chart JSON fences or internal audit fields. Fact-table labels/precision/subtables match the reader. Full body uses the historical filename; excerpt adds `-节选`. The existing downloader appends `.md`.
|
||||
@@ -1104,7 +1105,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
|
||||
- **先中文、后梵文。** 主行中文,副行 12px 三级色写引擎原名(`lang="sa-Latn"`)。中文名只来自 `src/lib/panchanga-names-zh.ts`,该表逐字抄自任务书附表,不自行翻译、不增删;匹配忽略大小写、空格、连字符,别名只有 Garija/Gara、Dwitiya/Dvitiya、Shashthi/Shashti。表里查不到的名字原样显示梵文、不出副行,并在控制台记一次 `panchanga_name_zh_missing`。星期接口里本来就是中文,不出副行。接口字段不变:`factor.value` 仍是引擎名,中文在视图层投影。§15 行星表「没有中文宿名表就不造一个」仍只管星盘页;星历这张表是产品在任务书里给定的。
|
||||
- **吉 / 中 / 凶是行右侧的彩色小标签**(`.ephemeris-quality`):吉 `--color-success`、凶 `--color-danger`、中 `--color-warning`,底是同色 `color-mix(… 12%, transparent)`。只照搬引擎标签,不写成建议。
|
||||
- **卡片顶部当日总评 + 计数。** 一句 `{月相短语},{整体}{Vishti 追加}。`,按固定规则拼(`panchangaDaySummary`,纯函数,不调模型):Purnima「满月之日」、Amavasya「新月之日」、其余白分「月亮渐盈」、黑分「月亮渐亏」;吉 ≥ 4「整体吉」、3「整体偏吉」、2「吉凶参半」、≤ 1「整体偏凶」,中不计入;迦那为 Vishti 时追加「;迦那落在毗湿提,重要的事避开」。下面两枚计数标签「N 吉」「M 凶」。五要素或其中任一吉凶缺项时,总评和计数都不出,只显示已有行。
|
||||
- 卡片下固定一行岁差说明,按资料实际岁差填名:「五要素按 Lahiri 岁差算,和星盘页的 {Raman / KP / True Pushya} 不同。」资料就是 Lahiri 时写「和星盘页相同」,读不到资料岁差时只写前半句。行运段不加这句。
|
||||
- 卡片下固定一行岁差说明,按资料实际岁差填名:「五要素按 Lahiri 岁差算,和星盘页的 {Raman / KP / True Citra / True Pushya / Fagan-Bradley} 不同。」资料就是 Lahiri 时写「和星盘页相同」,读不到资料岁差时只写前半句。行运段不加这句。
|
||||
- **当日行运(2026-09-26):** 每行是一个 `li`:左 1.75rem 彩色行星符号(复用 `--color-planet-*`,与星盘页行星表同色、同字体栈)+「{行星} 在 {星座}」+ 副行「落你的第 N 宫」(仅 `natalRelativeAvailable` 为真时)与逆行时的「逆行」+ 右侧等宽度数(度°分′,`tabular-nums`)。桌面与手机同一行式,不再有表头和 `data-label` 堆叠。没有本命上升时副行不写宫位,段首写「还没有本命上升,先不写落在你的第几宫。」
|
||||
- **未来九十天:** 按日期升序只写事实(「某星 进入 某座」「某星 停滞转顺 · 度数」)。`/api/ephemeris_events` 未上线时整段静态说明:「换座和停滞还没接上。这一页其余部分不受影响。」不得让整页失败。
|
||||
- **出口(2026-09-26 挪位):** 「带这天去提问」不在顶栏了。页面最底部一张浅色提示卡 `.ephemeris-ask-card`(`--color-action-soft` 底、`--radius-lg`、宽度与段落卡一致):左侧「想问这一天适不适合做某件事?」,右侧主按钮「带这天去提问」(44px),行为不变——把当前日期预填进对话草稿并回到 `/`。窄屏时问句自己折行,按钮不变宽。不写「这一页是天象本身,不是对你的判断。」
|
||||
|
||||
@@ -106,7 +106,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
- 五要素先写中文,梵文小字在下;中文名只用 `panchanga-names-zh.ts` 那张表(任务书给定),不自己翻,查不到就照写梵文。
|
||||
- 当日总评只有一种句式:`{月相短语},{整体}{Vishti 追加}。` 月相短语是「满月之日 / 新月之日 / 月亮渐盈 / 月亮渐亏」;整体是「整体吉 / 整体偏吉 / 吉凶参半 / 整体偏凶」;迦那是 Vishti 时追加「;迦那落在毗湿提,重要的事避开」。例:「满月之日,整体偏吉;迦那落在毗湿提,重要的事避开。」规则拼句,不调模型,不加别的形容词;五要素不全就不写。
|
||||
- 计数标签写「3 吉」「2 凶」,数字与字之间一个空格。吉 / 中 / 凶标签只照搬引擎。
|
||||
- 岁差说明:「五要素按 Lahiri 岁差算,和星盘页的 Raman 不同。」Raman 换成资料里实际的岁差名(Raman / KP / True Pushya);资料就是 Lahiri 时写「五要素按 Lahiri 岁差算,和星盘页相同。」;读不到时只写「五要素按 Lahiri 岁差算。」
|
||||
- 岁差说明:「五要素按 Lahiri 岁差算,和星盘页的 Raman 不同。」Raman 换成资料里实际的岁差名(Raman / KP / True Citra / True Pushya / Fagan-Bradley);资料就是 Lahiri 时写「五要素按 Lahiri 岁差算,和星盘页相同。」;读不到时只写「五要素按 Lahiri 岁差算。」
|
||||
- 行运行:「太阳 在 狮子座」,副行「落你的第 2 宫」,逆行时加「· 逆行」。没有本命上升时段首写「还没有本命上升,先不写落在你的第几宫。」(原句「相对本命这一列空着」里的「列」已经没有了)。
|
||||
- 底部提示卡:左边「想问这一天适不适合做某件事?」,右边按钮「带这天去提问」。这是邀请用户问,不是页面给宜忌结论;页面其余地方仍不写「适合 / 不宜」。
|
||||
|
||||
|
||||
@@ -3979,7 +3979,10 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.report-export-header p { margin: var(--space-2) 0; font-size: var(--type-body-sm); color: var(--color-ink-secondary); }
|
||||
.report-export-count { font-variant-numeric: tabular-nums; }
|
||||
.report-export-presets { display: flex; flex-wrap: wrap; gap: var(--space-2); }
|
||||
.report-export-presets [aria-pressed="true"] { background: var(--color-action-soft); color: var(--color-action); }
|
||||
/* Selected preset: neutral raised fill + full ink + a visible edge. The old
|
||||
--color-action-soft fill reads as a dark red-brown block in the dark theme
|
||||
(#3a2620) — BUG-1078. */
|
||||
.report-export-presets [aria-pressed="true"] { background: var(--color-canvas-muted); color: var(--color-ink); border-color: var(--color-ink-secondary); }
|
||||
.report-export-list { overflow-y: auto; overscroll-behavior: contain; min-height: 0; padding: 0 var(--space-4) var(--space-4); }
|
||||
.report-export-list h3 { font-size: var(--type-title-sm); margin: var(--space-3) 0 var(--space-2); }
|
||||
.report-export-grid { display: grid; gap: var(--space-2); }
|
||||
@@ -3995,6 +3998,59 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
@media (min-width: 860px) { .report-export-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media print { .report-export-drawer { display: none !important; } }
|
||||
|
||||
/* ── 我的报告 buttons (BUG-1078) ────────────────────────────────────────────
|
||||
Scoped to the report surfaces only: the list (`.report-center-*`), the
|
||||
reader and its export drawer (`.personal-report-reader`), and the
|
||||
state / error / not-found / loading screens (`.personal-report-state`).
|
||||
The global tokens and components/ui/button.tsx stay as they are; other
|
||||
pages keep the shared primary button (product decision 2026-09-28).
|
||||
Why the shared solid button looked wrong: the unlayered `button { color:
|
||||
inherit; }` near the top of this sheet beats Tailwind's layered
|
||||
`text-primary-foreground`, so a solid <button> renders in the inherited ink —
|
||||
#1d1d1f on #a9583e in the light theme (3.33:1, the reported 「黑字深红底」)
|
||||
and #f2f0ea on #d78064 in the dark theme (2.57:1). The unlayered
|
||||
`button:disabled { opacity: .45 }` then fades the whole thing.
|
||||
- Solid (`variant="default"`): light = #fbfaf7 on #a9583e (4.85:1);
|
||||
dark = --color-ink #f2f0ea on --color-action-on-dark #8f4a33 (5.77:1),
|
||||
hover mixes 6% ink in (5.1:1).
|
||||
- Disabled, any variant: a neutral grey fill with tertiary ink, opacity 1.
|
||||
The solid variant is matched as `[class~="bg-primary"]`, not `.bg-primary`:
|
||||
this sheet does not define Tailwind utilities, and class-name-definition-
|
||||
contract reads every `.name` here as one of our own class families. */
|
||||
:is(.report-center-message, .report-center-body, .personal-report-state, .personal-report-reader) {
|
||||
--report-button-fill: var(--color-action);
|
||||
/* Same as the shared `hover:bg-primary/80`, so the light theme is unchanged. */
|
||||
--report-button-fill-hover: color-mix(in srgb, var(--color-action) 80%, transparent);
|
||||
--report-button-ink: var(--color-on-dark);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) :is(.report-center-message, .report-center-body, .personal-report-state, .personal-report-reader) {
|
||||
--report-button-fill: var(--color-action-on-dark);
|
||||
--report-button-fill-hover: color-mix(in srgb, var(--color-action-on-dark) 94%, var(--color-ink));
|
||||
--report-button-ink: var(--color-ink);
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] :is(.report-center-message, .report-center-body, .personal-report-state, .personal-report-reader) {
|
||||
--report-button-fill: var(--color-action-on-dark);
|
||||
--report-button-fill-hover: color-mix(in srgb, var(--color-action-on-dark) 94%, var(--color-ink));
|
||||
--report-button-ink: var(--color-ink);
|
||||
}
|
||||
:is(.report-center-message, .report-center-body, .personal-report-state, .personal-report-reader) [data-slot="button"][class~="bg-primary"] {
|
||||
background: var(--report-button-fill);
|
||||
color: var(--report-button-ink);
|
||||
}
|
||||
@media (hover: hover) {
|
||||
:is(.report-center-message, .report-center-body, .personal-report-state, .personal-report-reader) [data-slot="button"][class~="bg-primary"]:not(:disabled):hover {
|
||||
background: var(--report-button-fill-hover);
|
||||
}
|
||||
}
|
||||
:is(.report-center-message, .report-center-body, .personal-report-state, .personal-report-reader) [data-slot="button"]:disabled {
|
||||
opacity: 1;
|
||||
border-color: transparent;
|
||||
background: var(--color-canvas-strong);
|
||||
color: var(--color-ink-tertiary);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
personal-report: unique block — editorial reader + A4 print
|
||||
Only /reports/[reportId] uses these semantic classes. The report owns its
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
export const AYANAMSA_VALUES = ["raman", "lahiri", "kp", "true_pushya"] as const;
|
||||
export const AYANAMSA_VALUES = [
|
||||
"raman",
|
||||
"lahiri",
|
||||
"kp",
|
||||
"true_citra",
|
||||
"true_pushya",
|
||||
"fagan_bradley",
|
||||
] as const;
|
||||
export type AyanamsaName = (typeof AYANAMSA_VALUES)[number];
|
||||
export const DEFAULT_AYANAMSA: AyanamsaName = "raman";
|
||||
|
||||
@@ -18,11 +25,21 @@ export const AYANAMSA_OPTIONS = [
|
||||
label: "KP",
|
||||
description: "克里希那穆提体系用的岁差。",
|
||||
},
|
||||
{
|
||||
value: "true_citra",
|
||||
label: "True Citra",
|
||||
description: "以角宿一的实际位置定起点,和 Lahiri 只差约 1 角分。",
|
||||
},
|
||||
{
|
||||
value: "true_pushya",
|
||||
label: "True Pushya",
|
||||
description: "True Pushya 岁差。",
|
||||
},
|
||||
{
|
||||
value: "fagan_bradley",
|
||||
label: "Fagan-Bradley",
|
||||
description: "西方恒星占星常用,比 Lahiri 多约 0.9°。",
|
||||
},
|
||||
] as const satisfies ReadonlyArray<{
|
||||
value: AyanamsaName;
|
||||
label: string;
|
||||
@@ -43,6 +60,7 @@ export function resolveAyanamsa(
|
||||
if (typeof raw === "string") {
|
||||
const key = raw.trim().toLowerCase().replace(/-/g, "_");
|
||||
if (key === "krishnamurti" || key === "krishnamurti_paddhati") return "kp";
|
||||
if (key === "true_chitra") return "true_citra";
|
||||
if (isAyanamsaName(key)) return key;
|
||||
}
|
||||
return DEFAULT_AYANAMSA;
|
||||
|
||||
@@ -263,7 +263,9 @@ const AYANAMSA_SHORT_NAME: Readonly<Record<AyanamsaName, string>> = {
|
||||
raman: "Raman",
|
||||
lahiri: "Lahiri",
|
||||
kp: "KP",
|
||||
true_citra: "True Citra",
|
||||
true_pushya: "True Pushya",
|
||||
fagan_bradley: "Fagan-Bradley",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Widen the household ayanamsa setting from four to six values
|
||||
-- (TASK-chart-types-and-report-buttons-20260928 T6): True Chitra and
|
||||
-- Fagan-Bradley join Raman, Lahiri, KP and True Pushya. Every value is one the
|
||||
-- engine already supports (`scripts/ayanamsa_utils.py` AYANAMSA_MODES); Djwhal
|
||||
-- Khul and Sassanian stay out on purpose.
|
||||
--
|
||||
-- Only the CHECK changes: no column, default, grant or row is touched, so the
|
||||
-- migrate-then-deploy window is safe (the deployed code only writes the old
|
||||
-- four). `chart_profiles.ayanamsa` has no CHECK; the people route writes the
|
||||
-- typed column directly, so the JSON fallback trigger is not on the write path.
|
||||
begin;
|
||||
|
||||
alter table public.profiles
|
||||
drop constraint if exists profiles_ayanamsa_check;
|
||||
|
||||
alter table public.profiles
|
||||
add constraint profiles_ayanamsa_check
|
||||
check (ayanamsa in ('raman', 'lahiri', 'kp', 'true_citra', 'true_pushya', 'fagan_bradley'));
|
||||
|
||||
commit;
|
||||
@@ -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