Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c71d4cba8 | ||
|
|
e3fb1dda72 | ||
|
|
c577d51f26 |
@@ -1,5 +1,9 @@
|
||||
# BLOCKED
|
||||
|
||||
## #233 年运 Markdown 导出的本机门禁
|
||||
|
||||
- 无 Docker 的前端全量失败清单与同机 staging 一致。Python 快速门的三项 Kaala / PyJHora 失败在 staging 复现。另一个 VedAstro 断言只在此次全量运行失败,两边定向复跑均通过。完整门禁留给独立验收。见 [#233 进度](docs/tasks/PROGRESS-annual-md-export-20261113.md)。
|
||||
|
||||
## #212 年运页面缺少方案 B 的写作事实字段
|
||||
|
||||
- ~~写作事实缺方案 B 展示字段,原型的年主星候选口径待定。~~ 评论 43147 已授权页面投影并确认五个候选来源。实现已完成,待独立验收。
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-11-13 — 年运报告可导出 Markdown
|
||||
|
||||
- 写完后从顶栏导出整篇或选取章节,计算依据可整组带走。旧报告也可导出。
|
||||
- 沿用当前 Skill 版本(BUG-1869)。
|
||||
|
||||
## 2026-11-13 — 统一网页和报告的分隔符
|
||||
|
||||
- 浏览器标签、星盘、侧栏与报告页面按场景使用逗号、顿号、括号或间距。星盘出生资料卡的小字可换行。个人报告 Markdown 和后台报告正文采用同一规则。
|
||||
|
||||
@@ -25346,3 +25346,14 @@
|
||||
- 验证:公开盘和合成盘复现事实响应缺字段,完整年包可取月相回归、月盘和 Tripataki。上游定向回归通过,细节见 [#212 进度](tasks/PROGRESS-annual-page-option-b-20261113.md)。
|
||||
- 修复及防复发:复用完整年包生成页面字段,按引擎五个实际来源列年主星候选,接入返照日期、月相回归、补充层和双轴审计。真实快照回归覆盖两地、候选、导出和旧附录。写作证据和旧审计对照哈希一致。
|
||||
- 关联记录:#212、#210、#211、BUG-1856、BUG-1857、BUG-1217。修复版本 `codex/annual-page-option-b-20261113`。
|
||||
|
||||
## BUG-1869 | 年运报告缺少整篇 Markdown 导出
|
||||
|
||||
- 状态:fixed-pending-verify(待独立验收)。
|
||||
- 首次发现 / 最近更新:2026-11-13 / 2026-11-13。
|
||||
- 影响面:年运报告顶栏、导出面板和已保存报告。
|
||||
- 现象与触发:打开一份写完的年运报告,只能从附录导出计算依据,正文没有下载入口。
|
||||
- 根因:年运页面只把附录交给 Markdown 下载器,没有按快照组织正文,也未接入共用的导出面板。
|
||||
- 修复:写完后在顶栏显示共用按钮和面板。按页面顺序导出公开章节及计算依据。按旧快照现有字段导出,省略没有记录的返照块。
|
||||
- 验证与防复发:覆盖写完、写作中、失败及缺节状态,检查整篇和计算依据选取、三列审计及旧报告回读。详见 [#233 进度](tasks/PROGRESS-annual-md-export-20261113.md)。
|
||||
- 关联记录:#233、#212、BUG-1868、BUG-1870、BUG-1845。修复版本 `codex/annual-md-export-20261113`。
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# #233 年运 Markdown 导出
|
||||
|
||||
- 可信任务书见评论 43227。从 staging `097d5b3a` 开工并快进到任务书提交。BUG-1869 是任务书预留编号,当前记录未占用,沿用该编号。
|
||||
- 从个人报告抽出共用导出按钮。年运在 `SecondaryPageShell.actions` 调用它,并复用 `ReportExportDrawer` 的整篇和自选模式。年运只显示正文和计算依据,后者用一个复选框整组选取。旧报告中的计算依据单列审计仍可下载。
|
||||
- 先导出标题、范围和返照日期,再按页面顺序导出各节。采用新时间轴的快照把时间点合在「这一年怎么走」,旧快照按原来的两个章节导出。月相回归放在正文末尾。附录按现有小节顺序使用 `annualAppendixMarkdown`,包括三列审计和全部本命瑜伽。
|
||||
- 文件名取范围起始日,无起始日时取浏览器当天日期。正文只投影页面字段,计算依据沿用页面过滤。导出前应用普通报告公开内容的清洗。
|
||||
- 既有测试断言未改。新增用例覆盖新旧快照、导出入口、分组选择、内容顺序和文件名。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向 36 项通过,包含个人报告导出和英文版既有用例。最终改动复跑年运导出、方案 B 和分隔符合同,9 项通过。
|
||||
- 同机无 Docker 的前端全量比对 staging `097d5b3a`。本分支 3722 项,3639 通过。staging 3718 项,3635 通过。按测试名比较,新增 4 项,既有测试无缺失。两边相同的 46 项失败、37 项跳过、0 项取消。
|
||||
- 390px Chrome 本地回放使用虚构报告和公开盘计算夹具。顶栏标题和按钮均可见,自选面板只显示两组,整篇下载的 Markdown 文件含正文、月相回归、审计表和外部验证,文件名与起始日一致。写作中与失败的报告不显示按钮。
|
||||
- TypeScript 和生产构建通过。ESLint 0 错误,62 条既有警告。首页保持静态,首屏 gzip JS 516093 B 持平,CSS 从 90978 B 到 91054 B。
|
||||
- Python 快速门退出 1,1482 项通过、2 项跳过、29 个子测试通过。三项 Kaala / PyJHora 失败在同机 staging 复现。其余一项全量失败是 `test_the_new_layer_leaves_every_existing_output_unchanged` 的 VedAstro 可选证据字段差异。本分支与 staging 定向复跑均通过,完整绿灯待独立验收。
|
||||
|
||||
## 需拍板
|
||||
|
||||
- 暂无。任务书列出了产品确认过的导出结构。原型链接在当前会话无法读取,界面按任务书文字复用现有控件。
|
||||
@@ -35,6 +35,7 @@
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [#234 任务书](TASK-remove-middle-dot-20261113.md) | [执行进度](PROGRESS-remove-middle-dot-20261113.md) | 用户可见分隔符(BUG-1870) | 待验收 | `codex/remove-middle-dot-20261113` |
|
||||
| [#212 任务书](TASK-annual-page-option-b-20261113.md) | [执行进度](PROGRESS-annual-page-option-b-20261113.md) | 年运页面方案 B(BUG-1868) | 待验收 | `codex/annual-page-option-b-20261113` |
|
||||
| [#233 任务书](TASK-annual-md-export-20261113.md) | [执行进度](PROGRESS-annual-md-export-20261113.md) | 年运报告 Markdown 导出(BUG-1869) | 待验收 | `codex/annual-md-export-20261113` |
|
||||
| [#243 任务书](TASK-reading-computed-fallback-20261113.md) | [执行进度](PROGRESS-reading-computed-fallback-20261113.md) | 个人报告解读计算兜底(BUG-1877) | 待验收 | `codex/reading-computed-fallback-20261113` |
|
||||
| [#242 任务书](TASK-reading-rebind-repair-20261113.md) | [执行进度](PROGRESS-reading-rebind-repair-20261113.md) | 报告解读重绑与逐节重写(BUG-1876) | 待验收 | `codex/reading-rebind-repair-20261113` |
|
||||
| [#241 修订任务书](FIX-chart-choice-questions-1.md) | [执行进度](PROGRESS-chart-choice-questions-20261113.md) | 多轮选盘事件题与判边(BUG-1875) | 待验收 | `codex/chart-choice-questions-20261113` |
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# TASK:年运报告导出 .md(2026-11-13)
|
||||
|
||||
- Gitea:#233
|
||||
- 分支:`codex/annual-md-export-20261113`,从 staging `097d5b3a` 切出。#212(方案 B)和 #234(去「·」)都已合入。
|
||||
- Bug 编号:BUG-1869,是预留的号。开工时核对 `docs/BUG_HISTORY.md`,已被占用就顺延,并在 PROGRESS 写明。
|
||||
- 负责验收:Claude(jyotisha-99,原 jyotisha-de)
|
||||
|
||||
任务书没写到的取舍,不得写成"产品决定"或"裁定",一律标「需拍板」。
|
||||
|
||||
不得修改或删除既有断言来让测试通过。确需修改的,在 PROGRESS 里逐条写明原值、新值和原因。
|
||||
|
||||
## 背景
|
||||
|
||||
- 产品负责人 2026-11-13 要求年运报告能导出 .md。原型见 https://claude.ai/artifact/7nq8yt22n8L3KXiAFQXk4z 的「年运页导出按钮」和「年运导出面板」两张。
|
||||
- 原型的要求:
|
||||
- 按钮和个人报告用同一个「导出」按钮(带 Download 图标的 outline 按钮),放在年运页顶栏右侧。
|
||||
- 报告写完才出现。正在写、失败或没写完时都不显示。
|
||||
- 点开后直接用个人报告的导出面板 `ReportExportDrawer`:先选「整篇报告」或「自选章节」。
|
||||
- 年运只有中文,不显示语言切换。
|
||||
- 「自选章节」分两组:「正文」和「计算依据」。整篇的摘要写成「N 节正文,计算依据 M 项」。
|
||||
- 「计算依据」整组导出,内容和页面附录一致,包括三列审计表和外部验证四行。
|
||||
|
||||
## 要做的
|
||||
|
||||
1. **按钮**
|
||||
- 放进年运页 `SecondaryPageShell` 的 `actions` 位置。
|
||||
- 复用 `components/personal-report/report-actions.tsx` 的按钮写法和文字(「导出」,aria-label「导出报告」)。可以抽一个共用的小组件,也可以给 `ReportActions` 加一个年运用法,二选一,在 PROGRESS 写明。不另做新样式的按钮。
|
||||
2. **面板**
|
||||
- 复用 `ReportExportDrawer`。
|
||||
- 分组名目前写死为「正文 / 分盘 / 核对表」。加一个可选参数,让年运显示「正文 / 计算依据」。个人报告的显示和行为必须不变,用现有测试证明。
|
||||
- 年运没有的分组不显示,也不计入摘要。
|
||||
3. **导出内容**:新函数放在 `frontend/src/lib/annual-report-generate.ts` 或新文件 `annual-report-export.ts`。
|
||||
- **正文组**:一节一块,顺序和页面一致。
|
||||
- 开头一块是标题和范围:「X的这一年」、范围那一行、「这一年从哪天算到哪天」块。
|
||||
- 每节导出页面上显示的文字:依据、正文、应期、记住,以及时间轴或窗口表。表格用 Markdown 表格。
|
||||
- 「月相回归(Tithi Pravesh)」作为单独一块,放在正文组的最后。
|
||||
- **计算依据组**:附录每个小节一块。
|
||||
- 用 `annualAppendixDisplayLines` 的结果。审计表用三列 Markdown 表格。
|
||||
- 本命瑜伽导出全部条目,不折叠。
|
||||
- 文字直接复用 #212 现有的 `annualAppendixMarkdown` 逻辑,不要另写一套。
|
||||
- **不得出现在导出里**:
|
||||
- 把握等级(A–D);
|
||||
- 「·」;
|
||||
- MEVG / Global Web Evidence 等页面隐藏的英文行名;
|
||||
- 写作提示词、模型原文、package 原始 JSON。
|
||||
- **旧快照**(单列审计、没有 `page_appendix`、没有 `solarReturns` / `tithiReturns`)照样能导出,缺的块直接省略。
|
||||
- **文件名**:`年运报告-<范围起始日 YYYY-MM-DD>.md`。拿不到起始日时用今天的日期。
|
||||
4. **去掉 #212 加在附录里的「导出计算依据」按钮。** 原型里没有这个按钮,现在统一由顶栏的「导出」负责。`annualAppendixMarkdown` 可以留作内部函数。
|
||||
|
||||
## 不要碰
|
||||
|
||||
- `scripts/**`、`references/**`
|
||||
- 冻结文件
|
||||
- `frontend/src/lib/consult-preset-prompts.ts`
|
||||
- 个人报告、合盘的导出内容
|
||||
|
||||
## 要改的文件
|
||||
|
||||
- `frontend/src/components/annual/annual-report-page.tsx`
|
||||
- `frontend/src/components/personal-report/report-export-drawer.tsx`,只加分组名参数
|
||||
- 视情况改 `report-actions.tsx`
|
||||
- `frontend/src/lib/annual-report-generate.ts` 或新增 `annual-report-export.ts`
|
||||
- 新测试 `frontend/tests/annual-md-export-20261113.test.tsx`
|
||||
- `frontend/DESIGN.md` 年运一节加一行
|
||||
- `docs/BUG_HISTORY.md`,只在末尾追加
|
||||
- `docs/tasks/PROGRESS-annual-md-export-20261113.md`
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 新测试覆盖以下几点:
|
||||
- 写完的报告显示「导出」,写作中和失败时不显示;
|
||||
- 面板只有「正文」「计算依据」两组,没有语言切换;
|
||||
- 整篇导出包含每一节、审计表和外部验证四行;
|
||||
- 导出里没有 A–D 等级、「·」、MEVG 英文行名;
|
||||
- 旧快照能导出;
|
||||
- 文件名符合规则。
|
||||
2. 个人报告导出的既有测试全部通过,不改断言。
|
||||
3. 附录里不再有「导出计算依据」按钮。
|
||||
4. 390px 宽度下,顶栏「导出」不挤掉标题,面板能正常打开。
|
||||
5. tsc、eslint(0 错)、`npm test`(基线失败除外,在 PROGRESS 写明)、quick gate 全部通过。
|
||||
|
||||
## 自测命令
|
||||
|
||||
```
|
||||
cd frontend && npx tsc --noEmit -p . && npx eslint . && npm test
|
||||
PATH=<venv>/bin:$PATH PYTHONHASHSEED=0 python scripts/run_quality_gate.py --profile quick --skip-frontend-runtime
|
||||
```
|
||||
|
||||
## 进度记录
|
||||
|
||||
写在 `docs/tasks/PROGRESS-annual-md-export-20261113.md`,包括:
|
||||
|
||||
- 选了哪种复用方式;
|
||||
- 导出各块的顺序;
|
||||
- 改过的断言;
|
||||
- 测试结果;
|
||||
- 「需拍板」。
|
||||
+2
-1
@@ -1400,7 +1400,8 @@ Yogini 还在取时,右栏用和 Chara 相同的轨道等待(`OrbitWaiting`
|
||||
|
||||
## 年运报告与开始遮罩(2026-11-01,issue #153 / #154)
|
||||
|
||||
- **方案 B(#212,BUG-1868):** 报告标题后以浅灰块列返照起止和名义年末,跨年盘分别列。时间轴之后、计算依据之前用细边框块显示月相回归,现居地优先并标注出生地参考。计算依据沿用折叠,附录可下载 Markdown。审计用不换行可横向拖动的表格,列为「方法」「计算」「在本报告中」。本命瑜伽先显示第一条,其余在原生折叠里展开。页面不渲染把握等级。
|
||||
- **方案 B(#212,BUG-1868):** 报告标题后以浅灰块列返照起止和名义年末,跨年盘分别列。时间轴之后、计算依据之前用细边框块显示月相回归,现居地优先并标注出生地参考。计算依据沿用折叠。审计用不换行可横向拖动的表格,列为「方法」「计算」「在本报告中」。本命瑜伽先显示第一条,其余在原生折叠里展开。页面不渲染把握等级。
|
||||
- **年运导出(#233,BUG-1869):** 写完后顶栏右侧显示共用的描边「导出」按钮。桌面从右侧打开面板,手机从底部打开。可以导出整篇或自选正文,计算依据可整组选取。手机顶栏保留「年运」标题。
|
||||
- **本命瑜伽(BUG-1857):** 年运写作材料中的瑜伽按原有顺序提供完整清单及状态词,沿用本命段落的版式和篇幅。
|
||||
- **年运附录(BUG-1855):** Narayana 标为条件参考。种子未决时在口径说明中列出候选数,每支单独一行。
|
||||
- **入口**:「我的报告」最上面两个横条并排(手机上下排,2026-11-03 方案 A,见 Personal report centre):「年运」横条是 `Sun` 图标方块、标题、一行「这一年每段怎么安排,几个要留意的时间点」,右边描边按钮「打开年运」(手机「打开」),直接打开 `/reports/annual`。没有单独的入口页。个人报告横条上不写份数。
|
||||
|
||||
@@ -187,7 +187,17 @@
|
||||
.annual-return-note p { margin: 0; }
|
||||
.annual-return-note > div + div { margin-top: var(--space-3); }
|
||||
.annual-return-note-detail { color: var(--color-ink-secondary); font-size: var(--type-caption); }
|
||||
.annual-appendix > button { margin-top: var(--space-3); }
|
||||
@media (max-width: 767px) {
|
||||
.chat-panel:has(.annual-page) .chat-header-title:has(.chat-profile-picker-trigger) > strong {
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.chat-panel:has(.annual-page) .chat-profile-picker-full-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
.annual-appendix section details { margin-top: var(--space-2); }
|
||||
.annual-appendix { border-top: 1px solid var(--color-border); padding-top: var(--space-4); }
|
||||
.annual-appendix section { margin-top: var(--space-3); }
|
||||
|
||||
@@ -11,6 +11,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { AppLink } from "@/components/app-link";
|
||||
import { BirthPlacePicker } from "@/components/birth-place-picker";
|
||||
import { ReportExportButton } from "@/components/personal-report/report-actions";
|
||||
import { ReportExportDrawer } from "@/components/personal-report/report-export-drawer";
|
||||
import { SecondaryPageShell } from "@/components/secondary-page-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
@@ -18,8 +20,8 @@ import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
|
||||
import { accountDialogHref } from "@/lib/account-dialog-intent";
|
||||
import { readCurrentSubjectId, useCurrentSubjectReady, useCurrentSubjectScope } from "@/lib/current-subject";
|
||||
import { dragScroll } from "@/lib/drag-scroll";
|
||||
import { annualAppendixDisplayLines, annualAppendixMarkdown, type AnnualSnapshot } from "@/lib/annual-report-generate";
|
||||
import { downloadMarkdownReport } from "@/lib/consultation-report-export";
|
||||
import { annualAppendixDisplayLines, type AnnualSnapshot } from "@/lib/annual-report-generate";
|
||||
import { buildAnnualExportBlocks, hasAnnualTimeline } from "@/lib/annual-report-export";
|
||||
import {
|
||||
ANNUAL_DOMAINS,
|
||||
ANNUAL_DOMAIN_TITLES,
|
||||
@@ -575,9 +577,6 @@ function AnnualAppendix({ blocks }: Readonly<{ blocks: AnnualSnapshot["appendix"
|
||||
return (
|
||||
<details className="annual-appendix">
|
||||
<summary>计算依据</summary>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => downloadMarkdownReport("年运计算依据", annualAppendixMarkdown(blocks))}>
|
||||
导出计算依据
|
||||
</Button>
|
||||
{blocks.map((block) => {
|
||||
const lines = annualAppendixDisplayLines(block);
|
||||
const [first, ...rest] = lines;
|
||||
@@ -611,8 +610,7 @@ function AnnualAppendix({ blocks }: Readonly<{ blocks: AnnualSnapshot["appendix"
|
||||
export function AnnualReportView({ snapshot, generating, failed = false }: Readonly<{ snapshot: AnnualSnapshot; generating: boolean; failed?: boolean }>) {
|
||||
const nextId = generating ? snapshot.plan.find((entry) => !(entry.id in snapshot.sections))?.id ?? null : null;
|
||||
// Reports written before BUG-1636 keep the two separate sections.
|
||||
const timeline = snapshot.plan.some((entry) => entry.layout === "timeline")
|
||||
&& snapshot.windows.every((window) => Boolean(window.phaseId));
|
||||
const timeline = hasAnnualTimeline(snapshot);
|
||||
return (
|
||||
<article className="annual-report" aria-busy={generating}>
|
||||
<header className="annual-report-head">
|
||||
@@ -655,6 +653,25 @@ export function AnnualReportView({ snapshot, generating, failed = false }: Reado
|
||||
);
|
||||
}
|
||||
|
||||
export function AnnualReportExport({ report }: Readonly<{ report: Pick<ReportState, "status" | "annual"> | null }>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const snapshot = report?.annual;
|
||||
if (report?.status !== "ready" || snapshot?.status !== "ready"
|
||||
|| !snapshot.plan.every((entry) => entry.id in snapshot.sections)) return null;
|
||||
|
||||
return <>
|
||||
<ReportExportButton onClick={() => setOpen(true)} />
|
||||
{open ? (
|
||||
<ReportExportDrawer
|
||||
format="annual"
|
||||
editions={{ zh: buildAnnualExportBlocks(snapshot) }}
|
||||
reportDate={snapshot.range.span.match(/^\d{4}-\d{2}-\d{2}\b/)?.[0]}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</>;
|
||||
}
|
||||
|
||||
function Skeleton({ overview, writing = false }: Readonly<{ overview: AnnualOverview | null; writing?: boolean }>) {
|
||||
return (
|
||||
<article className="annual-report is-skeleton" aria-hidden="true">
|
||||
@@ -783,7 +800,9 @@ export function AnnualReportPage({ initialReportId = null }: Readonly<{ initialR
|
||||
return (
|
||||
// Page entry (2026-11-05): the page curtain stays up until the overview (or an
|
||||
// opened report) is in, so the page never appears as an empty skeleton.
|
||||
<SecondaryPageShell title="年运" waiting={!subjectReady || (!overview && !overviewError) || (reportId !== null && report === null) ? "正在打开年运…" : null}>
|
||||
<SecondaryPageShell title="年运"
|
||||
actions={report?.status === "ready" ? <AnnualReportExport key={report.id} report={report} /> : undefined}
|
||||
waiting={!subjectReady || (!overview && !overviewError) || (reportId !== null && report === null) ? "正在打开年运…" : null}>
|
||||
<div className={`annual-page${showVeil ? " is-veiled" : ""}`}>
|
||||
{overviewError ? <p className="annual-error" role="alert">{overviewError}</p> : null}
|
||||
{writing && report ? (
|
||||
|
||||
@@ -33,6 +33,14 @@ interface ReportActionsProps {
|
||||
placement?: "bar" | "header";
|
||||
}
|
||||
|
||||
export function ReportExportButton({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<Button type="button" variant="outline" aria-label="导出报告" onClick={onClick}>
|
||||
<Download aria-hidden="true" />导出
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportActions({
|
||||
markdown,
|
||||
charts,
|
||||
@@ -71,14 +79,7 @@ export function ReportActions({
|
||||
)}
|
||||
{/* Labelled, outlined and full height (2026-09-29): the icon-only ghost
|
||||
button it replaces was the one control people could not find. */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-label="导出报告"
|
||||
onClick={() => setExportOpen(true)}
|
||||
>
|
||||
<Download aria-hidden="true" />导出
|
||||
</Button>
|
||||
<ReportExportButton onClick={() => setExportOpen(true)} />
|
||||
{editions && (
|
||||
<ReportExportDrawer
|
||||
editions={editions}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import type { EnglishEditionStatus, ReportLanguage } from "@/lib/report-language";
|
||||
import { annualExportFilename } from "@/lib/annual-report-export";
|
||||
|
||||
type Group = ReportExportBlock["group"];
|
||||
const GROUPS: readonly Group[] = ["body", "chart", "table"];
|
||||
@@ -25,7 +26,11 @@ const GROUP_LABELS: Readonly<Record<ReportLanguage, Record<Group, string>>> = {
|
||||
en: { body: "Chapters", chart: "Charts", table: "Tables" },
|
||||
};
|
||||
|
||||
export function exportSummary(counts: Record<Group, number>, language: ReportLanguage): string {
|
||||
export function exportSummary(counts: Record<Group, number>, language: ReportLanguage, format?: "annual"): string {
|
||||
if (format === "annual") {
|
||||
return [counts.body ? `${counts.body} 节正文` : "", counts.table ? `计算依据 ${counts.table} 项` : ""]
|
||||
.filter(Boolean).join(",");
|
||||
}
|
||||
const parts = language === "en"
|
||||
? [[counts.body, "chapters"], [counts.chart, "charts"], [counts.table, "tables"]] as const
|
||||
: [[counts.body, "章正文"], [counts.chart, "张分盘"], [counts.table, "张核对表"]] as const;
|
||||
@@ -52,16 +57,17 @@ export type ReportExportEditions = Readonly<{ zh: readonly ReportExportBlock[];
|
||||
* 导出报告. Desktop slides in from the right; a phone slides up from the bottom.
|
||||
* Pick the edition, then 「整篇报告」 or 「自选章节」. No caption and no file size.
|
||||
*/
|
||||
export function ReportExportDrawer({ editions, initialLanguage = "zh", englishEdition = "legacy", reportDate, onClose }: {
|
||||
export function ReportExportDrawer({ editions, initialLanguage = "zh", englishEdition = "legacy", reportDate, format, onClose }: {
|
||||
editions: ReportExportEditions;
|
||||
/** The edition the reader is showing; the sheet starts on it. */
|
||||
initialLanguage?: ReportLanguage;
|
||||
englishEdition?: EnglishEditionStatus;
|
||||
reportDate?: string | null;
|
||||
format?: "annual";
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const side = useSyncExternalStore(subscribeExportSheetSide, exportSheetSide, () => "right" as const);
|
||||
const hasEnglish = englishEdition === "ready" && Boolean(editions.en?.length);
|
||||
const hasEnglish = format !== "annual" && englishEdition === "ready" && Boolean(editions.en?.length);
|
||||
const [language, setLanguage] = useState<ReportLanguage>(initialLanguage === "en" && hasEnglish ? "en" : "zh");
|
||||
const blocks = (language === "en" ? editions.en : editions.zh) ?? editions.zh;
|
||||
const [mode, setMode] = useState<"whole" | "pick">("whole");
|
||||
@@ -114,7 +120,8 @@ export function ReportExportDrawer({ editions, initialLanguage = "zh", englishEd
|
||||
try {
|
||||
const body = blocks.filter(block => block.group === "body");
|
||||
const full = mode === "whole" || (body.length > 0 && body.every(block => selection.has(block.id)));
|
||||
downloadMarkdownReport(exportFilename(reportDate, full, language), joinExportBlocks(selected));
|
||||
const filename = format === "annual" ? annualExportFilename(reportDate) : exportFilename(reportDate, full, language);
|
||||
downloadMarkdownReport(filename, joinExportBlocks(selected));
|
||||
onClose();
|
||||
notifySuccess("已导出");
|
||||
} catch (cause) {
|
||||
@@ -123,7 +130,7 @@ export function ReportExportDrawer({ editions, initialLanguage = "zh", englishEd
|
||||
}
|
||||
}
|
||||
|
||||
const summary = exportSummary(counts, language);
|
||||
const summary = exportSummary(counts, language, format);
|
||||
|
||||
return (
|
||||
<Sheet open onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||
@@ -133,7 +140,7 @@ export function ReportExportDrawer({ editions, initialLanguage = "zh", englishEd
|
||||
className="report-export-drawer gap-0 overflow-hidden p-0"
|
||||
aria-labelledby="report-export-title"
|
||||
>
|
||||
{hasEnglish ? null : <ExportEditionNotice englishEdition={englishEdition} />}
|
||||
{hasEnglish || format === "annual" ? null : <ExportEditionNotice englishEdition={englishEdition} />}
|
||||
<header className="report-export-header">
|
||||
<div className="report-export-heading">
|
||||
<h2 id="report-export-title">导出报告</h2>
|
||||
@@ -183,35 +190,37 @@ export function ReportExportDrawer({ editions, initialLanguage = "zh", englishEd
|
||||
<div className="report-export-list">
|
||||
{GROUPS.map(group => {
|
||||
const items = blocks.filter(block => block.group === group);
|
||||
if (!items.length) return null;
|
||||
if (!items.length || (format === "annual" && group === "chart")) return null;
|
||||
const shown = needle ? items.filter(block => block.title.toLowerCase().includes(needle)) : items;
|
||||
if (needle && !shown.length) return null;
|
||||
const on = items.filter(block => selection.has(block.id)).length;
|
||||
const expanded = open.has(group);
|
||||
return <section key={group} className="report-export-group" aria-label={labels[group]}>
|
||||
const grouped = format === "annual" && group === "table";
|
||||
const label = grouped ? "计算依据" : labels[group];
|
||||
const expanded = !grouped && open.has(group);
|
||||
return <section key={group} className="report-export-group" aria-label={label}>
|
||||
<div className="report-export-group-head">
|
||||
<label className="report-export-row is-group">
|
||||
<Checkbox
|
||||
aria-label={labels[group]}
|
||||
aria-label={label}
|
||||
checked={on === items.length}
|
||||
indeterminate={on > 0 && on < items.length}
|
||||
onCheckedChange={(checked) => toggle(items.map(block => block.id), checked === true)}
|
||||
/>
|
||||
<span>{labels[group]}<small>{on}/{items.length}</small></span>
|
||||
<span>{label}<small>{on}/{items.length}</small></span>
|
||||
</label>
|
||||
<Button
|
||||
{!grouped ? <Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="report-export-expand"
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${expanded ? "收起" : "展开"}${labels[group]}`}
|
||||
aria-label={`${expanded ? "收起" : "展开"}${label}`}
|
||||
onClick={() => setOpen(current => { const next = new Set(current); if (next.has(group)) next.delete(group); else next.add(group); return next; })}
|
||||
>
|
||||
<ChevronRight aria-hidden="true" />
|
||||
</Button>
|
||||
</Button> : null}
|
||||
</div>
|
||||
{expanded || needle ? shown.map(block => (
|
||||
{!grouped && (expanded || needle) ? shown.map(block => (
|
||||
<label key={block.id} className="report-export-row">
|
||||
<Checkbox
|
||||
aria-label={block.title}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { annualAppendixDisplayLines, annualAppendixMarkdown, type AnnualSnapshot } from "./annual-report-generate";
|
||||
import type { ReportExportBlock } from "./report-export-blocks";
|
||||
import { projectOrdinaryReportMarkdown } from "./report-public-projection";
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
type Window = AnnualSnapshot["windows"][number];
|
||||
type Entry = AnnualSnapshot["plan"][number];
|
||||
|
||||
export function hasAnnualTimeline(snapshot: AnnualSnapshot): boolean {
|
||||
return snapshot.plan.some((entry) => entry.layout === "timeline")
|
||||
&& snapshot.windows.every((window) => Boolean(window.phaseId));
|
||||
}
|
||||
|
||||
export function annualExportFilename(startDate?: string | null, today = new Date()): string {
|
||||
const localDate = [today.getFullYear(), today.getMonth() + 1, today.getDate()]
|
||||
.map((part) => String(part).padStart(2, "0")).join("-");
|
||||
return `年运报告-${startDate && /^\d{4}-\d{2}-\d{2}$/.test(startDate) ? startDate : localDate}`;
|
||||
}
|
||||
|
||||
function fields(value: unknown): Row {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Row : {};
|
||||
}
|
||||
|
||||
function rows(value: unknown): Row[] {
|
||||
return Array.isArray(value) ? value.map(fields) : [];
|
||||
}
|
||||
|
||||
function visible(value: unknown): string {
|
||||
if (typeof value !== "string") return "";
|
||||
return value
|
||||
.replace(/MEVG\s*\/\s*Global Web Evidence|Global Web Evidence|Real Case Calibration|\bMEVG\b/gi, "")
|
||||
.replace(/(?:把握(?:等级|上限)?|置信(?:度|等级)|证据等级)\s*(?:为|[::])?\s*[A-D](?:\s*[–—-]\s*[A-D])?(?:\s*[级档])?/gu, "")
|
||||
.replace(/(?:(\s*[A-D]\s*[级档]\s*)|\(\s*[A-D]\s*[级档]\s*\))/gu, "")
|
||||
.replace(/\s*·\s*/g, ",")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function paragraphs(value: unknown): string {
|
||||
return visible(value).split(/\r?\n+/).map((line) => line.trim()).filter(Boolean).join("\n\n");
|
||||
}
|
||||
|
||||
function labelled(label: string, value: unknown): string {
|
||||
const text = paragraphs(value);
|
||||
return text ? `**${label}:**${text}` : "";
|
||||
}
|
||||
|
||||
function table(headings: readonly string[], data: readonly (readonly unknown[])[]): string {
|
||||
if (!data.length) return "";
|
||||
const row = (cells: readonly unknown[]) => `| ${cells.map((cell) => visible(cell)
|
||||
.replaceAll("|", "\\|").replace(/\r?\n/g, " ")).join(" | ")} |`;
|
||||
return [row(headings), row(headings.map(() => "---")), ...data.map(row)].join("\n");
|
||||
}
|
||||
|
||||
function bullets(values: readonly string[]): string {
|
||||
return values.map(visible).filter(Boolean).map((line) => `- ${line}`).join("\n");
|
||||
}
|
||||
|
||||
function parts(...values: string[]): string {
|
||||
return values.filter(Boolean).join("\n\n");
|
||||
}
|
||||
|
||||
function phaseTable(snapshot: AnnualSnapshot, body: Row): string {
|
||||
const written = rows(body.rows);
|
||||
return table(["时间", "事情", "怎么安排"], snapshot.remaining.map((row) => {
|
||||
const item = written.find((entry) => entry.row_id === row.id);
|
||||
return [row.span, item?.matter, item?.arrange];
|
||||
}));
|
||||
}
|
||||
|
||||
function windowTable(items: readonly { window?: Window; item?: Row }[], timeline = false): string {
|
||||
const headings = timeline
|
||||
? ["时间", "主题", "可以怎么做", "为什么", "另一面", "要有的条件"]
|
||||
: ["时间", "主题", "可以怎么做", "依据", "分歧与限制", "要有的条件"];
|
||||
return parts(table(headings, items.map(({ window, item }) => {
|
||||
const status = timeline
|
||||
? ["要留意的几天", window?.position === "past" ? "已过" : window?.position === "current" ? "进行中" : ""]
|
||||
: ["候选"];
|
||||
return [[window?.span, ...status].filter(Boolean).join(","), item?.theme, item?.action,
|
||||
item?.support, timeline && !item?.support ? "" : item?.divergence, timeline && !item?.support ? "" : item?.conditions];
|
||||
})), ...items.map(({ window, item }) => {
|
||||
if (!window || (timeline && !item)) return "";
|
||||
const evidence = bullets(timeline ? window.basis : [...window.basis, ...window.limits]);
|
||||
return evidence ? parts(`**${visible(window.span)}的盘面依据**`, evidence) : "";
|
||||
}));
|
||||
}
|
||||
|
||||
function timelineMarkdown(snapshot: AnnualSnapshot): string {
|
||||
const written = rows(fields(snapshot.sections.remaining).rows);
|
||||
const windowItems = rows(fields(snapshot.sections.windows).items);
|
||||
const details = (windows: readonly Window[]) => windowTable(windows.map((window) => ({
|
||||
window, item: windowItems.find((item) => item.window_id === window.id),
|
||||
})), true);
|
||||
const phaseIds = new Set(snapshot.remaining.flatMap((row) => row.phaseIds ?? [row.id]));
|
||||
const earlier = details(snapshot.windows.filter((window) => !phaseIds.has(window.phaseId)));
|
||||
return parts(earlier ? parts("### 已经过去的时间点", earlier) : "", ...snapshot.remaining.map((row) => {
|
||||
const item = written.find((entry) => entry.row_id === row.id);
|
||||
const phase = table(["时间", "事情", "怎么安排"], [[row.span, item?.matter, item?.arrange]]);
|
||||
const windows = snapshot.windows.filter((window) => (row.phaseIds ?? [row.id]).includes(window.phaseId));
|
||||
return parts(row.current ? "### 现在" : "", phase, details(windows));
|
||||
}));
|
||||
}
|
||||
|
||||
function sectionMarkdown(snapshot: AnnualSnapshot, entry: Entry, timeline: boolean): string {
|
||||
const body = fields(snapshot.sections[entry.id]);
|
||||
if (timeline && entry.kind === "remaining") return timelineMarkdown(snapshot);
|
||||
if (body.skipped) return "这一节按你的选择没有写。";
|
||||
switch (entry.kind) {
|
||||
case "person": return paragraphs(body.text);
|
||||
case "overview": return parts(labelled("依据", body.evidence), paragraphs(body.text),
|
||||
bullets(rows(body.remember).map((item) => [snapshot.windows.find((row) => row.id === item.window_id)?.span,
|
||||
visible(item.text)].filter(Boolean).join(" "))));
|
||||
case "area": return parts(labelled("依据", body.evidence), paragraphs(body.text),
|
||||
labelled("应期(两套计时)", body.timing), labelled("记住", body.remember));
|
||||
case "domain": return parts(paragraphs(body.base), ...rows(body.segments).map((segment) => {
|
||||
const label = visible(entry.segments?.find((item) => item.id === segment.id)?.label);
|
||||
return parts(label ? `### ${label}` : "", paragraphs(segment.text));
|
||||
}));
|
||||
case "windows": {
|
||||
const items = rows(body.items);
|
||||
if (!items.length) return "这一年没有几个来源同时指向的时间点。";
|
||||
return windowTable(items.map((item) => ({ window: snapshot.windows.find((row) => row.id === item.window_id), item })));
|
||||
}
|
||||
case "remaining": return phaseTable(snapshot, body);
|
||||
case "close": return paragraphs(body.line);
|
||||
}
|
||||
}
|
||||
|
||||
function noteMarkdown(notes: AnnualSnapshot["solarReturns"]): string {
|
||||
return parts(...(notes ?? []).map((note) => parts(paragraphs(note.main), paragraphs(note.detail))));
|
||||
}
|
||||
|
||||
export function buildAnnualExportBlocks(snapshot: AnnualSnapshot): ReportExportBlock[] {
|
||||
const blocks: ReportExportBlock[] = [];
|
||||
function add(id: string, group: ReportExportBlock["group"], title: string, markdown: string) {
|
||||
const publicMarkdown = projectOrdinaryReportMarkdown(markdown).trim();
|
||||
if (!publicMarkdown) return;
|
||||
// The shared downloader joins body blocks without a separator to preserve original Markdown.
|
||||
const text = group === "body" ? `${publicMarkdown}\n\n` : publicMarkdown;
|
||||
blocks.push({ id, group, title, text, chars: text.length });
|
||||
}
|
||||
const title = `${visible(snapshot.subject.name)}的这一年`;
|
||||
const solar = noteMarkdown(snapshot.solarReturns);
|
||||
add("body:opening", "body", title, parts(`# ${title}`,
|
||||
[snapshot.range.label, snapshot.range.span].map(visible).filter(Boolean).join(","),
|
||||
solar ? parts("## 这一年从哪天算到哪天", solar) : ""));
|
||||
|
||||
const timeline = hasAnnualTimeline(snapshot);
|
||||
for (const entry of snapshot.plan) {
|
||||
if (!(entry.id in snapshot.sections) || (timeline && entry.kind === "windows")) continue;
|
||||
const title = visible(entry.title);
|
||||
add(`body:section-${entry.id}`, "body", title, parts(`## ${title}`, sectionMarkdown(snapshot, entry, timeline)));
|
||||
}
|
||||
const lunar = noteMarkdown(snapshot.tithiReturns);
|
||||
if (lunar) add("body:tithi", "body", "月相回归(Tithi Pravesh)", parts("## 月相回归(Tithi Pravesh)", lunar));
|
||||
|
||||
for (const [index, block] of (snapshot.appendix ?? []).entries()) {
|
||||
const title = visible(block.title);
|
||||
const audit = block.audit?.filter(({ method }) => !/^(?:MEVG\b|Global Web Evidence\b|Real Case Calibration\b)/i.test(method.trim()))
|
||||
.map((row) => ({ method: visible(row.method), calculation: visible(row.calculation), usage: visible(row.usage) }));
|
||||
const lines = annualAppendixDisplayLines(block)
|
||||
.filter((line) => !/^(?:MEVG\b|Global Web Evidence\b|Real Case Calibration\b)/i.test(line.trim()))
|
||||
.map(visible).filter(Boolean);
|
||||
const appendix = annualAppendixMarkdown([{ ...block, title, lines, audit }]);
|
||||
// Each subheading stays individually addressable; the drawer selects the group together.
|
||||
add(`table:${block.id}`, "table", title, index === 0 ? appendix : appendix.replace(/^# 计算依据\n\n/, ""));
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import "./install-test-document";
|
||||
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 { AnnualReportExport, AnnualReportView } from "../src/components/annual/annual-report-page";
|
||||
import { annualExportFilename, buildAnnualExportBlocks } from "../src/lib/annual-report-export";
|
||||
import { annualSolarReturns, annualTithiReturns, buildAppendix, type AnnualSnapshot } from "../src/lib/annual-report-generate";
|
||||
import { joinExportBlocks } from "../src/lib/report-export-blocks";
|
||||
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support";
|
||||
|
||||
Object.assign(globalThis, { React });
|
||||
const { publicFacts } = JSON.parse(readFileSync(new URL("./fixtures/annual-page-option-b-20261113.json", import.meta.url), "utf8"));
|
||||
const range = {
|
||||
kind: "birthday_year", key: "annual:birthday_year:2026", label: "生日年度",
|
||||
span: "2026-08-04 到 2027-08-04", crossesBirthday: false, birthdays: [],
|
||||
} as const;
|
||||
const birth = { known: false, beforeMinutes: null, afterMinutes: null, source: null, rectification: "none" } as const;
|
||||
const windowRow = {
|
||||
id: "w", span: "九月初", phaseId: "r", position: "current", basis: ["盘面依据一"], limits: ["保留限制"],
|
||||
systems: [], themes: [], lean: "", leanKey: "", highlightRank: null,
|
||||
};
|
||||
const snapshot: AnnualSnapshot = {
|
||||
kind: "annual_report_snapshot", version: 1, status: "ready", subject: { name: "虚构甲", isSelf: false },
|
||||
range, factsSha256: "private-fixture-marker", appendix: buildAppendix(publicFacts, range, birth),
|
||||
solarReturns: annualSolarReturns(publicFacts), tithiReturns: annualTithiReturns(publicFacts),
|
||||
plan: [
|
||||
{ id: "person", kind: "person", title: "先看这个人", disposition: "write" },
|
||||
{ id: "overview", kind: "overview", title: "这一年", disposition: "write" },
|
||||
{ id: "work", kind: "area", title: "工作", disposition: "write" },
|
||||
{ id: "windows", kind: "windows", title: "几个时间点", disposition: "write" },
|
||||
{ id: "remaining", kind: "remaining", layout: "timeline", title: "这一年怎么走", disposition: "write" },
|
||||
{ id: "close", kind: "close", title: "收尾", disposition: "write" },
|
||||
],
|
||||
sections: {
|
||||
person: { text: "这里有两段。\n再说一句。", systemPrompt: "private-fixture-marker" },
|
||||
overview: { text: "这一年的正文。", evidence: "年盘依据", remember: [{ window_id: "w", text: "提前安排" }] },
|
||||
work: { evidence: "把握等级 B", text: "主业 · 先排次序。", timing: "两套时间", remember: "留出余量", grade: "A" },
|
||||
windows: { items: [{ window_id: "w", theme: "安排", action: "做完手头事", support: "说明", divergence: "另一面", conditions: "有空时" }] },
|
||||
remaining: { rows: [{ row_id: "r", matter: "回顾", arrange: "预留一天" }] },
|
||||
close: { line: "到这里就好。" },
|
||||
},
|
||||
windows: [windowRow], remaining: [{ id: "r", span: "秋天", lean: "", days: 20, current: true }],
|
||||
};
|
||||
|
||||
test("annual export is available only for a finished report and the appendix has one entry point", () => {
|
||||
const render = (report: React.ComponentProps<typeof AnnualReportExport>["report"]) =>
|
||||
renderToStaticMarkup(<AnnualReportExport report={report} />);
|
||||
assert.match(render({ status: "ready", annual: snapshot }), /aria-label="导出报告"/);
|
||||
assert.equal(render({ status: "generating", annual: snapshot }), "");
|
||||
assert.equal(render({ status: "failed", annual: snapshot }), "");
|
||||
assert.equal(render({ status: "ready", annual: { ...snapshot, status: "writing" } }), "");
|
||||
assert.equal(render({ status: "ready", annual: { ...snapshot, sections: {} } }), "");
|
||||
assert.equal(render(null), "");
|
||||
assert.doesNotMatch(renderToStaticMarkup(<AnnualReportView snapshot={snapshot} generating={false} />), /导出计算依据/);
|
||||
});
|
||||
|
||||
test("annual Markdown follows the page order, projects public text and keeps all appendix rows", () => {
|
||||
const blocks = buildAnnualExportBlocks(snapshot);
|
||||
assert.deepEqual(blocks.filter((block) => block.group === "body").map((block) => block.title),
|
||||
["虚构甲的这一年", "先看这个人", "这一年", "工作", "这一年怎么走", "收尾", "月相回归(Tithi Pravesh)"]);
|
||||
const markdown = joinExportBlocks(blocks);
|
||||
for (const text of ["这一年从哪天算到哪天", "这里有两段", "年盘依据", "九月初 提前安排", "主业,先排次序",
|
||||
"应期(两套计时)", "留出余量", "| 时间 | 事情 | 怎么安排 |", "预留一天", "做完手头事", "盘面依据一",
|
||||
"| 方法 | 计算 | 在本报告中 |", "本地计算", "预置来源匹配", "实时联网", "真实案例校准"]) {
|
||||
assert.ok(markdown.includes(text), text);
|
||||
}
|
||||
const yogas = snapshot.appendix.find((block) => block.id === "natal-yogas");
|
||||
const yogaBlock = blocks.find((block) => block.id === "table:natal-yogas");
|
||||
assert.ok(yogas && yogaBlock);
|
||||
for (const line of yogas.lines) assert.ok(yogaBlock.text.includes(line.replace(/\s*·\s*/g, ",")));
|
||||
assert.doesNotMatch(markdown, /·|把握等级\s*[A-D]|MEVG|Global Web Evidence|Real Case Calibration|private-fixture-marker/);
|
||||
});
|
||||
|
||||
test("annual export supports old snapshots with separate windows and a single-column audit", () => {
|
||||
const old: AnnualSnapshot = {
|
||||
...snapshot, solarReturns: undefined, tithiReturns: undefined,
|
||||
plan: snapshot.plan.map((entry) => ({ ...entry, layout: undefined })),
|
||||
appendix: buildAppendix({
|
||||
technique_audit: [
|
||||
{ technique: "Tajika Aspects", status: "executed", note: "旧审计" },
|
||||
{ technique: "MEVG / Global Web Evidence", status: "blocked" },
|
||||
],
|
||||
}, range, birth),
|
||||
};
|
||||
const markdown = joinExportBlocks(buildAnnualExportBlocks(old));
|
||||
assert.match(markdown, /几个时间点[\s\S]*候选[\s\S]*保留限制[\s\S]*这一年怎么走/);
|
||||
assert.match(markdown, /计算已执行。旧审计/);
|
||||
assert.doesNotMatch(markdown, /这一年从哪天算到哪天|月相回归(Tithi Pravesh)|在本报告中|MEVG/);
|
||||
assert.equal(annualExportFilename(undefined, new Date(2026, 0, 9)), "年运报告-2026-01-09");
|
||||
});
|
||||
|
||||
test("annual drawer shows two groups and downloads the selected appendix with the range start date", async () => {
|
||||
const h = createClientLifecycleHarness();
|
||||
const createUrl = URL.createObjectURL;
|
||||
const revokeUrl = URL.revokeObjectURL;
|
||||
const create = document.createElement;
|
||||
let filename = "";
|
||||
let blob: Blob | undefined;
|
||||
URL.createObjectURL = (value) => {
|
||||
assert.ok(value instanceof Blob);
|
||||
blob = value;
|
||||
return "blob:annual-test";
|
||||
};
|
||||
URL.revokeObjectURL = () => {};
|
||||
document.createElement = ((tag: string, ...rest: unknown[]) => tag === "a"
|
||||
? { set download(value: string) { filename = value; }, click() {}, remove() {} }
|
||||
: Reflect.apply(create, document, [tag, ...rest])) as typeof document.createElement;
|
||||
try {
|
||||
type Node = ReturnType<typeof h.elements>[number];
|
||||
const walk = (node: Node): Node[] => [node, ...node.childNodes.flatMap((child) => walk(child as Node))];
|
||||
const nodes = () => [...walk(h.container as unknown as Node), ...walk(h.document.body as unknown as Node)];
|
||||
const byLabel = (label: string, role?: string) => {
|
||||
const node = nodes().find((element) => element.getAttribute("aria-label") === label
|
||||
&& (!role || element.getAttribute("role") === role));
|
||||
assert.ok(node, label);
|
||||
return node;
|
||||
};
|
||||
const byText = (role: string, text: string) => {
|
||||
const node = nodes().find((element) => (role === "button" ? element.tagName === "BUTTON" : element.getAttribute("role") === role)
|
||||
&& element.text.startsWith(text));
|
||||
assert.ok(node, text);
|
||||
return node;
|
||||
};
|
||||
await h.render(<AnnualReportExport report={{ status: "ready", annual: snapshot }} />);
|
||||
await h.event(byLabel("导出报告"));
|
||||
assert.match(byText("tab", "整篇报告").text, /7 节正文,计算依据 \d+ 项/);
|
||||
assert.ok(!nodes().some((node) => node.getAttribute("aria-label") === "导出语言"));
|
||||
await h.event(byText("tab", "自选章节"));
|
||||
assert.deepEqual([...new Set(nodes().filter((node) => node.tagName === "SECTION")
|
||||
.map((node) => node.getAttribute("aria-label")).filter(Boolean))], ["正文", "计算依据"]);
|
||||
await h.event(byLabel("正文", "checkbox"));
|
||||
await h.event(byLabel("计算依据", "checkbox"));
|
||||
await h.event(byText("button", "导出 "));
|
||||
assert.equal(filename, "年运报告-2026-08-04.md");
|
||||
const markdown = await blob?.text();
|
||||
assert.ok(markdown);
|
||||
assert.match(markdown, /技法执行情况[\s\S]*真实案例校准/);
|
||||
assert.doesNotMatch(markdown, /虚构甲的这一年/);
|
||||
assert.deepEqual(h.errors, []);
|
||||
} finally {
|
||||
await h.close();
|
||||
document.createElement = create;
|
||||
URL.createObjectURL = createUrl;
|
||||
URL.revokeObjectURL = revokeUrl;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user