宫头与宫界<\/caption>/);
assert.equal((markup.match(/class="is-shifted"/g) ?? []).length, shifted.length);
assert.equal((markup.match(/是<\/td>/g) ?? []).length, shifted.length);
diff --git a/frontend/tests/chart-moon-transit.test.tsx b/frontend/tests/chart-moon-transit.test.tsx
index 79237cb0..283b9014 100644
--- a/frontend/tests/chart-moon-transit.test.tsx
+++ b/frontend/tests/chart-moon-transit.test.tsx
@@ -87,6 +87,12 @@ test("the Moon chart is the D1 counted from the Moon's sign, projected without a
const markup = renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId: "MOON", onSelectVarga: () => {} }));
assert.match(markup, /月亮盘 · 以月亮所在星座([^)]+)为第 1 宫<\/caption>/);
assert.deepEqual(ascendantMarkHouses(markup), [moon.ascendantHouse], "the lagna mark sits in the natal ascendant's house");
+ const table = markup.slice(markup.indexOf("月亮盘"));
+ const ascendantRow = table.match(/| [\s\S]*?上升[\s\S]*?<\/tr>/)?.[0] ?? "";
+ assert.match(ascendantRow, / | Uttara Phalguni<\/td>/);
+ assert.match(ascendantRow, /is-sun/);
+ assert.match(ascendantRow, />1);
+ assert.match(ascendantRow, />不适用);
});
test("a chart counted from the ascendant still draws the lagna mark in house 1 only", async () => {
@@ -125,7 +131,8 @@ test("the transit layer posts today's noon with the natal ayanamsa and node mode
const markup = renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId: "TRANSIT", onSelectVarga: () => {} }));
assert.match(markup, /行运 · 2026-09-15 12:00(Asia\/Shanghai)<\/caption>/);
- for (const column of ["行星", "今天的位置", "速度", "落本命", "本命同星"]) assert.match(markup, new RegExp(`| ${column} | `));
+ for (const column of ["行星", "今天的位置", "落本命", "本命同星"]) assert.match(markup, new RegExp(`${column} | `));
+ assert.match(markup, /速度<\/th>/);
assert.ok(markup.includes(CHART_VIEW_COPY.transitNote));
assert.match(markup, //);
assert.match(markup, /- 岁差<\/dt>/);
diff --git a/frontend/tests/chart-page-mobile-width-20260929.test.ts b/frontend/tests/chart-page-mobile-width-20260929.test.ts
index 24b4742b..3ea5335d 100644
--- a/frontend/tests/chart-page-mobile-width-20260929.test.ts
+++ b/frontend/tests/chart-page-mobile-width-20260929.test.ts
@@ -48,7 +48,21 @@ test("the Vedic waiting slot reserves the result legend band without changing th
test("only the house column in frozen natal and Moon tables overrides centering", () => {
assert.match(cssDeclarations(".chart-page-planet-table td.chart-page-planet-mid", globalStyles), /text-align:\s*center/);
+ assert.match(cssDeclarations(".chart-page-planet-table th.chart-page-planet-mid", globalStyles), /text-align:\s*center/);
assert.match(cssDeclarations(".chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > tbody > tr > td:nth-child(4)", globalStyles), /text-align:\s*left/);
+ for (const child of [3, 6, 7]) {
+ assert.match(
+ cssDeclarations(`.chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > thead > tr > th:nth-child(${child})`, globalStyles),
+ /text-align:\s*center/,
+ );
+ }
+});
+
+test("the current dasha card keeps its tint and drops the left accent line", () => {
+ const current = cssDeclarations(".chart-page-dasha-period.is-current", globalStyles);
+ assert.match(current, /background:\s*var\(--color-selected\)/);
+ assert.match(current, /border-color:\s*var\(--color-border-strong\)/);
+ assert.doesNotMatch(current, /box-shadow/);
});
test("wide chart-page content still scrolls inside its own box instead of widening the page", () => {
diff --git a/frontend/tests/chart-varga-table.test.tsx b/frontend/tests/chart-varga-table.test.tsx
index 12451038..cefabcf5 100644
--- a/frontend/tests/chart-varga-table.test.tsx
+++ b/frontend/tests/chart-varga-table.test.tsx
@@ -99,8 +99,11 @@ test("the D1 table is byte-for-byte the table the page drew before BUG-1076, asc
// (Leo 29°23′ = Uttara Phalguni pada 1, lord Sun), where the baseline had dashes.
const baseline = readFileSync(new URL("./fixtures/chart-d1-table-baseline.html", import.meta.url), "utf8");
const dashes = '
| — | — | — | — | ';
+ // 原值:上升行状态格 — |
+ // 新值:不适用 |
+ // 原因:产品把「—」看成没数据。上升没有尊贵,与分盘表、报告同一口径写「不适用」。基线文件仍是改前的表。
const filled = 'Uttara Phalguni | ☉ | '
- + '1 | — | ';
+ + '1 | 不适用 | ';
assert.equal(baseline.split(dashes).length, 2, "the baseline's ascendant row is the only all-dash row");
assert.equal(table, baseline.replace(dashes, filled));
});
@@ -113,6 +116,8 @@ test("the D1 ascendant row shows the engine's nakshatra, lord and pada", async (
const ascendant = row(render(view, "D1"), "上升");
assert.match(ascendant, /Uttara Phalguni<\/td>/);
assert.match(ascendant, /is-sun/, "Uttara Phalguni's lord is the Sun");
+ assert.match(ascendant, />1, "pada 1");
+ assert.match(ascendant, />不适用);
// An older cached chart without the fields still renders, with dashes.
const chart = structuredClone(golden.chart);
for (const key of ["nakshatra", "nakshatra_pada", "nakshatra_lord"]) delete (chart.ascendant as Record)[key];
@@ -136,6 +141,8 @@ test("divisional rows carry engine dignity and vargottama, and drop nakshatra co
const markup = render(view, "D9");
const header = markup.match(/([\s\S]*?)<\/thead>/)?.[1] ?? "";
for (const column of ["行星", "分盘星座", "宫位", "本命星座", "庙旺", "同位"]) assert.match(header, new RegExp(`>${column}<`));
+ assert.match(header, /| 庙旺<\/th>/);
+ assert.match(header, / | 同位<\/th>/);
assert.doesNotMatch(header, /星宿|宿主|pada/);
// Same wording as the D1 status column (BUG-1202), friendship levels included.
// 原值:同位行星单元格是「同位」,非同位是「—」
diff --git a/frontend/tests/database-scoring-job-claim-shape.test.ts b/frontend/tests/database-scoring-job-claim-shape.test.ts
new file mode 100644
index 00000000..367a7bf3
--- /dev/null
+++ b/frontend/tests/database-scoring-job-claim-shape.test.ts
@@ -0,0 +1,157 @@
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { fileURLToPath } from "node:url";
+import test from "node:test";
+
+import {
+ closeLocalPostgresDataPools,
+ createLocalPostgresDataClient,
+} from "../src/lib/db/local-postgres-client-core.ts";
+import type { DataClient } from "../src/lib/db/data-client.ts";
+import { birthTimeScoringAlgorithmVersion } from "../src/lib/birth-time-scoring-job.ts";
+import { createSupabaseScoringJobStore } from "../src/lib/birth-time-scoring-job-store.ts";
+import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
+
+const runnerPath = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url));
+
+function dockerAvailable(): boolean {
+ return spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], {
+ encoding: "utf8",
+ stdio: "ignore",
+ }).status === 0;
+}
+
+const skipWithoutDocker = dockerAvailable() ? false : "docker unavailable on this host";
+
+const ids = {
+ user: "e1111111-1111-4111-8111-111111111111",
+ caseId: "e2222222-2222-4222-8222-222222222222",
+ job: "e3333333-3333-4333-8333-333333333333",
+ shapeJob: "e4444444-4444-4444-8444-444444444444",
+};
+
+const fingerprint = "claim-shape-fingerprint";
+
+function sqlLiteral(value: string): string {
+ return `'${value.replaceAll("'", "''")}'`;
+}
+
+const identity = {
+ userId: ids.user,
+ caseId: ids.caseId,
+ jobId: ids.job,
+ evidenceFingerprint: fingerprint,
+ algorithmVersion: birthTimeScoringAlgorithmVersion,
+ now: "2026-10-15T00:00:00.000Z",
+};
+
+test("claimScoringJob reads the first row when rpc returns an array", async () => {
+ const rpc = async () => ({
+ data: [{ claim_state: "claimed", algorithm_version: birthTimeScoringAlgorithmVersion }],
+ error: null,
+ });
+ const store = createSupabaseScoringJobStore({ rpc } as unknown as DataClient, async () => null);
+ const claim = await store.claimScoringJob(identity);
+ assert.deepEqual(claim, {
+ kind: "claimed",
+ algorithmVersion: birthTimeScoringAlgorithmVersion,
+ });
+});
+
+test("claimScoringJob also accepts the single-object rpc shape", async () => {
+ const rpc = async () => ({
+ data: { claim_state: "processing", algorithm_version: birthTimeScoringAlgorithmVersion },
+ error: null,
+ });
+ const store = createSupabaseScoringJobStore({ rpc } as unknown as DataClient, async () => null);
+ const claim = await store.claimScoringJob(identity);
+ assert.deepEqual(claim, {
+ kind: "processing",
+ algorithmVersion: birthTimeScoringAlgorithmVersion,
+ });
+});
+
+test("claim_birth_time_scoring_job returns an array and claimScoringJob accepts it", { skip: skipWithoutDocker }, async () => {
+ const fixture = startPostgresFixture();
+ const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password");
+ try {
+ const migration = spawnSync(process.execPath, [runnerPath], {
+ encoding: "utf8",
+ env: { ...process.env, SCHEMA_DATABASE_URL: schemaUrl },
+ });
+ assert.equal(
+ migration.status,
+ 0,
+ JSON.stringify({
+ status: migration.status,
+ stderr: migration.stderr,
+ stdoutTail: String(migration.stdout).slice(-1500),
+ }),
+ );
+
+ fixture.psqlAs(
+ "identity_runtime",
+ "identity-runtime-test-password",
+ `insert into identity.users (id, name, email, email_verified, email_verified_at)
+ values (${sqlLiteral(ids.user)}, 'Claim Shape', 'claim-shape@example.com', true, now());`,
+ );
+ fixture.psql(`
+ insert into public.birth_time_rectification_cases (
+ id, user_id, status, reported_date, source
+ ) values (
+ ${sqlLiteral(ids.caseId)}, ${sqlLiteral(ids.user)}, 'rectifying', '1990-01-01', 'family_exact'
+ );
+ insert into public.birth_time_rectification_scoring_jobs (
+ id, case_id, user_id, evidence_fingerprint, algorithm_version, status, expires_at
+ ) values (
+ ${sqlLiteral(ids.job)}, ${sqlLiteral(ids.caseId)}, ${sqlLiteral(ids.user)},
+ ${sqlLiteral(fingerprint)}, ${sqlLiteral(birthTimeScoringAlgorithmVersion)},
+ 'pending', '2026-10-15T01:00:00Z'
+ ), (
+ ${sqlLiteral(ids.shapeJob)}, ${sqlLiteral(ids.caseId)}, ${sqlLiteral(ids.user)},
+ ${sqlLiteral(`${fingerprint}-shape`)}, ${sqlLiteral(birthTimeScoringAlgorithmVersion)},
+ 'pending', '2026-10-15T01:00:00Z'
+ );
+ update public.birth_time_rectification_cases
+ set journey_protocol = 'legacy-guided-v1',
+ turn_state = ${sqlLiteral(JSON.stringify({
+ nextAction: { kind: "score_pending", jobId: ids.shapeJob },
+ }))}::jsonb
+ where id = ${sqlLiteral(ids.caseId)};
+ `);
+
+ const client = createLocalPostgresDataClient(
+ fixture.connectionUrl("service_runtime", "service-runtime-test-password"),
+ null,
+ "service_role",
+ );
+ const direct = await client.rpc("claim_birth_time_scoring_job", {
+ p_user_id: ids.user,
+ p_case_id: ids.caseId,
+ p_job_id: ids.shapeJob,
+ p_evidence_fingerprint: `${fingerprint}-shape`,
+ p_algorithm_version: birthTimeScoringAlgorithmVersion,
+ p_now: identity.now,
+ });
+ assert.equal(direct.error, null, direct.error?.message);
+ assert.equal(Array.isArray(direct.data), true);
+ assert.equal(direct.data[0]?.claim_state, "claimed");
+
+ fixture.psql(`
+ update public.birth_time_rectification_cases
+ set turn_state = ${sqlLiteral(JSON.stringify({
+ nextAction: { kind: "score_pending", jobId: ids.job },
+ }))}::jsonb
+ where id = ${sqlLiteral(ids.caseId)};
+ `);
+ const store = createSupabaseScoringJobStore(client, async () => null);
+ const claim = await store.claimScoringJob(identity);
+ assert.deepEqual(claim, {
+ kind: "claimed",
+ algorithmVersion: birthTimeScoringAlgorithmVersion,
+ });
+ } finally {
+ await closeLocalPostgresDataPools();
+ fixture.stop();
+ }
+});
diff --git a/frontend/tests/fixtures/chart-view-golden.json b/frontend/tests/fixtures/chart-view-golden.json
index 8d47e425..a1dfc734 100644
--- a/frontend/tests/fixtures/chart-view-golden.json
+++ b/frontend/tests/fixtures/chart-view-golden.json
@@ -1,5 +1,5 @@
{
- "sourceCommand": "anaconda python: domain_calculation_service.compute_chart + compute_vimshottari_timeline; divisional_charts_extended.DivisionalChartsCalculator; jaimini.calc_chara_dasha_with_antardasha (pratyantar_dashas stripped; unused by the page); western_chart_engine.build_tropical_natal_chart. Fictional 1990-06-15 12:00 Beijing 39.9042N 116.4074E tz+8 Raman mean node. D40/D45/D60 appended 2026-09-28 from the same chart.planets[*].lon and ascendant.lon via DivisionalChartsCalculator._calculate_single_varga (recompute of D9/D10/D30 from those inputs equals the stored ones). 2026-10-03 (BUG-1201/1202): chart.ascendant nakshatra fields from jyotish_engine._nakshatra_fields(ascendant.lon) and varga_full.result[*].planets[*].dignity_level from scripts/varga_dignity.annotate_varga_dignity on the stored positions (the same calls /api/chart and /api/varga_full now make).",
+ "sourceCommand": "anaconda python: domain_calculation_service.compute_chart + compute_vimshottari_timeline; divisional_charts_extended.DivisionalChartsCalculator; jaimini.calc_chara_dasha_with_antardasha (pratyantar_dashas stripped; unused by the page); western_chart_engine.build_tropical_natal_chart. Fictional 1990-06-15 12:00 Beijing 39.9042N 116.4074E tz+8 Raman mean node. D40/D45/D60 appended 2026-09-28 from the same chart.planets[*].lon and ascendant.lon via DivisionalChartsCalculator._calculate_single_varga (recompute of D9/D10/D30 from those inputs equals the stored ones). 2026-10-03 (BUG-1202): varga_full.result[*].planets[*].dignity_level from scripts/varga_dignity.annotate_varga_dignity on the stored positions. 2026-10-15 (BUG-1352): chart.ascendant nakshatra, nakshatra_pada and nakshatra_lord are the fields returned by JyotishAPIHandler._compute_chart_sync for this birth (tests/test_api_chart_ascendant_nakshatra.py); they are not filled in by hand.",
"chart": {
"success": true,
"birth": {
diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py
index ca07c6cd..432467e5 100644
--- a/scripts/jyotish_api_server.py
+++ b/scripts/jyotish_api_server.py
@@ -2589,12 +2589,24 @@ def _vedastro_runtime_fingerprint() -> dict:
}
+def _chart_ascendant_nakshatra(ascendant_data: dict, lon: float) -> dict:
+ """Keep the engine's nakshatra fields when /api/chart rebuilds the ascendant."""
+ fields = {
+ key: ascendant_data[key]
+ for key in ('nakshatra', 'nakshatra_pada', 'nakshatra_lord')
+ if ascendant_data.get(key) not in (None, '')
+ }
+ if 'nakshatra' in fields:
+ return fields
+ return _load_local_module('jyotish_engine')._nakshatra_fields(lon)
+
+
def _build_api_chart_cache_payload(body: dict) -> dict:
import api_scratch_retention as retention
today = body.get('today') or body.get('current_date')
return {
- 'cache_schema_version': 6,
+ 'cache_schema_version': 7,
'birth': retention.normalize_chart_cache_birth(body),
'calculation': {
'ayanamsa': _request_ayanamsa(body),
@@ -6663,6 +6675,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
'degree': round(asc_lon % 30, 2),
'degree_in_sign': round(asc_lon % 30, 2),
'lon': round(asc_lon, 4),
+ **_chart_ascendant_nakshatra(ascendant_data, asc_lon),
},
'planets': planets_data, 'houses': houses, 'shadbala': shadbala_summary,
'dasha': {
diff --git a/tests/test_api_chart_ascendant_nakshatra.py b/tests/test_api_chart_ascendant_nakshatra.py
new file mode 100644
index 00000000..8232dc87
--- /dev/null
+++ b/tests/test_api_chart_ascendant_nakshatra.py
@@ -0,0 +1,56 @@
+"""The chart API must keep the ascendant nakshatra the engine already computed."""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[1]
+SCRIPTS = ROOT / "scripts"
+TESTS = ROOT / "tests"
+for path in (SCRIPTS, TESTS):
+ if str(path) not in sys.path:
+ sys.path.insert(0, str(path))
+
+import jyotish_api_server # noqa: E402
+from test_api_server_security import _handler # noqa: E402
+
+BEIJING = {
+ "year": 1990,
+ "month": 6,
+ "day": 15,
+ "hour": 12,
+ "minute": 0,
+ "second": 0,
+ "lat": 39.9042,
+ "lon": 116.4074,
+ "tz": 8,
+ "ayanamsa": "raman",
+ "node_mode": "mean",
+ "transit_date": "2026-10-15",
+ "skip_vedastro_main_entry_overview": True,
+}
+
+
+def test_api_chart_keeps_ascendant_nakshatra_from_the_engine(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("JYOTISH_API_CHART_CACHE_TTL_SECONDS", "0")
+ monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "0")
+ monkeypatch.setattr(
+ jyotish_api_server,
+ "_attach_vedastro_main_entry_overview",
+ lambda result, _birth: result,
+ )
+ engine = jyotish_api_server._load_local_module("jyotish_engine")
+ result = _handler()._compute_chart_sync(BEIJING)
+ lon = result["ascendant"]["lon"]
+ expected = engine._nakshatra_fields(lon)
+
+ for packet in (result["ascendant"], result["modules"]["chart"]["ascendant"]):
+ assert packet["nakshatra"] == expected["nakshatra"]
+ assert packet["nakshatra_pada"] == expected["nakshatra_pada"]
+ assert packet["nakshatra_lord"] == expected["nakshatra_lord"]
+ assert result["ascendant"]["nakshatra"] == "Uttara Phalguni"
+ assert result["ascendant"]["nakshatra_pada"] == 1
+ assert result["ascendant"]["nakshatra_lord"] == "Sun"
diff --git a/tests/test_api_scratch_retention.py b/tests/test_api_scratch_retention.py
index 65dab310..9c401d36 100644
--- a/tests/test_api_scratch_retention.py
+++ b/tests/test_api_scratch_retention.py
@@ -139,7 +139,10 @@ def test_numeric_birth_spellings_share_one_chart_cache_key():
assert _chart_key(dict(base, hour=12, tz="Asia/Kolkata")) not in keys
skipped = _chart_key(dict(base, hour=12, skip_vedastro_main_entry_overview=True))
assert skipped not in keys
- assert api._build_api_chart_cache_payload(base)["cache_schema_version"] == 6
+ # 原值:cache_schema_version == 6
+ # 新值:7
+ # 原因:/api/chart 的上升补上星宿三项,旧缓存里没有这三项,必须换键。
+ assert api._build_api_chart_cache_payload(base)["cache_schema_version"] == 7
def test_audit_artifact_default_off_and_compact_when_on(monkeypatch, tmp_path):
| | | | |