Files
Jyotisha/frontend/tests/surface-feedback-20260929.test.tsx
T
Jesse_ChenandClaude Opus 5.5 6ad0bea320 feat(rectification): start from an offer card in the consultation, come back to it (BUG-1247~1251)
TASK-rectification-in-chat-step1-20261006, direct execution.

- The homepage 生时校正 pill and the text trigger (isRectificationHandoffQuestion)
  are gone. The consult agent decides (RECTIFICATION_OFFER_RULE, one
  definition) and calls offer-birth-time-rectification; the server decides
  whether a card may show (self, product on, no case in progress,
  feature_pricing price). The offer is stored with the answer and travels on
  run.completed; the card hangs under that answer.
- Opening from the card binds the offer to the case in the consultation's own
  messages (no table change); GET/POST /api/rectification/cases/[caseId]/source
  find the source and go back: one divider (time changed or not), earlier
  answers captioned 按校正前的时间, and a 按新时间重新看 button that sends only
  when clicked. Auto-return after the post-adopt check or a terminal case, only
  for work done in this view.
- Model history skips the divider and notes answers written with the old time.

Full suite 4987 / fail 24, identical to 6ec8efd5 (4973 / 24), no test name
lost; tsc 0; lint 0 errors; build keeps / Static, first-load gzip +0.20%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-10-07 12:58:58 +08:00

