import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { EphemerisView } from "../src/components/ephemeris/ephemeris-page.tsx"; import { SidebarProvider } from "../src/components/ui/sidebar.tsx"; import { parseEphemerisOkResponse, type EphemerisOkResponse } from "../src/lib/ephemeris-contract.ts"; import { EPHEMERIS_COPY, ephemerisAskDraft, ephemerisAyanamsaNote, panchangaFactorsFromEngine, shiftEphemerisDate, transitsFromEngine, } from "../src/lib/ephemeris-view.ts"; Object.assign(globalThis, { React }); const pageSource = readFileSync(new URL("../src/components/ephemeris/ephemeris-page.tsx", import.meta.url), "utf8"); // 原值:`../src/app/ephemeris/page.tsx` // 新值:`../src/app/(app)/ephemeris/page.tsx` // 原因:四个次级路由移进 `(secondary)` 路由组共享一份外壳(TASK-sidebar-unify D2)。 // 路由组括号不进 URL,`/ephemeris` 一字未改;这里改的只是源码位置。 const routePage = readFileSync(new URL("../src/app/(app)/ephemeris/page.tsx", import.meta.url), "utf8"); const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); const panchangaGolden = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-panchanga-range.golden.json", import.meta.url), "utf8")); const natalGolden = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-natal.golden.json", import.meta.url), "utf8")); const transit15 = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-transit-2026-09-15.golden.json", import.meta.url), "utf8")); const transit14 = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-transit-2026-09-14.golden.json", import.meta.url), "utf8")); function payloadFor(date: "2026-09-14" | "2026-09-15", events: EphemerisOkResponse["events"] = { status: "unavailable" }): EphemerisOkResponse { const transits = transitsFromEngine({ transitChart: date === "2026-09-14" ? transit14 : transit15, natalChart: natalGolden, }); const parsed = parseEphemerisOkResponse({ status: "ok", date, today: "2026-09-15", timezoneId: "Asia/Shanghai", panchanga: { available: true, ayanamsaNote: "lahiri_not_account", factors: panchangaFactorsFromEngine(panchangaGolden, date), }, transits: { available: true, natalRelativeAvailable: true, natalRelativeNote: "shown", ayanamsa: "raman", bodies: transits.bodies, }, events, }); assert.ok(parsed); return parsed; } /* 原值:直接 `renderToStaticMarkup()` 新值:外面裹一层 `SidebarProvider` 原因:次级页的 `SidebarProvider` 从每页各一份上移到 `app/(app)/layout.tsx` (TASK-sidebar-unify D2)。页面组件自己不再自带 provider,而 46px 顶栏里的 `SidebarTrigger` 仍要读它。断言主语一字未改,改的是测试挂载环境。 */ function withSidebarProvider(element: React.ReactElement) { return renderToStaticMarkup(React.createElement(SidebarProvider, null, element)); } function render(payload: EphemerisOkResponse | null, date = payload?.date ?? "2026-09-15") { return withSidebarProvider(React.createElement(EphemerisView, { date, today: "2026-09-15", payload, onPrevious() {}, onNext() {}, onToday() {}, onSelectDate() {}, onAsk() {}, })); } test("the ephemeris page is its own route and never grows page.tsx", () => { assert.match(routePage, /from "@\/components\/ephemeris\/ephemeris-page"/); assert.doesNotMatch(readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"), /ephemeris-page/); // 原值: assert.doesNotMatch(app-sidebar.tsx, /ephemeris/) // 新值: 本单文件归属只锁 page.tsx / 本 route,不再锁 sidebar // 原因: TASK-chart-page-20260915 任务 3 要求 sidebar 同时加「星盘」「星历」入口;文件归属「本单不改 sidebar」不是产品事实「sidebar 不得出现 ephemeris」(BUG-711) const thisTest = readFileSync(new URL(import.meta.url), "utf8"); assert.doesNotMatch(thisTest, /from "@\/components\/app-sidebar"/); }); test("date switching replaces panchanga and transits", () => { const fourteenth = payloadFor("2026-09-14"); const fifteenth = payloadFor("2026-09-15"); assert.notEqual( fourteenth.panchanga.factors.find((item) => item.key === "tithi")?.value, fifteenth.panchanga.factors.find((item) => item.key === "tithi")?.value, ); const moon14 = fourteenth.transits.bodies.find((item) => item.planet === "Moon"); const moon15 = fifteenth.transits.bodies.find((item) => item.planet === "Moon"); assert.notEqual(moon14?.degreeInSign, moon15?.degreeInSign); const markup14 = render(fourteenth); const markup15 = render(fifteenth); assert.match(markup14, /Shukla Chaturthi/); assert.match(markup15, /Shukla Panchami/); assert.doesNotMatch(markup15, /Shukla Chaturthi/); assert.match(markup14, /2026年9月14日/); assert.match(markup15, /2026年9月15日/); assert.equal(shiftEphemerisDate("2026-09-15", -1), "2026-09-14"); }); test("unavailable events render a static note, not a blank page", () => { const markup = render(payloadFor("2026-09-15")); assert.match(markup, new RegExp(EPHEMERIS_COPY.eventsUnavailable)); assert.match(markup, /Shukla Panchami/); assert.match(markup, /狮子座/); assert.doesNotMatch(markup, /spinner|正在加载|skeleton/i); }); test("the Lahiri note sits under panchanga and not under transits", () => { const markup = render(payloadFor("2026-09-15")); const panchanga = markup.indexOf(EPHEMERIS_COPY.panchangaHeading); // 原值:`EPHEMERIS_COPY.panchangaLahiriNote`(固定句「这一段按 Lahiri 岁差算,和星盘页用的岁差不是同一套。」) // 新值:`ephemerisAyanamsaNote("raman")`(「五要素按 Lahiri 岁差算,和星盘页的 Raman 不同。」,fixture 的 transits.ayanamsa 是 raman) // 原因:TASK-people-ephemeris-ui-20260926 D10 改句式,并要求按资料实际岁差填名、不写死;位置断言(五要素之下、行运之上)一字未改。 const lahiriNote = ephemerisAyanamsaNote("raman"); const note = markup.indexOf(lahiriNote); const transits = markup.indexOf(EPHEMERIS_COPY.transitsHeading); assert.ok(panchanga >= 0 && note >= 0 && transits >= 0); assert.ok(panchanga < note && note < transits); const transitsBlock = markup.slice(transits); assert.doesNotMatch(transitsBlock, new RegExp(lahiriNote)); }); test("今天 is absent on today and present off it, never disabled", () => { // 原值:`assert.match(markup, new RegExp(EPHEMERIS_COPY.today))` —— 在 // 「copy stays factual」里对唯一一次渲染(date === today)断言「今天」在场。 // 新值:拆成两个状态各断一次,并加断 `disabled` 不出现。 // 原因:T5.2 验收标准要求「在今日时不出现『今天』按钮,而不是出现一个禁用按钮」。 // 原断言恰好只覆盖 date === today 这一种情况,照搬会要求今日也渲染该按钮, // 与验收标准直接冲突。新断言覆盖两个状态,比原来严格。 const onToday = render(payloadFor("2026-09-15")); assert.doesNotMatch(onToday, new RegExp(EPHEMERIS_COPY.today)); assert.doesNotMatch(onToday, /disabled=""/); const offToday = render(payloadFor("2026-09-14")); assert.match(offToday, new RegExp(EPHEMERIS_COPY.today)); assert.doesNotMatch(offToday, /disabled=""/); }); test("mobile CSS turns five panchanga cards into one stacked card", () => { const markup = render(payloadFor("2026-09-15")); assert.match(markup, /class="ephemeris-panchanga"/); assert.equal([...markup.matchAll(/ephemeris-panchanga-item/g)].length, 5); assert.match(css, /@media \(max-width: 767px\) \{[\s\S]*\.ephemeris-panchanga \{[\s\S]*grid-template-columns:\s*1fr/); assert.match(css, /@media \(max-width: 767px\) \{[\s\S]*\.ephemeris-panchanga-item \{[\s\S]*grid-template-columns:\s*5\.5rem minmax\(0, 1fr\)/); assert.match(css, /\.ephemeris-date-button \{[\s\S]*min-height:\s*44px/); }); test("copy stays factual: no fortune-telling, no spinner, ask prefills the date", () => { const markup = render(payloadFor("2026-09-15")); // 原值: 页尾必须含「这一页是天象本身,不是对你的判断。」 // 新值: 页面不得出现该句 // 原因: 产品 2026-09-17 判定为过度提示,与星盘页眉标同一类 assert.doesNotMatch(markup, /天象本身|不是对你的判断/); assert.doesNotMatch(markup, /ephemeris-outro/); assert.match(markup, new RegExp(EPHEMERIS_COPY.ask)); assert.match(markup, new RegExp(EPHEMERIS_COPY.previousDay)); assert.match(markup, new RegExp(EPHEMERIS_COPY.nextDay)); // 原值:`assert.doesNotMatch(markup, /运势|适合|不宜|建议|吉祥日/)`,对整页 markup。 // 新值:先去掉底部提示卡那一句问句(EPHEMERIS_COPY.askPrompt),再对其余 markup 断同一组词;另断问句只出现一次。 // 原因:D8 产品定稿的底部提示卡原文是「想问这一天适不适合做某件事?」——这是邀请用户提问的问句,不是页面给出的 // 宜忌结论;五要素、总评、行运、九十天事件里仍不得出现这组词,约束范围没有放宽。 const withoutAskPrompt = markup.replace(EPHEMERIS_COPY.askPrompt, ""); assert.equal(markup.split(EPHEMERIS_COPY.askPrompt).length - 1, 1); assert.doesNotMatch(withoutAskPrompt, /运势|适合|不宜|建议|吉祥日/); assert.doesNotMatch(pageSource, /InlineSpinner|正在加载|skeleton/i); assert.equal(ephemerisAskDraft("2026-09-15"), "请按 2026年9月15日 这一天的天象来看。"); assert.match(pageSource, /setComposerDraft\(ephemerisAskDraft/); }); test("an uncached date keeps the date bar and replaces the three sections with static sentences", () => { const markup = withSidebarProvider(React.createElement(EphemerisView, { date: "2026-09-16", today: "2026-09-15", payload: null, dataState: "pending", onPrevious() {}, onNext() {}, onToday() {}, onSelectDate() {}, onAsk() {}, })); assert.match(markup, /2026年9月16日/); assert.match(markup, new RegExp(EPHEMERIS_COPY.previousDay)); assert.match(markup, new RegExp(EPHEMERIS_COPY.panchangaUnavailable)); assert.match(markup, new RegExp(EPHEMERIS_COPY.transitsUnavailable)); assert.match(markup, new RegExp(EPHEMERIS_COPY.eventsPending)); assert.doesNotMatch(markup, /还没接上|正在加载|skeleton|spinner/i); }); test("a failed date shows the retry sentence in each section", () => { const markup = withSidebarProvider(React.createElement(EphemerisView, { date: "2026-09-16", today: "2026-09-15", payload: null, dataState: "failed", onPrevious() {}, onNext() {}, onToday() {}, onSelectDate() {}, onAsk() {}, })); assert.equal((markup.match(new RegExp(EPHEMERIS_COPY.dateFailed, "g")) ?? []).length, 3); assert.match(markup, /2026年9月16日/); assert.doesNotMatch(markup, /正在加载|skeleton|spinner/i); });