170 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import React from "react";
import { BirthSkyEntry } from "../src/components/birth-sky/birth-sky-entry";
import { ChartSymbolHelp, TOUCH_BUBBLE_MS, placeBubble } from "../src/components/chart-page/chart-symbol-help";
import { VedicChartSvg } from "../src/components/personal-report/vedic-chart-svg";
import { assembleChartView } from "../src/lib/chart-view-load";
import type { ChartViewOk } from "../src/lib/chart-view-contract";
import { goldenSky } from "./birth-sky-test-support";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support";
// Product feedback 2026-09-29 evening (BUG-1100..1103).
Object.assign(globalThis, { React });
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
const golden = JSON.parse(read("./fixtures/chart-view-golden.json"));
async function d1() {
const result = await assembleChartView({
userId: "synthetic", accountId: "synthetic", profileFingerprint: "fixture-v1",
profile: { name: "Synthetic", date: "1990-06-15", time: "12:00", placeLabel: "Synthetic", latitude: 39.9042, longitude: 116.4074, timezoneOffset: 8, timezoneId: "Asia/Shanghai", ayanamsa: "raman", birthTimeStatus: "confirmed" },
asOf: "2026-09-15", layers: ["varga"],
postEngine: async (path) => ({ status: "ok", payload: path === "/api/chart" ? golden.chart : golden.varga_full }),
});
return (result.body as ChartViewOk).vedic.vargas.find((varga) => varga.id === "D1")!.chart;
}
test("the bubble sits over the mark, flips below near the top and never leaves the stage (BUG-1101)", () => {
const stage = { left: 100, top: 50, width: 440 };
const middle = placeBubble({ left: 300, top: 250, width: 20, height: 20 }, stage);
assert.deepEqual(middle, { x: 210, y: 200, below: false });
const top = placeBubble({ left: 300, top: 60, width: 20, height: 20 }, stage);
assert.equal(top.below, true);
assert.equal(top.y, 30, "below the mark: its bottom edge");
assert.equal(placeBubble({ left: 102, top: 250, width: 10, height: 10 }, stage).x, 118, "clamped from the left edge");
assert.equal(placeBubble({ left: 530, top: 250, width: 10, height: 10 }, stage).x, 322, "clamped from the right edge");
});
test("hover shows a bubble at the mark; a tap keeps it past pointer-out and closes after 3s; no hint line (BUG-1101)", async () => {
const chart = await d1();
const h = createClientLifecycleHarness();
try {
await h.render(<ChartSymbolHelp resetKey="D1"><VedicChartSvg chart={chart} ariaLabel="测试盘" interactive /></ChartSymbolHelp>);
const stage = h.elements().find((node) => node.getAttribute("class") === "chart-page-symbol-help")!;
const bubble = () => h.elements().find((node) => node.getAttribute("role") === "tooltip");
const asc = h.elements().find((node) => node.getAttribute("data-chart-help") === "As · 上升")!;
assert.equal(bubble()?.text, undefined);
assert.ok(!h.container.text.includes("停在或轻点"), "the hint line is gone");
await h.event(stage, "onPointerOver", { target: asc, pointerType: "mouse" });
assert.equal(bubble()?.text, "As · 上升");
await h.event(stage, "onPointerOut", { target: asc, relatedTarget: null, pointerType: "mouse" });
assert.equal(bubble()?.text, undefined);
// Touch: pointer-over is ignored, the tap (pointer-up) opens, pointer-out and leave do not close.
await h.event(stage, "onPointerOver", { target: asc, pointerType: "touch" });
assert.equal(bubble()?.text, undefined);
await h.event(stage, "onPointerUp", { target: asc, pointerType: "touch" });
assert.equal(bubble()?.text, "As · 上升");
await h.event(stage, "onPointerOut", { target: asc, relatedTarget: null, pointerType: "touch" });
await h.event(stage, "onPointerLeave", { pointerType: "touch" });
assert.equal(bubble()?.text, "As · 上升");
await new Promise((resolve) => setTimeout(resolve, TOUCH_BUBBLE_MS + 50));
await h.idle();
assert.equal(bubble()?.text, undefined, "closes by itself");
assert.deepEqual(h.errors, []);
} finally { await h.close(); }
});
test("the chart card hugs the drawing on desktop (BUG-1100)", () => {
const css = read("../src/app/globals.css");
assert.match(css, /\.chart-page-vedic-stage \{ width: 100%; max-width: calc\(440px \+ 2 \* var\(--space-3\) \+ 2px\); \}/);
assert.doesNotMatch(read("../src/components/chart-page/chart-symbol-help.tsx"), /停在或轻点盘内标记可查看说明/);
});
test("the sky button shows while the cover paints and opens the moment it lands (BUG-1102)", async () => {
const h = createClientLifecycleHarness();
try {
await h.render(<BirthSkyEntry cover={null} />);
const button = h.elements().find((node) => node.tagName === "BUTTON")!;
assert.match(button.text, /那一刻的天空/);
await h.event(button);
// Remembered, nothing opened yet and nothing spinning.
assert.ok(!h.container.text.includes("正在加载"));
const cover = { key: "self:fp", sky: goldenSky("day"), blob: new Blob([], { type: "image/png" }), url: "blob:birth-sky", serif: false };
await h.render(<BirthSkyEntry cover={cover} />);
await h.idle();
assert.deepEqual(h.errors, []);
} finally { await h.close(); }
const view = read("../src/components/chart-page/chart-page-view.tsx");
assert.match(view, /\(birthSky \|\| birthSkyPending\) \? <BirthSkyEntry cover=\{birthSky \?\? null\} \/>/);
const hook = read("../src/hooks/use-birth-sky-cover.ts");
assert.match(hook, /pending: key !== null && current === null && settledKey !== key/);
});
test("report centre card: spaced from the header, title says what, button says do (BUG-1103)", () => {
const centre = read("../src/components/personal-report/personal-report-center.tsx");
assert.match(centre, /<h2 id="report-center-create-title">完整本命报告<\/h2>/);
assert.doesNotMatch(centre, /<h2[^>]*>生成个人报告/);
assert.match(read("../src/app/globals.css"), /\.report-center-create \{\n width: min\(900px, 100%\);\n(?: \/\*[\s\S]*?\*\/\n)? margin: var\(--space-10\) auto var\(--space-8\);/);
});
// Phone check 2026-09-30 (BUG-1106..1107).
test("opening a report shows one waiting screen, the same from the route and from the reader (BUG-1106)", () => {
const route = read("../src/app/(app)/reports/[reportId]/loading.tsx");
assert.match(route, /return <ReportOpening \/>;/);
assert.doesNotMatch(route, /InlineSpinner|<main/);
const page = read("../src/components/personal-report/personal-report-page.tsx");
assert.match(page, /if \(state\.phase === "loading"\) return <ReportOpening \/>;/);
const opening = read("../src/components/personal-report/report-opening.tsx");
assert.match(opening, /<SecondaryHeader title="个人报告" \/>/);
});
test("on phones the reader bar is one row and the export sheet leaves room for the list (BUG-1107)", () => {
const css = read("../src/app/globals.css");
const phone = css.slice(css.indexOf(" .personal-report-back-label { display: none; }") - 800, css.indexOf(" .personal-report-back-label { display: none; }") + 400);
assert.match(phone, /\.personal-report-action-end \{ flex-wrap: nowrap;/);
assert.match(read("../src/components/personal-report/report-actions.tsx"), /aria-label="返回报告中心"[\s\S]*<span className="personal-report-back-label">返回报告中心<\/span>/);
// The export sheet was redesigned the same night (two steps, no presets); its own tests are in report-export-drawer.test.tsx.
const drawer = read("../src/components/personal-report/report-export-drawer.tsx");
assert.match(drawer, /aria-label="关闭导出" title="关闭"><X aria-hidden="true" \/>/);
assert.doesNotMatch(drawer, /report-export-presets|常用|全部分盘|仅核对表/);
});
test("switching editions keeps the reader on the same chapter (BUG-1108)", () => {
const page = read("../src/components/personal-report/personal-report-page.tsx");
assert.match(page, /const chapter = currentChapterIndex\(\);\n setLanguage\(next\);/);
assert.match(page, /setDrawnThrough\(chapter\);/);
assert.match(page, /drawnThrough=\{drawnThrough\}/);
const view = read("../src/components/personal-report/personal-report-markdown-view.tsx");
assert.match(view, /drawn=\{drawnThrough !== null && chapter <= drawnThrough\}/);
assert.match(view, /useState\(section\.eager \|\| drawn\)/);
});
// Product feedback 2026-09-30 01:00.
test("the home no longer says an unfinished rectification is waiting in history", async () => {
// 原值: rectificationEntryHint(true) === null 新值: 首页校正提示函数随按钮一起删除 原因: BUG-1247(TASK-rectification-in-chat-step1-20261006 P3/C1):首页生时校正按钮与文字口令删除,校正只从对话里的提议卡进入
const starterHome = await import("../src/components/starter-home");
assert.equal("rectificationEntryHint" in starterHome, false);
assert.doesNotMatch(read("../src/components/starter-home.tsx"), /上次那次校正还没完成/);
});
test("chart-type cells show the whole meaning line (two lines, CJK-safe line height)", () => {
const css = read("../src/app/globals.css");
const small = css.slice(css.indexOf(".chart-page-type-cell small {"), css.indexOf("}", css.indexOf(".chart-page-type-cell small {")));
assert.match(small, /line-height: 1\.4/);
assert.match(small, /-webkit-line-clamp: 2/);
assert.doesNotMatch(small, /white-space: nowrap/);
});
test("chart, ephemeris and report waits show the orbit, the sentence only for screen readers", () => {
const orbit = read("../src/components/orbit-waiting.tsx");
assert.match(orbit, /<span className="sr-only">\{label\}<\/span>/);
assert.match(orbit, /app-loading-orbit/);
assert.match(read("../src/components/secondary-page-shell.tsx"), /<OrbitWaiting className="secondary-page-waiting" label=\{waiting\} \/>/);
// 原值: /<OrbitWaiting className="chart-page-skeleton-copy" label=\{copy\} \/>/ (orbit logo over the empty chart)
// 新值: no orbit over the chart; the sentence is an sr-only span inside the role=status stage
// 原因: 产品 2026-09-30「星盘的加载动画太丑了」,选「宫位依次点亮」——空盘本身就是等待动画
const chartWait = read("../src/components/chart-page/chart-page-skeleton.tsx");
assert.doesNotMatch(chartWait, /OrbitWaiting/);
assert.match(chartWait, /<span className="sr-only">\{copy\}<\/span>/);
assert.doesNotMatch(read("../src/components/chart-page/chart-page-skeleton.tsx"), /<p className="chart-page-note/);
});
test("the sky button carries a drawn constellation, not the sparkle", () => {
const entry = read("../src/components/birth-sky/birth-sky-entry.tsx");
assert.doesNotMatch(entry, /Sparkles/);
assert.match(entry, /<ConstellationIcon \/>/);
assert.equal((entry.match(/<circle /g) ?? []).length, 4);
});