niceeval 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/INDEX.md +23 -23
  2. package/README.zh.md +6 -6
  3. package/dist/agents/types.d.ts +2 -2
  4. package/dist/i18n/zh-CN.d.ts +3 -3
  5. package/dist/report/aggregate.d.ts +32 -26
  6. package/dist/report/aggregate.js +157 -76
  7. package/dist/report/built-in/index.d.ts +2 -0
  8. package/dist/report/built-in/index.js +8 -0
  9. package/dist/report/components.d.ts +91 -164
  10. package/dist/report/components.js +377 -114
  11. package/dist/report/compute.d.ts +86 -73
  12. package/dist/report/compute.js +592 -432
  13. package/dist/report/flag.d.ts +28 -17
  14. package/dist/report/flag.js +86 -16
  15. package/dist/report/format.d.ts +11 -11
  16. package/dist/report/format.js +17 -15
  17. package/dist/report/index.d.ts +16 -17
  18. package/dist/report/index.js +20 -22
  19. package/dist/report/load.js +3 -2
  20. package/dist/report/locale.d.ts +49 -34
  21. package/dist/report/locale.js +106 -58
  22. package/dist/report/metrics.d.ts +10 -3
  23. package/dist/report/metrics.js +46 -12
  24. package/dist/report/primitives.d.ts +42 -15
  25. package/dist/report/primitives.js +135 -26
  26. package/dist/report/react/AttemptList.d.ts +10 -8
  27. package/dist/report/react/AttemptList.js +18 -10
  28. package/dist/report/react/DeltaTable.js +19 -18
  29. package/dist/report/react/EvalList.d.ts +3 -3
  30. package/dist/report/react/EvalList.js +0 -0
  31. package/dist/report/react/ExperimentComparison.d.ts +4 -2
  32. package/dist/report/react/ExperimentComparison.js +5 -4
  33. package/dist/report/react/ExperimentList.d.ts +3 -3
  34. package/dist/report/react/ExperimentList.js +16 -15
  35. package/dist/report/react/MetricBars.js +5 -4
  36. package/dist/report/react/MetricLine.js +12 -5
  37. package/dist/report/react/MetricMatrix.js +1 -1
  38. package/dist/report/react/MetricScatter.js +54 -17
  39. package/dist/report/react/MetricTable.js +2 -12
  40. package/dist/report/react/ScopeSummary.d.ts +10 -0
  41. package/dist/report/react/ScopeSummary.js +17 -0
  42. package/dist/report/react/Scoreboard.js +6 -6
  43. package/dist/report/react/cell.js +2 -2
  44. package/dist/report/react/fixtures.d.ts +5 -9
  45. package/dist/report/react/fixtures.js +105 -149
  46. package/dist/report/react/index.d.ts +15 -5
  47. package/dist/report/react/index.js +18 -7
  48. package/dist/report/report.d.ts +137 -20
  49. package/dist/report/report.js +261 -34
  50. package/dist/report/text/faces.d.ts +17 -19
  51. package/dist/report/text/faces.js +225 -157
  52. package/dist/report/text/plot.js +1 -1
  53. package/dist/report/text/table.js +2 -2
  54. package/dist/report/tree.d.ts +90 -40
  55. package/dist/report/tree.js +252 -94
  56. package/dist/report/types.d.ts +245 -300
  57. package/dist/report/types.js +4 -3
  58. package/dist/report/web.d.ts +21 -5
  59. package/dist/report/web.js +42 -16
  60. package/dist/results/select.d.ts +38 -16
  61. package/dist/results/select.js +73 -25
  62. package/dist/results/types.d.ts +38 -14
  63. package/dist/shared/aggregate.d.ts +3 -2
  64. package/dist/shared/aggregate.js +5 -4
  65. package/docs-site/zh/README.md +44 -0
  66. package/docs-site/zh/examples/ai-agent-application.mdx +63 -0
  67. package/docs-site/zh/examples/coding-agent-extensions.mdx +57 -0
  68. package/docs-site/zh/examples/index.mdx +50 -0
  69. package/docs-site/zh/{concepts → explanation}/adapter.mdx +11 -11
  70. package/docs-site/zh/{concepts → explanation}/assert.mdx +7 -7
  71. package/docs-site/zh/{concepts → explanation}/drive.mdx +8 -8
  72. package/docs-site/zh/{concepts → explanation}/evals.mdx +4 -4
  73. package/docs-site/zh/{concepts → explanation}/experiment.mdx +8 -8
  74. package/docs-site/zh/{concepts → explanation}/hitl.mdx +8 -8
  75. package/docs-site/zh/{concepts → explanation}/judge.mdx +5 -5
  76. package/docs-site/zh/{concepts → explanation}/overview.mdx +5 -5
  77. package/docs-site/zh/{guides → explanation}/runner.mdx +1 -1
  78. package/docs-site/zh/{concepts → explanation}/tier.mdx +6 -6
  79. package/docs-site/zh/{guides → how-to}/agent-feedback-loop.mdx +7 -7
  80. package/docs-site/zh/{guides → how-to}/authoring.mdx +2 -2
  81. package/docs-site/zh/{guides → how-to}/connect-otel.mdx +6 -6
  82. package/docs-site/zh/{guides → how-to}/connect-your-agent.mdx +18 -18
  83. package/docs-site/zh/{guides → how-to}/custom-reports.mdx +6 -6
  84. package/docs-site/zh/{guides → how-to}/experiments.mdx +3 -3
  85. package/docs-site/zh/{guides → how-to}/publish-report.mdx +2 -2
  86. package/docs-site/zh/{guides → how-to}/sandbox-agent.mdx +2 -2
  87. package/docs-site/zh/{guides → how-to}/sandbox-providers.mdx +1 -1
  88. package/docs-site/zh/{guides → how-to}/viewing-results.mdx +6 -6
  89. package/docs-site/zh/{guides → how-to}/write-experiment.mdx +3 -3
  90. package/docs-site/zh/{guides → how-to}/write-send.mdx +13 -13
  91. package/docs-site/zh/index.mdx +23 -25
  92. package/docs-site/zh/introduction.mdx +8 -8
  93. package/docs-site/zh/reference/builtin-agents.mdx +5 -5
  94. package/docs-site/zh/reference/capabilities.mdx +6 -6
  95. package/docs-site/zh/reference/cli.mdx +9 -7
  96. package/docs-site/zh/reference/define-agent.mdx +1 -1
  97. package/docs-site/zh/reference/events.mdx +3 -3
  98. package/docs-site/zh/{guides → reference}/official-adapters.mdx +7 -7
  99. package/docs-site/zh/{guides → reference}/report-components.mdx +5 -5
  100. package/docs-site/zh/{guides → reference}/results-data.mdx +5 -5
  101. package/docs-site/zh/{guides → troubleshooting}/debug-sandbox.mdx +2 -2
  102. package/docs-site/zh/{guides → troubleshooting}/debugging.mdx +4 -2
  103. package/docs-site/zh/{quickstart.mdx → tutorials/quickstart.mdx} +5 -17
  104. package/package.json +6 -2
  105. package/src/agents/index.ts +2 -2
  106. package/src/agents/openai-compat.ts +1 -1
  107. package/src/agents/streaming.ts +2 -2
  108. package/src/agents/types.ts +3 -3
  109. package/src/cli.ts +42 -23
  110. package/src/context/context.ts +1 -1
  111. package/src/context/session.test.ts +1 -1
  112. package/src/context/session.ts +1 -1
  113. package/src/i18n/en.ts +18 -16
  114. package/src/i18n/zh-CN.ts +16 -15
  115. package/src/report/aggregate.ts +175 -87
  116. package/src/report/built-in/index.tsx +9 -0
  117. package/src/report/components.tsx +625 -285
  118. package/src/report/compute.ts +717 -515
  119. package/src/report/dual-render.test.tsx +738 -1148
  120. package/src/report/flag.ts +97 -33
  121. package/src/report/format.ts +18 -22
  122. package/src/report/index.ts +113 -58
  123. package/src/report/load.ts +3 -2
  124. package/src/report/locale.ts +120 -69
  125. package/src/report/metrics.ts +42 -12
  126. package/src/report/primitives.tsx +190 -45
  127. package/src/report/react/AttemptList.tsx +32 -20
  128. package/src/report/react/DeltaTable.tsx +63 -45
  129. package/src/report/react/EvalList.tsx +0 -0
  130. package/src/report/react/ExperimentComparison.tsx +12 -7
  131. package/src/report/react/ExperimentList.tsx +38 -26
  132. package/src/report/react/MetricBars.tsx +5 -4
  133. package/src/report/react/MetricLine.tsx +13 -8
  134. package/src/report/react/MetricMatrix.tsx +2 -2
  135. package/src/report/react/MetricScatter.tsx +74 -20
  136. package/src/report/react/MetricTable.tsx +4 -76
  137. package/src/report/react/ScopeSummary.tsx +86 -0
  138. package/src/report/react/Scoreboard.tsx +28 -10
  139. package/src/report/react/cell.tsx +2 -2
  140. package/src/report/react/enhance.js +57 -5
  141. package/src/report/react/fixtures.ts +109 -156
  142. package/src/report/react/index.tsx +24 -39
  143. package/src/report/react/render.test.tsx +139 -104
  144. package/src/report/react/styles.css +181 -91
  145. package/src/report/report.test.ts +761 -1031
  146. package/src/report/report.ts +425 -47
  147. package/src/report/text/faces.ts +257 -164
  148. package/src/report/text/plot.ts +1 -1
  149. package/src/report/text/table.ts +2 -2
  150. package/src/report/tree.ts +362 -104
  151. package/src/report/types.ts +257 -287
  152. package/src/report/web.ts +63 -20
  153. package/src/results/attempt-evidence.test.ts +4 -4
  154. package/src/results/attempt-evidence.ts +5 -5
  155. package/src/results/copy.ts +6 -6
  156. package/src/results/host-equivalence.test.ts +26 -14
  157. package/src/results/index.ts +10 -4
  158. package/src/results/open.ts +8 -4
  159. package/src/results/results.test.ts +4 -3
  160. package/src/results/select.ts +104 -34
  161. package/src/results/types.ts +36 -14
  162. package/src/runner/feedback/human.test.ts +1 -1
  163. package/src/runner/run.ts +1 -1
  164. package/src/sandbox/cli-commands.ts +2 -2
  165. package/src/scoring/judge.test.ts +1 -1
  166. package/src/shared/aggregate.ts +5 -4
  167. package/src/show/compose.ts +50 -67
  168. package/src/show/index.ts +107 -56
  169. package/src/show/render.ts +43 -27
  170. package/src/show/report-host.test.ts +188 -0
  171. package/src/show/report-host.ts +375 -0
  172. package/src/show/show.test.ts +86 -36
  173. package/src/view/app/App.test.tsx +69 -0
  174. package/src/view/app/App.tsx +144 -48
  175. package/src/view/app/components/AttemptModal.tsx +324 -63
  176. package/src/view/app/components/CodeView.tsx +10 -4
  177. package/src/view/app/i18n.ts +31 -17
  178. package/src/view/app/main.tsx +13 -8
  179. package/src/view/app/pages/{RunsPage.tsx → AttemptsPage.tsx} +6 -6
  180. package/src/view/app/types.ts +4 -1
  181. package/src/view/client-dist/app.css +1 -1
  182. package/src/view/client-dist/app.js +14 -14
  183. package/src/view/data.test.ts +9 -3
  184. package/src/view/data.ts +145 -49
  185. package/src/view/index.ts +48 -44
  186. package/src/view/server.ts +35 -15
  187. package/src/view/shared/types.ts +34 -5
  188. package/src/view/styles.css +224 -0
  189. package/src/view/view-report.test.ts +161 -57
  190. package/dist/report/built-ins/experiment-comparison.d.ts +0 -39
  191. package/dist/report/built-ins/experiment-comparison.js +0 -119
  192. package/dist/report/built-ins/index.d.ts +0 -2
  193. package/dist/report/built-ins/index.js +0 -2
  194. package/dist/report/react/GroupSummary.d.ts +0 -8
  195. package/dist/report/react/GroupSummary.js +0 -8
  196. package/dist/report/react/RunOverview.d.ts +0 -8
  197. package/dist/report/react/RunOverview.js +0 -12
  198. package/docs-site/zh/example/ai-agent-application.mdx +0 -152
  199. package/docs-site/zh/example/claude-code-codex-plugin.mdx +0 -167
  200. package/docs-site/zh/example/claude-code-codex-skill.mdx +0 -152
  201. package/docs-site/zh/example/showcase.mdx +0 -39
  202. package/src/report/built-in-user-parity.test.tsx +0 -597
  203. package/src/report/built-ins/experiment-comparison.tsx +0 -179
  204. package/src/report/built-ins/index.ts +0 -7
  205. package/src/report/react/GroupSummary.tsx +0 -66
  206. package/src/report/react/RunOverview.tsx +0 -109
  207. /package/docs-site/zh/{example/tier1-ai-sdk-v7.mdx → examples/integrations/ai-sdk-v7.mdx} +0 -0
  208. /package/docs-site/zh/{example/tier1-claude-sdk.mdx → examples/integrations/claude-sdk.mdx} +0 -0
  209. /package/docs-site/zh/{example/tier1-codex-sdk.mdx → examples/integrations/codex-sdk.mdx} +0 -0
  210. /package/docs-site/zh/{example/tier1-langgraph.mdx → examples/integrations/langgraph.mdx} +0 -0
  211. /package/docs-site/zh/{example/tier1-pi-sdk.mdx → examples/integrations/pi-sdk.mdx} +0 -0
  212. /package/docs-site/zh/{guides → how-to}/ci-integration.mdx +0 -0
  213. /package/docs-site/zh/{guides → how-to}/dataset-fanout.mdx +0 -0
  214. /package/docs-site/zh/{guides → how-to}/fixtures.mdx +0 -0
  215. /package/docs-site/zh/{guides → how-to}/reporters.mdx +0 -0
  216. /package/docs-site/zh/{guides → how-to}/scoring-guide.mdx +0 -0
@@ -1,1256 +1,846 @@
1
1
  // cases: docs/engineering/unit-tests/reports/cases.md
2
- // 双面验收:每个官方组件对同一份数据,web 面(renderToStaticMarkup)与 text 面
3
- // 给出一致判读 —— 排序方向随 bettersamples < total 角标、缺数据 — 不补 0、
4
- // 截断如实报剩余;text 面形态以内联快照锁定(照 report-components.mdx 的示例形态)。
5
- // 另验收:排版原语的分栏与降级、渲染前树校验、defineComponent 自定义组件、
6
- // defineReport + renderReportToText / renderReportToStaticHtml 两个宿主入口。
2
+ // 管线与双面渲染的单元测试:resolve(spec/data 双形态、记忆化、组合组件、同层并行保序、
3
+ // 非法节点拒绝)validate(裸字符串、单面组件、Tabs 配对)、装载规范化(defineReport 三种
4
+ // 写法、content/pages 互斥、外壳嵌套、page id、标题回退)、text/web 双面同源、
5
+ // Table 与文本排版原语、FailureList 等价、内建报告等价。
7
6
 
8
7
  import { renderToStaticMarkup } from "react-dom/server";
9
8
  import { describe, expect, it } from "vitest";
10
9
 
11
- import type { EvalResult } from "../types.ts";
12
- import type { Results, Selection, Snapshot } from "../results/index.ts";
13
- import type { GroupSummaryData, ScatterData } from "./types.ts";
14
- import type { AttemptLocator, MetricScatterProps } from "./index.ts";
15
- import { evalLevelStats } from "../shared/verdict.ts";
10
+ import type { EvalResult, Verdict } from "../types.ts";
11
+ import type { AttemptHandle, Results, Scope, ScopeWarning, Snapshot } from "../results/index.ts";
12
+ import { makeScope } from "../results/select.ts";
13
+ import {
14
+ createTextContext,
15
+ defineComponent,
16
+ renderNodeToText,
17
+ resolveReportTree,
18
+ validateReportTree,
19
+ ResolveMemo,
20
+ type ReportNode,
21
+ } from "./tree.ts";
22
+ import {
23
+ buildReportMeta,
24
+ defineReport,
25
+ FALLBACK_REPORT_TITLE,
26
+ pickReportPage,
27
+ renderReportToText,
28
+ ReportPageNotFoundError,
29
+ resolveReportTitle,
30
+ } from "./report.ts";
31
+ import { renderReportToStaticHtml } from "./web.ts";
16
32
  import {
17
33
  AttemptList,
18
- Col,
19
- DeltaTable,
20
- EvalList,
34
+ ExperimentComparison,
21
35
  ExperimentList,
22
- GroupSummary,
36
+ FailureList,
23
37
  MetricBars,
24
- MetricLine,
25
38
  MetricMatrix,
26
39
  MetricScatter,
27
40
  MetricTable,
28
- Row,
29
- RunOverview,
30
- Scoreboard,
31
- Section,
32
- Style,
33
- Table,
34
- Text,
35
- bar,
36
- costUSD,
37
- defineComponent,
38
- defineReport,
39
- endToEndPassRate,
40
- isReportDefinition,
41
- padEnd,
42
- taskPassRate,
43
- renderReportToText,
44
- stringWidth,
45
- } from "./index.ts";
46
- import { ExperimentComparison } from "./built-ins/index.ts";
47
- import { renderReportToStaticHtml } from "./web.ts";
48
- import { createTextContext, renderNodeToText, validateReportTree } from "./tree.ts";
49
- import {
50
- attemptListItems,
51
- deltaData,
52
- evalListItems,
53
- experimentListItems,
54
- groupSummaryData,
55
- lineData,
56
- matrixData,
57
- overviewData,
58
- scatterData,
59
- scoreboardData,
60
- tableData,
61
- } from "./react/fixtures.ts";
62
-
63
- const ctx = createTextContext({ width: 80 });
64
- const text = (node: Parameters<typeof renderNodeToText>[0]) => renderNodeToText(node, ctx);
65
-
66
- // ───────────────────────── 每组件双面一致 ─────────────────────────
67
-
68
- describe("RunOverview 双面", () => {
69
- const html = renderToStaticMarkup(<RunOverview data={overviewData} />);
70
- const term = text(<RunOverview data={overviewData} />);
71
-
72
- it("text 面形态:头行(含通过率)+ 判定行 + 警告行", () => {
73
- expect(term).toMatchInlineSnapshot(`
74
- "2 experiments · 12 evals · 48 attempts · End-to-end pass rate 70% 46/48 · composed from 2 runs · latest 2026-07-01T11:30:00Z
75
- passed 36 · failed 8 · errored 2 · skipped 2 · no data · 4m 21s
76
- ! snapshot covers 9 of 12 evals seen in history; re-run \`niceeval exp compare/bub\` for a full snapshot"
77
- `);
78
- });
79
-
80
- it("两面同口径:通过率显示同一个 MetricCell.display(70%,不是 36/46 现算的 78%);成本全缺都是 no data 不编 $0;警告都在", () => {
81
- for (const face of [html, term]) {
82
- expect(face).toContain("70%");
83
- expect(face).not.toContain("78%");
84
- expect(face).toContain("46/48"); // samples < total:两级聚合的覆盖率角标,两面一致
85
- expect(face).toContain("no data");
86
- expect(face).not.toContain("$0");
87
- expect(face).toContain("snapshot covers 9 of 12 evals");
88
- }
89
- });
90
- });
91
-
92
- // fixtures.overviewData 是手工摆好的终值,只验证渲染面「原样显示 MetricCell,不重算」;
93
- // 下面这组用真实 Selection 走一遍 RunOverview.data(= compute.ts 的 overviewData()),
94
- // 专门验证 totals.passRate 本身的计算口径 —— 四种通过率公式在这个 fixture 上各不相同:
95
- // 默认端到端两级聚合:eval a 题内 2/3、eval b 题内 1、eval c errored=0,
96
- // 跨题均值 (2/3 + 1 + 0) / 3 = 5/9 ≈ 55.6%
97
- // 条件 taskPassRate 排除 errored:(2/3 + 1) / 2 = 5/6 ≈ 83.3%
98
- // 端到端 attempt 平铺:3 passed / (3 passed + 1 failed + 1 errored) = 60%
99
- // eval 折叠投票:a、b passed,c errored → 2/3 ≈ 66.7%
100
- // 四个数互不相同,任何一处偷懒复用另一个公式都会在这里露馅。
101
- describe("RunOverview.data · endToEndPassRate 两级聚合口径", () => {
102
- function fakeVaryingAttemptsContext(): { selection: Selection; attempts: { evalId: string; result: { verdict: EvalResult["verdict"] } }[] } {
103
- const dir = "/results/compare_bub/snap-1";
104
- const base = {
105
- experimentId: "compare/bub",
106
- startedAt: "2026-07-01T10:00:00Z",
107
- completedAt: "2026-07-01T10:05:00Z",
108
- agent: "bub",
109
- schemaVersion: 1,
110
- dir,
111
- };
112
- const mk = (evalId: string, verdict: "passed" | "failed" | "errored" | "skipped", attemptIndex: number, minute: number) => ({
113
- evalId,
114
- experimentId: "compare/bub",
115
- result: {
116
- id: evalId,
117
- agent: "bub",
118
- verdict,
119
- attempt: attemptIndex,
120
- startedAt: `2026-07-01T10:0${minute}:00Z`,
121
- durationMs: 1000,
122
- assertions: [],
123
- },
124
- ref: { snapshot: "compare_bub/snap-1", attempt: `${evalId}/a${attemptIndex}` },
125
- snapshot: base,
126
- events: async () => null,
127
- trace: async () => null,
128
- o11y: async () => null,
129
- diff: async () => null,
130
- sources: async () => null,
131
- });
132
- const attempts = [
133
- // eval a:3 attempts,2 通过 1 失败 → 题内 partial credit 2/3
134
- mk("algebra/a", "passed", 0, 0),
135
- mk("algebra/a", "failed", 1, 1),
136
- mk("algebra/a", "passed", 2, 2),
137
- // eval b:1 attempt 通过 → 题内 1
138
- mk("algebra/b", "passed", 0, 3),
139
- // eval c:1 attempt errored —— 默认端到端记 0,条件 taskPassRate 才排除
140
- mk("algebra/c", "errored", 0, 4),
141
- // eval d:1 attempt 跳过 —— 两级聚合与 eval 折叠计票都要把它排除在分母外
142
- mk("algebra/d", "skipped", 0, 5),
143
- ];
144
- const evalIds = [...new Set(attempts.map((a) => a.evalId))];
145
- const snapshot: Snapshot = {
146
- ...base,
147
- evals: evalIds.map((id) => ({ id, attempts: attempts.filter((a) => a.evalId === id) })),
148
- attempts,
149
- } as unknown as Snapshot;
150
- const selection: Selection = { snapshots: [snapshot], warnings: [], filter: () => selection };
151
- return { selection, attempts };
152
- }
153
-
154
- it("totals.passRate = 端到端两级聚合 55.6%,不排除 errored,也不复用平铺或 eval 折叠投票", async () => {
155
- const { selection, attempts } = fakeVaryingAttemptsContext();
156
- const data = await RunOverview.data(selection);
157
-
158
- expect(data.totals.passRate.value).toBeCloseTo(5 / 9, 10);
159
- expect(data.totals.passRate.display).toBe("55.6%");
160
- expect(data.totals.passRate.samples).toBe(5); // 6 attempts - 1 skipped(errored 进桶且记 0)
161
- expect(data.totals.passRate.total).toBe(6);
162
-
163
- // attempt 原始占比(旧 bug 公式):必须与两级聚合不同,证明没有从 passed/failed/errored 现算
164
- const attemptFraction = data.totals.passed / (data.totals.passed + data.totals.failed + data.totals.errored);
165
- expect(attemptFraction).toBeCloseTo(0.6, 10);
166
- expect(attemptFraction).not.toBeCloseTo(data.totals.passRate.value as number, 3);
167
-
168
- // eval 折叠投票(evalLevelStats,GroupSummary 的口径):也必须与两级聚合不同
169
- const stats = evalLevelStats(
170
- attempts.map((a) => ({ verdict: a.result.verdict, key: a.evalId })),
171
- (r) => r.key,
172
- );
173
- expect(stats.passRate).toBeCloseTo(2 / 3, 10);
174
- expect(stats.passRate).not.toBeCloseTo(data.totals.passRate.value as number, 3);
175
- });
176
- });
177
-
178
- // ───────────────────────── GroupSummary ─────────────────────────
179
-
180
- describe("GroupSummary 双面", () => {
181
- const html = renderToStaticMarkup(<GroupSummary data={groupSummaryData} />);
182
- const term = text(<GroupSummary data={groupSummaryData} />);
183
-
184
- it("text 面形态:一行头(通过率 + experiment/eval 数 + failed/errored + 总成本)+ 最后运行时间", () => {
185
- expect(term).toMatchInlineSnapshot(`
186
- "End-to-end pass rate 60% 5/6 · 2 experiments · 6 evals · failed 1 · errored 1 · $1.50
187
- latest 2026-07-01T11:30:00Z"
188
- `);
189
- });
41
+ ScopeSummary,
42
+ } from "./components.tsx";
43
+ import { Col, Row, Section, Style, Tab, Table, Tabs, Text } from "./primitives.tsx";
44
+ import { attemptListData, metricScatterData } from "./compute.ts";
45
+ import { costUSD, defineMetric, endToEndPassRate } from "./metrics.ts";
46
+ import builtInReport from "./built-in/index.tsx";
47
+
48
+ // ───────────────────────── fake 数据 ─────────────────────────
49
+
50
+ let seq = 0;
51
+
52
+ function res(id: string, verdict: Verdict, extra: Partial<EvalResult> = {}): EvalResult {
53
+ seq += 1;
54
+ return {
55
+ id,
56
+ agent: "agent-x",
57
+ verdict,
58
+ attempt: 0,
59
+ startedAt: `2026-07-01T00:00:00.${String(seq).padStart(6, "0")}Z`,
60
+ durationMs: 1000,
61
+ assertions: [],
62
+ ...extra,
63
+ };
64
+ }
190
65
 
191
- it("两面同口径:通过率显示同一个 MetricCell.display(60%,不是现场重算);总成本、最后运行时间两面一致", () => {
192
- for (const face of [html, term]) {
193
- expect(face).toContain("60%");
194
- expect(face).toContain("$1.50");
195
- expect(face).toContain("2026-07-01T11:30:00Z");
196
- }
197
- // 覆盖率角标:samples(5) < total(6) —— 1 道 eval 是 skipped,没进分母
198
- expect(html).toContain("5/6");
199
- expect(term).toContain("5/6");
200
- });
66
+ let runSeq = 0;
67
+
68
+ function snap(spec: {
69
+ experimentId: string;
70
+ results: EvalResult[];
71
+ agent?: string;
72
+ model?: string;
73
+ name?: Snapshot["name"];
74
+ runStartedAt?: string;
75
+ }): Snapshot {
76
+ runSeq += 1;
77
+ const startedAt = spec.runStartedAt ?? `2026-06-01T00:00:00.${String(runSeq).padStart(3, "0")}Z`;
78
+ const snapshot = {
79
+ experimentId: spec.experimentId,
80
+ startedAt,
81
+ completedAt: startedAt,
82
+ agent: spec.agent ?? "agent-x",
83
+ model: spec.model,
84
+ name: spec.name,
85
+ schemaVersion: 1,
86
+ dir: `/results/exp/snap-${runSeq}`,
87
+ } as Snapshot;
88
+ const attempts: AttemptHandle[] = spec.results.map((r) => ({
89
+ evalId: r.id,
90
+ experimentId: spec.experimentId,
91
+ result: r,
92
+ ref: { snapshot: `exp/snap-${runSeq}`, attempt: `${r.id}/a${r.attempt}` },
93
+ snapshot,
94
+ events: async () => null,
95
+ trace: async () => null,
96
+ o11y: async () => null,
97
+ agentSetup: async () => null,
98
+ diff: async () => null,
99
+ sources: async () => null,
100
+ }));
101
+ const evals = new Map<string, AttemptHandle[]>();
102
+ for (const attempt of attempts) evals.set(attempt.evalId, [...(evals.get(attempt.evalId) ?? []), attempt]);
103
+ snapshot.evals = [...evals.entries()].map(([id, list]) => ({ id, attempts: list }));
104
+ snapshot.attempts = attempts;
105
+ return snapshot;
106
+ }
201
107
 
202
- it("缺成本:两面都显示无数据文案,不编 $0", () => {
203
- const missingCost: GroupSummaryData = { ...groupSummaryData, totalCostUSD: null };
204
- const h = renderToStaticMarkup(<GroupSummary data={missingCost} />);
205
- const t = text(<GroupSummary data={missingCost} />);
206
- for (const face of [h, t]) {
207
- expect(face).toContain("no data");
208
- expect(face).not.toContain("$0");
209
- }
210
- });
108
+ function scopeOf(snapshots: Snapshot[], warnings: ScopeWarning[] = []): Scope {
109
+ return makeScope("current-evals", snapshots, warnings);
110
+ }
211
111
 
212
- it("零失败:web 面整个 errored 片段不渲染,text 面也没有 errored 分段;failed 仍如实显示 0(不是省略整个字段)", () => {
213
- const allPassed: GroupSummaryData = {
214
- ...groupSummaryData,
215
- verdicts: { passed: 6, failed: 0, errored: 0, skipped: 0 },
216
- passRate: { value: 1, display: "100%", samples: 6, total: 6, refs: [] },
112
+ function resultsOf(snapshots: Snapshot[]): Results {
113
+ const byId = new Map<string, Snapshot[]>();
114
+ for (const s of snapshots) byId.set(s.experimentId, [...(byId.get(s.experimentId) ?? []), s]);
115
+ const experiments = [...byId.entries()].map(([id, snaps]) => {
116
+ const sorted = [...snaps].sort((a, b) => b.startedAt.localeCompare(a.startedAt));
117
+ return {
118
+ id,
119
+ snapshots: sorted,
120
+ latest: sorted[0]!,
121
+ evalIds: [...new Set(sorted.flatMap((s) => s.evals.map((e) => e.id)))].sort(),
217
122
  };
218
- const h = renderToStaticMarkup(<GroupSummary data={allPassed} />);
219
- const t = text(<GroupSummary data={allPassed} />);
220
- expect(h).not.toContain("nre-verdict-errored");
221
- expect(h).not.toContain(">errored<");
222
- expect(t).not.toContain("errored");
223
- // failed 计数依旧渲染,哪怕是 0 —— 数据字段本身从不省略,只是 errored 的展示片段在 0 时省略
224
- expect(h).toContain(">failed<");
225
- expect(t).toContain("failed 0");
226
- });
227
-
228
- it("存在错误:两面都显示 errored 计数(与 groupSummaryData 基础 fixture 的 errored:1 一致)", () => {
229
- expect(html).toContain("nre-verdict-errored");
230
- expect(html).toContain(">errored<");
231
- expect(term).toContain("errored 1");
232
- });
233
-
234
- it("zh-CN locale:web 面走中文字典(端到端成功率/失败/错误/总成本/实验数),text 面同理;display 数字不本地化", () => {
235
- const zhHtml = renderToStaticMarkup(<GroupSummary data={groupSummaryData} locale="zh-CN" />);
236
- expect(zhHtml).toContain("端到端成功率");
237
- expect(zhHtml).toContain("失败");
238
- expect(zhHtml).toContain("错误");
239
- expect(zhHtml).toContain("总成本");
240
- expect(zhHtml).toContain("实验数");
241
- expect(zhHtml).toContain("60%"); // display 本身不随 locale 变
242
-
243
- const zhCtx = createTextContext({ width: 80, locale: "zh-CN" });
244
- const zhTerm = renderNodeToText(<GroupSummary data={groupSummaryData} />, zhCtx);
245
- expect(zhTerm).toContain("端到端成功率");
246
- expect(zhTerm).toContain("失败 1");
247
- expect(zhTerm).toContain("错误 1");
248
- expect(zhTerm).toContain("60%");
249
- });
250
- });
251
-
252
- describe("MetricTable 双面", () => {
253
- const html = renderToStaticMarkup(<MetricTable data={tableData} />);
254
- const term = text(<MetricTable data={tableData} />);
255
-
256
- it("text 面形态:对齐列 + 覆盖率角标 + 缺数据 —", () => {
257
- expect(term).toMatchInlineSnapshot(`
258
- "agent pass rate code lines
259
- codex 50% —
260
- bub 87% 120 lines 5/6"
261
- `);
262
123
  });
124
+ return {
125
+ experiments,
126
+ skipped: [],
127
+ latest: () => makeScope("latest-snapshots", experiments.map((e) => e.latest), []),
128
+ current: () => makeScope("current-evals", experiments.map((e) => e.latest), []),
129
+ } as unknown as Results;
130
+ }
263
131
 
264
- it("两面同口径:行序一致(预排即终排)、角标一致、缺数据不补 0", () => {
265
- // 行序:codex bub 前(数据侧顺序,两面都不重排)
266
- expect(html.indexOf(">codex<")).toBeLessThan(html.indexOf(">bub<"));
267
- expect(term.indexOf("codex")).toBeLessThan(term.indexOf("bub"));
268
- // 覆盖率角标 5/6 两面都在
269
- expect(html).toContain("5/6");
270
- expect(term).toContain("5/6");
271
- // null 格子:web 是 no data 文案,text 是 —,都绝不画 0
272
- expect(html).toContain("no data");
273
- expect(term).toContain("—");
274
- });
275
- });
276
-
277
- describe("MetricMatrix 双面", () => {
278
- const html = renderToStaticMarkup(<MetricMatrix data={matrixData} />);
279
- const term = text(<MetricMatrix data={matrixData} />);
280
-
281
- it("text 面形态:稀疏格子 — + 下钻命令指向最值得看的一行", () => {
282
- expect(term).toMatchInlineSnapshot(`
283
- "eval bub codex
284
- algebra/quadratic 100% 0%
285
- geometry/angles 50% —
286
-
287
- next: niceeval show geometry/angles"
288
- `);
289
- });
132
+ /** 管线便捷入口:resolve + validate + text 渲染,报告声明用最小 meta。 */
133
+ async function renderTreeText(node: ReportNode, scope: Scope, width = 100): Promise<string> {
134
+ const definition = defineReport(node);
135
+ const resolved = await resolveReportTree(pickReportPage(definition).content, {
136
+ scope,
137
+ results: resultsOf(scope.snapshots),
138
+ report: buildReportMeta(definition, scope, "report"),
139
+ memo: new ResolveMemo(),
140
+ });
141
+ validateReportTree(resolved);
142
+ return renderNodeToText(resolved, createTextContext({ width }));
143
+ }
290
144
 
291
- it("两面同口径:数字一致,稀疏格子不编数", () => {
292
- for (const value of ["100%", "0%", "50%"]) {
293
- expect(html).toContain(value);
294
- expect(term).toContain(value);
295
- }
296
- expect(html.match(/nre-td-empty/g)).toHaveLength(1); // web 空格子
297
- });
298
- });
145
+ // ───────────────────────── spec / data 双形态 ─────────────────────────
299
146
 
300
- describe("MetricBars 双面", () => {
301
- const html = renderToStaticMarkup(<MetricBars data={matrixData} />);
302
- const term = text(<MetricBars data={matrixData} />);
303
-
304
- it("text 面形态:一组条 = 一个 row 键,条长即刻度,缺数据 —", () => {
305
- expect(term).toMatchInlineSnapshot(`
306
- "algebra/quadratic
307
- bub ████████████████████ 100%
308
- codex ░░░░░░░░░░░░░░░░░░░░ 0%
309
- geometry/angles
310
- bub ██████████░░░░░░░░░░ 50%
311
- codex —"
312
- `);
313
- });
147
+ describe("spec 形态与 data 形态", () => {
148
+ const scatterScope = () =>
149
+ scopeOf([
150
+ snap({
151
+ experimentId: "cmp/a",
152
+ agent: "bub",
153
+ results: [res("q", "passed", { usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.2 } })],
154
+ }),
155
+ snap({
156
+ experimentId: "cmp/b",
157
+ agent: "codex",
158
+ results: [res("q", "failed", { usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.1 } })],
159
+ }),
160
+ ]);
314
161
 
315
- it("两面同口径:组内按值排序(better higher 降序),缺数据不出柱", () => {
316
- expect(html).toContain("100%");
317
- expect(html).toContain("50%");
318
- // web 面:codex geometry/angles 组没有柱(缺数据),柱数 = 3
319
- expect(html.match(/class="nre-bar /g)).toHaveLength(3);
320
- // text 面:codex 行是 —
321
- expect(term).toContain("codex —");
162
+ it("spec 形态与「先手工调 *Data 再传 data」严格等价:两棵树渲染深等", async () => {
163
+ const scope = scatterScope();
164
+ const options = { points: "experiment", series: "agent", x: costUSD, y: endToEndPassRate } as const;
165
+ const specText = await renderTreeText(
166
+ <MetricScatter points="experiment" series="agent" x={costUSD} y={endToEndPassRate} />,
167
+ scope,
168
+ );
169
+ const data = await metricScatterData(scope, options);
170
+ const dataText = await renderTreeText(<MetricScatter data={data} />, scope);
171
+ expect(specText).toBe(dataText);
172
+ });
173
+
174
+ it("同一组件同时给 data 与 spec 字段报完整用户反馈,不静默取一边", async () => {
175
+ const scope = scatterScope();
176
+ const data = await metricScatterData(scope, { points: "experiment", x: costUSD, y: endToEndPassRate });
177
+ await expect(
178
+ renderTreeText(
179
+ // @ts-expect-error data 与 spec 字段互斥,类型层已拒绝;这里模拟无类型 JS 输入
180
+ <MetricScatter data={data} points="experiment" x={costUSD} y={endToEndPassRate} />,
181
+ scope,
182
+ ),
183
+ ).rejects.toThrow(/both `data` and spec/);
184
+ });
185
+
186
+ it("input 省略时取宿主注入的 Scope;显式 input 覆盖数据来源", async () => {
187
+ const a = snap({ experimentId: "in/a", results: [res("q", "passed")] });
188
+ const b = snap({ experimentId: "in/b", results: [res("q", "failed")] });
189
+ const scope = scopeOf([a, b]);
190
+ const all = await renderTreeText(<ScopeSummary />, scope);
191
+ expect(all).toContain("2 experiments");
192
+ const narrowed = await renderTreeText(
193
+ <ScopeSummary input={scope.filter((s) => s.experimentId === "in/a")} />,
194
+ scope,
195
+ );
196
+ expect(narrowed).toContain("1 experiment ·");
197
+ // input 也可以是手挑的 Snapshot[](按快照出行)
198
+ const snapshotsOnly = await renderTreeText(
199
+ <MetricTable input={[a]} rows="snapshot" columns={[endToEndPassRate]} />,
200
+ scope,
201
+ 140,
202
+ );
203
+ expect(snapshotsOnly).toContain("in/a @");
204
+ expect(snapshotsOnly).not.toContain("in/b");
322
205
  });
323
- });
324
206
 
325
- describe("Scoreboard 双面", () => {
326
- const html = renderToStaticMarkup(<Scoreboard data={scoreboardData} />);
327
- const term = text(<Scoreboard data={scoreboardData} />);
328
-
329
- it("text 面形态:总分/满分 + 分科 + missing 注脚 + 权重表", () => {
330
- expect(term).toMatchInlineSnapshot(`
331
- "agent total algebra geometry
332
- bub 78.5/100 14/16 (1 missing) 3/4
333
- codex 52/100 9/16 1.4/4 (2 missing)
334
- weights: algebra/ ×2 · others ×1"
335
- `);
336
- });
207
+ it("data 结构校验:字段改名前的旧 JSON 报错且文案含版本漂移提示;round-trip 的同版本 JSON 照常渲染", async () => {
208
+ const scope = scatterScope();
209
+ const table = { dimension: "agent", columns: [], rows: [] }; // 旧形状:dimension 而非 rowDimension
210
+ await expect(renderTreeText(<MetricTable data={table as never} />, scope)).rejects.toThrow(
211
+ /does not match the current TableData shape[\s\S]*different niceeval version/,
212
+ );
337
213
 
338
- it("两面同口径:missing 与权重都如实", () => {
339
- expect(html).toContain("1 eval missing, scored 0");
340
- expect(term).toContain("(1 missing)");
341
- expect(html).toContain("algebra/ ×2");
342
- expect(term).toContain("algebra/ ×2");
214
+ const fresh = await metricScatterData(scope, { points: "experiment", x: costUSD, y: endToEndPassRate });
215
+ const roundTrip = JSON.parse(JSON.stringify(fresh));
216
+ const html = renderToStaticMarkup(<MetricScatter data={roundTrip} />);
217
+ expect(html).toContain("nre-metric-scatter");
343
218
  });
344
219
 
345
- it("一整科完全没跑(不是 missing>0 的部分缺):text 面该格渲染 —,证明走的是 renderTableText 的 null 处理,不是 scoreboardText 自己拼字符串", () => {
346
- const oneSubjectMissing: typeof scoreboardData = {
347
- ...scoreboardData,
348
- rows: [
349
- scoreboardData.rows[0]!,
350
- { key: "solo", total: { value: 14, display: "14" }, subjects: [{ key: "algebra", earned: 14, possible: 16, evals: 8, missing: 0 }] },
351
- ],
220
+ it("text 面散点轴方向跟随 better:成本轴反向(便宜在右)、提示恒为右上;x better 时整图无提示", async () => {
221
+ // cmp/a:cost $0.20、通过;cmp/b:cost $0.10、失败。cost better:"lower" → 轴反向,
222
+ // 便宜的 cmp/b(标记 B)落在贵的 cmp/a(标记 A)右侧;刻度仍显示真实值(右端是更小的 $0.10)。
223
+ const scope = scatterScope();
224
+ const text = await renderTreeText(
225
+ <MetricScatter points="experiment" x={costUSD} y={endToEndPassRate} />,
226
+ scope,
227
+ );
228
+ const markCol = (mark: string): number => {
229
+ const line = text.split("\n").find((l) => l.includes(mark));
230
+ expect(line, `plot row with mark ${mark}`).toBeTruthy();
231
+ return line!.indexOf(mark);
232
+ };
233
+ expect(markCol("B")).toBeGreaterThan(markCol("A"));
234
+ expect(text).toContain("better → upper right");
235
+ expect(text).toContain("$0.10");
236
+ expect(text).toContain("$0.20");
237
+
238
+ // x 未声明 better:轴正向、整图无方向提示(组件不猜「更好」朝哪边)。
239
+ const rawCost = defineMetric({
240
+ name: "rawCost",
241
+ unit: "$",
242
+ value: (attempt) => attempt.result.usage?.costUSD ?? null,
243
+ });
244
+ const noHint = await renderTreeText(
245
+ <MetricScatter points="experiment" x={rawCost} y={endToEndPassRate} />,
246
+ scope,
247
+ );
248
+ expect(noHint).not.toContain("better →");
249
+ // 正向轴:贵的 cmp/a(A)在右。
250
+ const noHintCol = (mark: string): number => {
251
+ const line = noHint.split("\n").find((l) => l.includes(mark));
252
+ expect(line, `plot row with mark ${mark}`).toBeTruthy();
253
+ return line!.indexOf(mark);
352
254
  };
353
- const soloTerm = text(<Scoreboard data={oneSubjectMissing} />);
354
- const [, , soloLine] = soloTerm.split("\n");
355
- expect(soloLine).toContain("—"); // geometry 列:solo 完全没有这一科,cells.subject1 = null → renderTableText 补 —
356
- expect(soloLine).not.toContain("0/4"); // 不补 0,与 <Table> null 契约一致
255
+ expect(noHintCol("A")).toBeGreaterThan(noHintCol("B"));
357
256
  });
358
257
  });
359
258
 
360
- describe("MetricScatter 双面", () => {
361
- const html = renderToStaticMarkup(<MetricScatter data={scatterData} />);
362
- const term = text(<MetricScatter data={scatterData} />);
363
-
364
- it("text 面形态:字符坐标图 + 图例 + 缺数据注脚", () => {
365
- expect(term).toMatchInlineSnapshot(`
366
- " pass rate ↑
367
- 90%│B····
368
- │ ·········
369
- │ ··········
370
- │ ·········
371
- │ ·········
372
- │ ·········
373
- │ C ··········
374
- │ ·········
375
- 50%│ ····A
376
- └───────────────────────────────────────────────────────────────────────────→ cost (axis reversed: right = better)
377
- $10.00 $5.00
378
-
379
- better → upper right
380
- A compare/bub-low B compare/bub-high C compare/codex-mid
381
- 1 point missing data"
382
- `);
383
- });
384
-
385
- it("两面同口径:x 轴反向(lower 好的一端在右)、缺数据点都不画、注脚同数", () => {
386
- // web:$5(便宜)在 $10 右边(cx 更大)——render.test.tsx 已细测,这里只对注脚
387
- expect(html).toContain("1 point missing data");
388
- expect(term).toContain("1 point missing data");
389
- expect(html).not.toContain('data-key="compare/codex-broken"'); // 注脚 title 如实列缺数据的点,但不画
390
- // text 图例里也没有缺数据的点
391
- expect(term).not.toContain("codex-broken");
392
- });
259
+ // ───────────────────────── resolve 记忆化 ─────────────────────────
393
260
 
394
- it("点太密时降级坐标表,不硬挤", () => {
395
- const narrow = renderNodeToText(<MetricScatter data={scatterData} />, createTextContext({ width: 40 }));
396
- expect(narrow).toContain("experiment");
397
- expect(narrow).toContain("$5.00");
398
- expect(narrow).not.toContain("");
399
- });
400
- });
261
+ describe("resolve 记忆化", () => {
262
+ it("Matrix Bars spec 时计算只发生一次;不同 spec 各自计算", async () => {
263
+ let calls = 0;
264
+ const counted = defineMetric({
265
+ name: "counted",
266
+ value: () => {
267
+ calls += 1;
268
+ return 1;
269
+ },
270
+ });
271
+ const scope = scopeOf([snap({ experimentId: "memo/a", results: [res("q", "passed")] })]);
272
+ await renderTreeText(
273
+ <Col>
274
+ <MetricMatrix rows="eval" columns="agent" cell={counted} />
275
+ <MetricBars rows="eval" columns="agent" cell={counted} />
276
+ </Col>,
277
+ scope,
278
+ );
279
+ expect(calls).toBe(1); // 一个 attempt,矩阵只算一遍
401
280
 
402
- describe("MetricLine 双面", () => {
403
- const html = renderToStaticMarkup(<MetricLine data={lineData} />);
404
- const term = text(<MetricLine data={lineData} />);
405
-
406
- it("text 面形态:同系列一个字母、沿 x 排布,y 轴刻度,图例 + 缺数据注脚", () => {
407
- expect(term).toMatchInlineSnapshot(`
408
- " pass rate ↑
409
- 80%│B··················
410
- │ ·····································
411
- │ ··················B
412
-
413
-
414
-
415
- │A··················
416
- │ ·····································
417
- 30%│ ··················A
418
- └───────────────────────────────────────────────────────────────────────────→ Simulated latency
419
- 100ms 300ms
420
-
421
- A 1 agents B 16 agents
422
- 1 point missing data"
423
- `);
281
+ calls = 0;
282
+ await renderTreeText(
283
+ <Col>
284
+ <MetricMatrix rows="eval" columns="agent" cell={counted} />
285
+ <MetricMatrix rows="eval" columns="model" cell={counted} />
286
+ </Col>,
287
+ scope,
288
+ );
289
+ expect(calls).toBe(2); // spec 不同(columns 维度不同):各自计算
424
290
  });
425
291
 
426
- it("两面同口径:未声明 flag 的点两面都不画、注脚同数", () => {
427
- expect(html).toContain("1 point missing data");
428
- expect(term).toContain("1 point missing data");
429
- expect(html).not.toContain('data-key="ultra/legacy"'); // 注脚 title 如实列缺数据的点,但不画
292
+ it("不同 input 各自计算;字段相同但实例不同的 Metric 各自计算、不报错", async () => {
293
+ let calls = 0;
294
+ const value = () => {
295
+ calls += 1;
296
+ return 1;
297
+ };
298
+ const m1 = defineMetric({ name: "twin", value });
299
+ const m2 = defineMetric({ name: "twin", value: (attempt) => value.call(null) }); // 引用不同的等价定义
300
+ const a = snap({ experimentId: "memo/in-a", results: [res("q", "passed")] });
301
+ const b = snap({ experimentId: "memo/in-b", results: [res("q", "passed")] });
302
+ const scope = scopeOf([a, b]);
303
+ await renderTreeText(
304
+ <Col>
305
+ <MetricMatrix input={[a]} rows="eval" columns="agent" cell={m1} />
306
+ <MetricMatrix input={[b]} rows="eval" columns="agent" cell={m1} />
307
+ <MetricMatrix input={[a]} rows="eval" columns="agent" cell={m2} />
308
+ </Col>,
309
+ scope,
310
+ );
311
+ expect(calls).toBe(3); // 两个 input 各一次 + 不同实例的 Metric 再一次
430
312
  });
431
313
  });
432
314
 
433
- describe("DeltaTable 双面", () => {
434
- const html = renderToStaticMarkup(<DeltaTable data={deltaData} />);
435
- const term = text(<DeltaTable data={deltaData} />);
315
+ // ───────────────────────── 组合组件与树形状 ─────────────────────────
436
316
 
437
- it("text 面形态:A → B + Δ,单侧缺数据是 —,Δ=0 是 ±0", () => {
438
- expect(term).toMatchInlineSnapshot(`
439
- "pair pass rate cost
440
- bub 50% 62% +12pp $0.20 $0.35 +$0.15
441
- codex 40% → 40% ±0 — → $0.30 —"
442
- `);
443
- });
444
-
445
- it("两面同口径:Δ 文案一致、缺数据都不硬算", () => {
446
- for (const piece of ["+12pp", "+$0.15", "±0"]) {
447
- expect(html).toContain(piece);
448
- expect(term).toContain(piece);
449
- }
450
- expect(html).toContain("nre-delta-missing");
451
- expect(term).toContain("— → $0.30");
317
+ describe("组合组件(函数形态)", () => {
318
+ it("resolve 阶段以 (props, ctx) 调用并递归展开;与手写等价树渲染相同;async 可用", async () => {
319
+ const scope = scopeOf([snap({ experimentId: "compose/a", results: [res("q", "passed")] })]);
320
+ const Composed = defineComponent(async (_props: Record<never, never>, ctx) => (
321
+ <Section title="wrapped">
322
+ <ScopeSummary input={ctx.scope} />
323
+ </Section>
324
+ ));
325
+ const composed = await renderTreeText(<Composed />, scope);
326
+ const manual = await renderTreeText(
327
+ <Section title="wrapped">
328
+ <ScopeSummary />
329
+ </Section>,
330
+ scope,
331
+ );
332
+ expect(composed).toBe(manual);
333
+ });
334
+
335
+ it("ctx.results 可自行挑 Snapshot[] 喂 input;ctx.report 携带走完回退链的 title 与当前 pageId", async () => {
336
+ const named = snap({ experimentId: "hist/a", name: "Memory Evals", results: [res("q", "passed")] });
337
+ const scope = scopeOf([named]);
338
+ const Meta = defineComponent((_props: Record<never, never>, ctx) => {
339
+ const history = ctx.results.experiments[0]!.snapshots;
340
+ return (
341
+ <Col>
342
+ <Text>{`title=${typeof ctx.report.title === "string" ? ctx.report.title : "?"} page=${ctx.report.pageId}`}</Text>
343
+ <ScopeSummary input={history} />
344
+ </Col>
345
+ );
346
+ });
347
+ const definition = defineReport({ pages: [{ id: "meta", title: "Meta", content: <Meta /> }] });
348
+ const text = await renderReportToText(definition, { scope, results: resultsOf([named]) }, { pageId: "meta" });
349
+ // 声明没给 title → 唯一快照 name 回退
350
+ expect(text).toContain("title=Memory Evals page=meta");
351
+ });
352
+
353
+ it("同层 sibling 并行取数且输出保持声明顺序:慢 resolve 在前不换位", async () => {
354
+ const order: string[] = [];
355
+ const Slow = defineComponent<{ label: string }, { label: string }>({
356
+ resolve: async (props) => {
357
+ await new Promise((r) => setTimeout(r, 25));
358
+ order.push("slow-resolved");
359
+ return props;
360
+ },
361
+ web: ({ label }) => <p>{label}</p>,
362
+ text: ({ label }) => label,
363
+ });
364
+ const Fast = defineComponent<{ label: string }, { label: string }>({
365
+ resolve: (props) => {
366
+ order.push("fast-resolved");
367
+ return props;
368
+ },
369
+ web: ({ label }) => <p>{label}</p>,
370
+ text: ({ label }) => label,
371
+ });
372
+ const scope = scopeOf([]);
373
+ const text = await renderTreeText(
374
+ <Col>
375
+ <Slow label="first" />
376
+ <Fast label="second" />
377
+ </Col>,
378
+ scope,
379
+ );
380
+ expect(order[0]).toBe("fast-resolved"); // 真并行:快的先完成
381
+ expect(text.indexOf("first")).toBeLessThan(text.indexOf("second")); // 输出仍按声明序
452
382
  });
453
383
  });
454
384
 
455
- describe("AttemptList 双面", () => {
456
- const html = renderToStaticMarkup(<AttemptList items={attemptListItems} />);
457
- const term = text(<AttemptList items={attemptListItems} />);
458
-
459
- it("两面同口径:判定符 + locator + 主失败/error 摘要一致", () => {
460
- for (const piece of ["roots-correct", "expected x=2", "received x=3", "TypeError", "@1a4a4a4", "@1c1c1c1"]) {
461
- expect(html).toContain(piece);
462
- expect(term).toContain(piece);
463
- }
464
- // 结构化 error 只显示一层摘要:stack / cause / diagnostics 属于 locator 下钻详情,
465
- // 随数据携带但不塞进比较列表(docs/feature/reports/library.md「AttemptList」)。
466
- for (const face of [html, term]) {
467
- expect(face).not.toContain("adapter.ts:42"); // error.stack
468
- expect(face).not.toContain("sandbox teardown timed out"); // diagnostics[].message
469
- }
470
- // web 面走证据室路由(#/attempt/@<locator>);text 面只列 locator 本身,不重复整条命令
471
- // (docs/feature/reports/architecture.md「text 输出只在整份报告末尾给一次命令模板」)。
472
- expect(html).toContain('href="#/attempt/@1a4a4a4"');
473
- expect(term).not.toMatch(/\[[EXD⏱,]+\]/);
474
- expect(html).not.toMatch(/\[[EXD⏱,]+\]/);
475
- });
476
-
477
- it("total > items.length 时两面都如实报剩余数量", () => {
478
- const htmlTrunc = renderToStaticMarkup(<AttemptList items={attemptListItems} total={attemptListItems.length + 2} />);
479
- const termTrunc = text(<AttemptList items={attemptListItems} total={attemptListItems.length + 2} />);
480
- expect(htmlTrunc).toContain("2 more not shown");
481
- expect(termTrunc).toContain("2 more not shown");
482
- });
483
-
484
- it("比较列表不内联 assertion evidence,并保留 locator 作为完整证据入口", () => {
485
- const longEvidence = "x".repeat(2_000);
486
- const item = {
487
- ...attemptListItems[0],
488
- assertions: [{ ...attemptListItems[0].assertions[0], evidence: longEvidence }],
489
- };
490
- const out = text(<AttemptList items={[item]} />);
491
- expect(out).toContain(item.locator);
492
- expect(out).not.toContain(longEvidence);
493
- expect(out.length).toBeLessThan(1_000);
494
- });
385
+ describe("ReportNode 形状与非法节点", () => {
386
+ const scope = () => scopeOf([snap({ experimentId: "node/a", results: [res("q", "passed")] })]);
495
387
 
496
- it("text 面每个 attempt 只显示一条有界结果摘要,不展开完整断言列表", () => {
497
- const blocks = term.split("\n\n");
498
- expect(blocks).toHaveLength(attemptListItems.length);
499
- const lines = term.split("\n");
500
- expect(lines.some((l) => /^ \S/.test(l))).toBe(true);
501
- expect(lines.some((l) => /^ \S/.test(l))).toBe(false);
502
- expect(term).not.toContain("strong-evidence");
388
+ it("数组 / Fragment 展平保序;null / undefined / boolean 渲染为空", async () => {
389
+ const groups = ["g1", "g2"];
390
+ const text = await renderTreeText(
391
+ <Col>
392
+ {groups.map((g) => (
393
+ <Section key={g} title={g}>
394
+ <Text>{`body of ${g}`}</Text>
395
+ </Section>
396
+ ))}
397
+ <>
398
+ <Text>in fragment</Text>
399
+ </>
400
+ {false && <Text>hidden</Text>}
401
+ {null}
402
+ </Col>,
403
+ scope(),
404
+ );
405
+ expect(text.indexOf("g1")).toBeLessThan(text.indexOf("g2"));
406
+ expect(text).toContain("in fragment");
407
+ expect(text).not.toContain("hidden");
503
408
  });
504
- });
505
409
 
506
- describe("EvalList 双面", () => {
507
- const html = renderToStaticMarkup(<EvalList items={evalListItems} />);
508
- const term = text(<EvalList items={evalListItems} />);
509
-
510
- it("两面同口径:身份、判定、展开到 Attempt 的 locator 徽标一致", () => {
511
- for (const piece of ["algebra/quadratic", "compare/bub", "geometry/angles", "compare/codex", "@1a4a4a4", "@1c1c1c1"]) {
512
- expect(html).toContain(piece);
513
- expect(term).toContain(piece);
514
- }
410
+ it("裸字符串在树校验时按完整用户反馈拒绝并指引包 <Text>", async () => {
411
+ await expect(renderTreeText(<Col>{"free text" as unknown as ReportNode}</Col>, scope())).rejects.toThrow(
412
+ /bare string[\s\S]*<Text>/,
413
+ );
515
414
  });
516
415
 
517
- it("text 面的 score 行插值正确:不留字面量占位符 {score}(回归——曾把带插值的 locale 模板当纯标签用,漏传 vars)", () => {
518
- expect(term).toContain("score 0%");
519
- expect(term).not.toContain("{score}");
416
+ it("React 组件 / 未包装函数与 HTML intrinsic 在展开遇到时拒绝", async () => {
417
+ const Plain = ({ label }: { label: string }) => <p>{label}</p>;
418
+ await expect(renderTreeText(<Plain label="x" />, scope())).rejects.toThrow(
419
+ /not a report component[\s\S]*defineComponent/,
420
+ );
421
+ await expect(renderTreeText(<div>x</div>, scope())).rejects.toThrow(/raw HTML <div>/);
520
422
  });
521
423
 
522
- it("text 面是逐条卡片,不是 renderTableText 的产物:空行分隔每个 eval、展开到 attempt 的行缩进——表格没有这层嵌套", () => {
523
- const blocks = term.split("\n\n");
524
- expect(blocks).toHaveLength(evalListItems.length);
525
- expect(term.split("\n").some((l) => /^ \S/.test(l))).toBe(true); // 展开到 attempt 的行缩进两格
424
+ it("validateReportTree 拒绝缺任一渲染面的组件(无类型 JS 绕过 defineComponent 时)", () => {
425
+ const single = Object.assign(() => null, {
426
+ [Symbol.for("niceeval.report.faces")]: { web: () => null }, // 缺 text 面
427
+ });
428
+ expect(() => validateReportTree({ type: single, props: {} })).toThrow(/missing its text face/);
429
+ expect(() => validateReportTree({ type: "div", props: {} })).toThrow(/raw HTML <div>/);
526
430
  });
527
431
 
528
- // bug: memory/eval-parent-repeats-attempt-failure.md
529
- it("Eval 父行不复述 Attempt 失败摘要,失败题仍显示题级平均值", () => {
530
- const reason = "roots-correct · expected x=2 · received x=3";
531
- for (const face of [html, term]) {
532
- expect(face.split(reason)).toHaveLength(2);
533
- expect(face).toContain("32.0s avg");
534
- expect(face).toContain("$0.12 avg");
535
- }
432
+ it("defineComponent 对象形态缺 text 或 web 在定义时报完整用户反馈", () => {
433
+ expect(() => defineComponent({ web: () => null } as never)).toThrow(/both faces/);
536
434
  });
537
435
  });
538
436
 
539
- describe("ExperimentList 双面", () => {
540
- const html = renderToStaticMarkup(<ExperimentList items={experimentListItems} />);
541
- const term = text(<ExperimentList items={experimentListItems} />);
542
-
543
- it("两面同口径:身份、Eval 判定构成、展开到 Eval 的 locator 徽标一致", () => {
544
- for (const piece of ["compare/bub", "compare/codex", "algebra/quadratic", "geometry/angles", "@1a4a4a4", "@1c1c1c1"]) {
545
- expect(html).toContain(piece);
546
- expect(term).toContain(piece);
547
- }
548
- // 官方两级聚合 endToEndPassRate.display 两面同一个数字,不各自重算
549
- expect(html).toContain("50%");
550
- expect(term).toContain("50%");
551
- });
552
-
553
- it("text 面先给比较表,再按 experiment 给 Eval / Attempt 父子表", () => {
554
- const blocks = term.split("\n\n");
555
- expect(blocks).toHaveLength(experimentListItems.length + 1);
556
- expect(blocks[0]).toContain("Model");
557
- expect(blocks[0]).toContain("Avg");
558
- expect(blocks[1]!.match(/algebra\/quadratic/g)).toHaveLength(1);
559
- expect(blocks[1]).toMatch(/✗ failed\s+algebra\/quadratic[\s\S]*├─ @1a4a4a4[\s\S]*└─ @1b5b5b5/);
560
- });
561
-
562
- // bug: memory/eval-parent-repeats-attempt-failure.md
563
- it("web 的 Eval 父行使用固定题级汇总,失败摘要只在 Attempt 子行出现", () => {
564
- const reason = "roots-correct · expected x=2 · received x=3";
565
- expect(html.split(reason)).toHaveLength(3); // 两个 failed Attempt,各显示一次
566
- expect(html).toContain('<span class="nre-eval-rollup">32.0s avg · $0.12 avg</span>');
567
- expect(term).toMatch(/✗ failed\s+algebra\/quadratic\s+32\.0s avg\s+\$0\.12 avg/);
568
- });
437
+ // ───────────────────────── 双面同源 ─────────────────────────
569
438
 
570
- it("Result 是两行收口的预览:received 携带整份多行源码时既不逐行铺表也不无限折行", () => {
571
- const fatAttempt = {
572
- ...experimentListItems[0]!.evalRows[0]!.attempts[0]!,
573
- assertions: [{
574
- name: "includes(/['\"]use cache['\"];?/)",
575
- groupPath: ["Catalog reads use use-cache directive and products cache tag"],
576
- severity: "gate" as const,
577
- score: 0,
578
- outcome: "failed" as const,
579
- expected: "matches /['\"]use cache['\"];?/",
580
- received: `\n// next.config.ts\n${"import type { NextConfig } from 'next';\n\n".repeat(50)}`,
581
- }],
439
+ describe("text/web 双面同源", () => {
440
+ it("双面显示同一份解析终值、覆盖率与 warning;宿主在报告树外统一显示 warning", async () => {
441
+ const s = snap({ experimentId: "dual/a", results: [res("q1", "passed"), res("q2", "skipped")] });
442
+ const warning: ScopeWarning = {
443
+ kind: "unfinished-snapshot",
444
+ experimentId: "dual/a",
445
+ startedAt: s.startedAt,
446
+ dir: s.dir,
447
+ message: "snapshot is incomplete",
448
+ command: "niceeval exp dual/a",
582
449
  };
583
- const fatItems = [{
584
- ...experimentListItems[0]!,
585
- evalRows: [{
586
- ...experimentListItems[0]!.evalRows[0]!,
587
- attempts: [fatAttempt],
588
- }],
589
- }];
590
- const detail = renderNodeToText(<ExperimentList items={fatItems} />, createTextContext({ width: 100 }))
591
- .split("\n\n")
592
- .at(-1)!;
593
- const lines = detail.split("\n");
594
- // 表头 1 + eval 父行 1 + attempt 子行 ≤2(Result maxLines: 2),外加 experimentId 头行
595
- expect(lines.length).toBeLessThanOrEqual(6);
596
- expect(detail).toContain("…");
597
- expect(lines.every((l) => l.trim().length > 0)).toBe(true); // 值里的空行不进表
598
- // 优先级让位:语义标题先截(全称不再出现),事实字段(expected/received)最后截
599
- expect(detail).not.toContain("Catalog reads use use-cache directive and products cache tag");
600
- expect(detail).toContain("expected");
450
+ const scope = scopeOf([s], [warning]);
451
+ const definition = defineReport(<ScopeSummary />);
452
+ const ctx = { scope, results: resultsOf([s]) };
453
+ const text = await renderReportToText(definition, ctx);
454
+ const html = await renderReportToStaticHtml(definition, ctx);
455
+ for (const face of [text, html]) {
456
+ expect(face).toContain("100%"); // endToEndPassRate:skipped 不稀释
457
+ expect(face).toContain("1/2"); // samples/total 覆盖率
458
+ expect(face).toContain("snapshot is incomplete");
459
+ }
460
+ // web 面把警告的 command 渲染为可复制命令
461
+ expect(html).toContain("niceeval exp dual/a");
462
+ expect(html).toContain("nre-warning-command");
463
+ });
464
+
465
+ it("web 排序/过滤只改变浏览状态:有无 filter prop 数值与行集合相同", async () => {
466
+ const scope = scopeOf([snap({ experimentId: "f/a", results: [res("q", "passed")] })]);
467
+ const definition = (filter: boolean) =>
468
+ defineReport(<MetricTable rows="experiment" columns={[endToEndPassRate]} filter={filter} />);
469
+ const ctx = { scope, results: resultsOf(scope.snapshots) };
470
+ const plain = await renderReportToStaticHtml(definition(false), ctx);
471
+ const filtered = await renderReportToStaticHtml(definition(true), ctx);
472
+ const values = (html: string) => html.match(/data-sort-value="[^"]*"/g);
473
+ expect(values(filtered)).toEqual(values(plain));
474
+ });
475
+
476
+ it("ExperimentList text 面保持实体层级:一题两 attempt 只出现一次 Eval 标题;失败摘要只在 Attempt 子行", async () => {
477
+ const s = snap({
478
+ experimentId: "grp/exp-a",
479
+ results: [
480
+ res("algebra/retry", "failed", {
481
+ attempt: 0,
482
+ assertions: [
483
+ {
484
+ name: "equals",
485
+ severity: "gate",
486
+ outcome: "failed",
487
+ score: 0,
488
+ detail: "equals(42)",
489
+ expected: "42",
490
+ received: "41",
491
+ },
492
+ ],
493
+ }),
494
+ res("algebra/retry", "passed", { attempt: 1 }),
495
+ ],
496
+ });
497
+ const text = await renderTreeText(<ExperimentList relativeTo="grp" />, scopeOf([s]), 160);
498
+ expect(text.match(/algebra\/retry/g)).toHaveLength(1); // Eval 父行只出现一次
499
+ expect(text).toContain("├─");
500
+ expect(text).toContain("└─");
501
+ // 失败摘要只在 Attempt 子行(父行不复述)
502
+ expect(text.match(/equals\(42\)/g)).toHaveLength(1);
503
+ // relativeTo:行标签只显示末段
504
+ expect(text).toContain("exp-a");
601
505
  });
602
506
  });
603
507
 
604
- // ───────────────────────── 排版原语 ─────────────────────────
605
-
606
- describe("排版原语", () => {
607
- it("Col 纵向堆叠;Section 标题 + 缩进;Text 折行;Style text 面为空", () => {
608
- const node = (
609
- <Col>
610
- <Section title="考试成绩单">
611
- <Text>algebra 权重 ×2,满分 100。</Text>
612
- </Section>
613
- <Style>{`.passbars i { background: #4a7; }`}</Style>
614
- </Col>
615
- );
616
- expect(text(node)).toMatchInlineSnapshot(`
617
- "考试成绩单
618
- algebra 权重 ×2,满分 100。"
619
- `);
620
- const html = renderToStaticMarkup(node);
621
- expect(html).toContain("<h2 class=\"nre-section-title\">考试成绩单</h2>");
622
- expect(html).toContain("<style>.passbars i { background: #4a7; }</style>");
623
- });
624
-
625
- it("Row:宽度够时字符分栏,│ 分隔,各栏折到自己的宽度", () => {
626
- const node = (
627
- <Row>
628
- <Text>left column body</Text>
629
- <Text>right column body</Text>
630
- </Row>
631
- );
632
- const wide = renderNodeToText(node, createTextContext({ width: 60 }));
633
- expect(wide).toContain("");
634
- expect(wide.split("\n")[0]).toContain("left column body");
635
- expect(wide.split("\n")[0]).toContain("right column body");
636
- });
637
-
638
- it("Row:宽度不足降级纵向,不硬挤", () => {
639
- const node = (
640
- <Row>
641
- <Text>left column body</Text>
642
- <Text>right column body</Text>
643
- </Row>
644
- );
645
- const narrow = renderNodeToText(node, createTextContext({ width: 30 }));
646
- expect(narrow).not.toContain("│");
647
- expect(narrow).toContain("left column body\n\nright column body");
508
+ // ───────────────────────── FailureList ─────────────────────────
509
+
510
+ describe("FailureList", () => {
511
+ it("与手写组合严格等价:failed/errored、开始时间降序、limit 截断且 total 报截断前总数", async () => {
512
+ const s = snap({
513
+ experimentId: "fail/a",
514
+ results: [
515
+ res("q1", "failed", { startedAt: "2026-07-01T01:00:00.000Z" }),
516
+ res("q2", "errored", {
517
+ startedAt: "2026-07-01T03:00:00.000Z",
518
+ error: { code: "x", message: "boom", phase: "eval.run" },
519
+ }),
520
+ res("q3", "failed", { startedAt: "2026-07-01T02:00:00.000Z" }),
521
+ res("q4", "passed", { startedAt: "2026-07-01T04:00:00.000Z" }),
522
+ ],
523
+ });
524
+ const scope = scopeOf([s]);
525
+ const failureText = await renderTreeText(<FailureList limit={2} />, scope);
526
+
527
+ // 手写组合:attemptListData → 过滤 → 排序 → AttemptList data 形态
528
+ const all = await attemptListData(scope);
529
+ const startedAt = new Map(s.attempts.map((a) => [a.evalId, a.result.startedAt ?? ""]));
530
+ const failures = all
531
+ .filter((x) => x.verdict === "failed" || x.verdict === "errored")
532
+ .sort((a, b) => (startedAt.get(b.evalId) ?? "").localeCompare(startedAt.get(a.evalId) ?? ""));
533
+ const manualText = await renderTreeText(<AttemptList data={failures.slice(0, 2)} total={failures.length} />, scope);
534
+ expect(failureText).toBe(manualText);
535
+ expect(failureText).toContain("(1 more not shown)"); // 3 条失败,截断到 2
536
+ // 最近的失败在前
537
+ expect(failureText.indexOf("q2")).toBeLessThan(failureText.indexOf("q3"));
538
+ expect(failureText).not.toContain("q4"); // passed 不进失败清单
539
+ });
540
+
541
+ it("失败数少于 limit 时 total 等于 data 长度,不产出截断文案", async () => {
542
+ const s = snap({ experimentId: "fail/few", results: [res("q1", "failed")] });
543
+ const text = await renderTreeText(<FailureList />, scopeOf([s]));
544
+ expect(text).not.toContain("more not shown");
648
545
  });
649
546
  });
650
547
 
651
- // ───────────────────────── Table(自定义表的标准件)─────────────────────────
652
- //
653
- // 官方的 MetricTable / MetricMatrix / Scoreboard / DeltaTable 的 text 面就建在它上面
654
- // (上面那几个 describe 的内联快照即它的输出);这里验收作者直接用它时的四条契约:
655
- // 显示宽度对齐(中文不撕歪)、右对齐、缺数据 —、超宽折行。
656
-
657
- /** 某个片段的右边缘落在第几显示列 —— 右对齐验收看的就是它。 */
658
- function displayEndOf(line: string, piece: string): number {
659
- const at = line.indexOf(piece);
660
- expect(at).toBeGreaterThanOrEqual(0);
661
- return stringWidth(line.slice(0, at + piece.length));
662
- }
548
+ // ───────────────────────── Table 与排版原语 ─────────────────────────
663
549
 
664
- describe("Table 双面", () => {
665
- const cjkTable = (
666
- <Table
667
- columns={[
668
- { key: "eval", header: "题目" },
669
- { key: "pass", header: "通过率", align: "right" },
670
- { key: "cost", header: "成本", align: "right" },
671
- ]}
672
- rows={[
673
- {
674
- key: "记忆/写缓存",
675
- locator: "@160iuj3h" as AttemptLocator,
676
- cells: { eval: "记忆/写缓存", pass: "87%", cost: "$0.09" },
677
- },
678
- {
679
- key: "浏览/表单填写",
680
- locator: "@1qrdcfq8" as AttemptLocator,
681
- cells: { eval: "浏览/表单填写", pass: null, cost: null },
682
- },
683
- ]}
684
- />
685
- );
686
-
687
- it("中文列宽:按显示宽度对齐(CJK 记 2 列),不是 .length —— String.padEnd 撕歪表的那一步", () => {
688
- const term = text(cjkTable);
689
- expect(term).toMatchInlineSnapshot(`
690
- "题目 通过率 成本 attempt
691
- 记忆/写缓存 87% $0.09 @160iuj3h
692
- 浏览/表单填写 — — @1qrdcfq8"
693
- `);
694
-
695
- const [header, first, second] = term.split("\n");
696
- // 每列的右边缘落在同一显示列:中文格子多一倍显示宽度,补齐必须按显示宽度算
697
- expect(displayEndOf(first, "87%")).toBe(displayEndOf(header, "通过率"));
698
- expect(displayEndOf(second, "—")).toBe(displayEndOf(header, "通过率"));
699
- expect(displayEndOf(first, "$0.09")).toBe(displayEndOf(header, "成本"));
700
- expect(displayEndOf(first, "@160iuj3h")).toBe(displayEndOf(second, "@1qrdcfq8"));
701
- // 上面那几条不是巧合:UTF-16 码元数与显示宽度在中文上就是不同的两个数,
702
- // 拿 .length 补齐(或 String.prototype.padEnd)必然歪
703
- expect("记忆/写缓存".length).toBe(6);
704
- expect(stringWidth("记忆/写缓存")).toBe(11);
705
- });
550
+ describe("Table 与文本排版原语", () => {
551
+ const ctx = () => createTextContext({ width: 100 });
706
552
 
707
- it("align: \"right\":长短不一的数字右边缘对齐;默认列仍是左对齐", () => {
708
- const term = text(
553
+ it("null 单元格与 cells 缺键都渲染成 —,不补 0", () => {
554
+ const text = renderNodeToText(
709
555
  <Table
710
556
  columns={[
711
- { key: "agent", header: "agent" },
712
- { key: "cost", header: "cost", align: "right" },
557
+ { key: "a", header: "A" },
558
+ { key: "b", header: "B", align: "right" },
713
559
  ]}
714
560
  rows={[
715
- { key: "bub", cells: { agent: "bub", cost: "$9.00" } },
716
- { key: "codex", cells: { agent: "codex", cost: "$123.45" } },
561
+ { key: "r1", cells: { a: "x", b: null } },
562
+ { key: "r2", cells: { a: "y" } },
717
563
  ]}
718
564
  />,
565
+ ctx(),
719
566
  );
720
- expect(term).toMatchInlineSnapshot(`
721
- "agent cost
722
- bub $9.00
723
- codex $123.45"
724
- `);
725
- const [, first, second] = term.split("\n");
726
- expect(displayEndOf(first, "$9.00")).toBe(displayEndOf(second, "$123.45"));
727
- // 左对齐列的左边缘对齐(不传 align 就是 left)
728
- expect(first.startsWith("bub")).toBe(true);
729
- expect(second.startsWith("codex")).toBe(true);
567
+ expect(text.match(/—/g)!.length).toBe(2);
568
+ expect(text).not.toMatch(/\b0\b/);
730
569
  });
731
570
 
732
- it("null —,不补 0;cells 里干脆缺这个键也是 —;两面同源", () => {
733
- const node = (
571
+ it("cells 出现未声明的 key 以完整用户反馈报错;列 key 重复报错", () => {
572
+ expect(() =>
573
+ renderNodeToText(
574
+ <Table columns={[{ key: "a", header: "A" }]} rows={[{ key: "r", cells: { ghost: "x" } }]} />,
575
+ ctx(),
576
+ ),
577
+ ).toThrow(/no column declares/);
578
+ expect(() =>
579
+ renderNodeToText(
580
+ <Table
581
+ columns={
582
+ [
583
+ { key: "a", header: "A" },
584
+ { key: "a", header: "A2" },
585
+ ] as never
586
+ }
587
+ rows={[]}
588
+ />,
589
+ ctx(),
590
+ ),
591
+ ).toThrow(/declared twice/);
592
+ });
593
+
594
+ it("带 locator 的行多出 attempt 列;全部无 locator 时不出该列", () => {
595
+ const withLocator = renderNodeToText(
734
596
  <Table
735
- columns={[
736
- { key: "agent", header: "agent" },
737
- { key: "pass", header: "pass", align: "right" },
738
- { key: "cost", header: "cost", align: "right" },
739
- ]}
597
+ columns={[{ key: "a", header: "A" }]}
740
598
  rows={[
741
- { key: "bub", cells: { agent: "bub", pass: "87%", cost: "$0.42" } },
742
- { key: "claude", cells: { agent: "claude", pass: null } }, // cost 键根本不在
599
+ { key: "r1", cells: { a: "x" }, locator: "@1abc0def" as never },
600
+ { key: "r2", cells: { a: "y" } },
743
601
  ]}
744
- />
602
+ />,
603
+ ctx(),
745
604
  );
746
- const term = text(node);
747
- const html = renderToStaticMarkup(node);
748
- expect(term).toMatchInlineSnapshot(`
749
- "agent pass cost
750
- bub 87% $0.42
751
- claude — —"
752
- `);
753
- for (const face of [term, html]) {
754
- expect(face).toContain("—");
755
- expect(face).not.toContain("$0.00");
756
- expect(face).not.toContain("0%");
757
- }
758
- expect(html).toContain('<td class="nre-align-right"><span class="nre-missing">—</span></td>');
759
- });
760
-
761
- it("超宽:先折最宽的左对齐列(数字列不折),压到下限仍放不下就丢列并如实报数", () => {
762
- const wide = (
763
- <Table
764
- columns={[
765
- { key: "eval", header: "eval" },
766
- { key: "reason", header: "reason" },
767
- { key: "cost", header: "cost", align: "right" },
768
- ]}
769
- rows={[
770
- {
771
- key: "weather/brooklyn",
772
- cells: {
773
- eval: "weather/brooklyn",
774
- reason: "gate calledTool(\"get_weather\") — the tool was never called by the agent",
775
- cost: "$0.04",
776
- },
777
- },
778
- ]}
779
- />
605
+ expect(withLocator).toContain("@1abc0def");
606
+ expect(withLocator).toContain("attempt");
607
+ const without = renderNodeToText(
608
+ <Table columns={[{ key: "a", header: "A" }]} rows={[{ key: "r1", cells: { a: "x" } }]} />,
609
+ ctx(),
780
610
  );
781
- const narrow = renderNodeToText(wide, createTextContext({ width: 48 }));
782
- // 折行:一条逻辑行铺成多条物理行,没有一行溢出 48 列
783
- const lines = narrow.split("\n");
784
- expect(lines.length).toBeGreaterThan(2);
785
- for (const line of lines) expect(stringWidth(line)).toBeLessThanOrEqual(48);
786
- // 数字列没被折:$0.04 完整出现在某一行上
787
- expect(narrow).toContain("$0.04");
788
- expect(narrow).toContain("never called");
789
-
790
- // 压到下限还是放不下 → 从右侧丢列,如实报剩余列数(不静默截断)
791
- const tiny = renderNodeToText(wide, createTextContext({ width: 20 }));
792
- expect(tiny).toContain("(1 more column not shown)");
793
- expect(tiny).not.toContain("$0.04");
611
+ expect(without).not.toContain("attempt");
794
612
  });
795
613
 
796
- it("行带 locator:web 面链到证据室,text 面多一列 attempt", () => {
797
- const html = renderToStaticMarkup(cjkTable);
798
- expect(html).toContain('<a class="nre-locator" href="#/attempt/@160iuj3h">@160iuj3h</a>');
799
- expect(html).toContain("<thead>");
800
- expect(html).toContain("<tbody>");
801
- expect(text(cjkTable)).toContain("@160iuj3h");
802
- });
803
- });
804
-
805
- // ───────────────────────── 树校验与自定义组件 ─────────────────────────
806
-
807
- describe("渲染前树校验", () => {
808
- it("字符串 intrinsic(<div>)报错,错误指名组件路径", () => {
809
- const bad = (
810
- <Col>
811
- <Section title="x">
812
- <div>raw</div>
813
- </Section>
814
- </Col>
815
- );
816
- expect(() => validateReportTree(bad)).toThrow(
817
- /Raw HTML <div> has no terminal face; use <Text>, layout primitives, or a defineComponent component\. \(in <Col> > <Section>\)/,
614
+ it("Row text 面:宽度装得下按显示宽度并排,装不下整块纵向堆叠、内容完整", () => {
615
+ const tree = (
616
+ <Row>
617
+ <Text>left block</Text>
618
+ <Text>right block</Text>
619
+ </Row>
818
620
  );
819
- });
820
-
821
- it("普通函数组件(组合页面片段)被调用展开继续校验", () => {
822
- const Fragmented = () => (
621
+ const wide = renderNodeToText(tree, createTextContext({ width: 80 }));
622
+ expect(wide.split("\n")[0]).toContain("left block");
623
+ expect(wide.split("\n")[0]).toContain("right block");
624
+ const narrow = renderNodeToText(tree, createTextContext({ width: 20 }));
625
+ expect(narrow).toContain("left block");
626
+ expect(narrow).toContain("right block");
627
+ expect(narrow.split("\n")[0]).not.toContain("right block"); // 纵向堆叠
628
+ });
629
+
630
+ it("Style 在 text 面零输出,web 面吐 <style>(页级全局)", async () => {
631
+ const scope = scopeOf([]);
632
+ const definition = defineReport(
823
633
  <Col>
824
- <Text>ok</Text>
825
- </Col>
634
+ <Style>{`.nre .x { color: red; }`}</Style>
635
+ <Text>visible</Text>
636
+ </Col>,
826
637
  );
827
- expect(() => validateReportTree(<Fragmented />)).not.toThrow();
828
- const BadInside = () => <span>nope</span>;
829
- expect(() => validateReportTree(<BadInside />)).toThrow(/Raw HTML <span>/);
830
- });
831
- });
832
-
833
- describe("defineComponent", () => {
834
- it("faces 两键必填,少一个面在运行时也拦住(编译期本就不过)", () => {
835
- // @ts-expect-error 缺 text 面编译不过;运行时同样报错
836
- expect(() => defineComponent({ web: () => null })).toThrow(/requires both faces/);
837
- });
838
-
839
- // custom-reports.mdx「换形态」的 PassBars 示例,逐字同源:text 面用公开的文本排版工具箱
840
- // (stringWidth / padEnd / bar),不用 String.prototype.padEnd —— 中文名的那一行是护栏。
841
- it("自定义双面组件:同一份数据两面判读一致,text 面拿到宽度;中文行不撕歪", () => {
842
- interface BarRow {
843
- key: string;
844
- ratio: number | null;
845
- display: string;
846
- }
847
- const PassBars = defineComponent<{ rows: BarRow[] }>({
848
- web({ rows }) {
849
- return (
850
- <ul className="passbars">
851
- {rows.map((r) => (
852
- <li key={r.key}>
853
- <span>{r.key}</span>
854
- <b>{r.ratio === null ? "—" : r.display}</b>
855
- </li>
856
- ))}
857
- </ul>
858
- );
859
- },
860
- text({ rows }, { width }) {
861
- expect(width).toBe(80);
862
- const label = Math.max(...rows.map((r) => stringWidth(r.key)));
863
- const barWidth = Math.min(20, width - label - 8);
864
- return rows
865
- .map((r) => {
866
- const chart = r.ratio === null ? padEnd("—", barWidth) : bar(r.ratio, barWidth);
867
- return `${padEnd(r.key, label)} ${chart} ${r.display}`;
868
- })
869
- .join("\n");
870
- },
871
- });
872
- const rows: BarRow[] = [
873
- { key: "bub", ratio: 0.87, display: "87%" },
874
- { key: "codex", ratio: 0.8, display: "80%" },
875
- { key: "克劳德", ratio: null, display: "—" },
876
- ];
877
- const term = text(<PassBars rows={rows} />);
878
- expect(term).toMatchInlineSnapshot(`
879
- "bub █████████████████░░░ 87%
880
- codex ████████████████░░░░ 80%
881
- 克劳德 — —"
882
- `);
883
- // 三行的条形都从同一显示列开始:"克劳德" 是 3 个码元、6 个显示列,
884
- // 拿 .padEnd(label) 补齐会把这一行整体左移 3 列
885
- const starts = term.split("\n").map((line, i) => stringWidth(line.slice(0, line.indexOf(["█", "█", "—"][i]))));
886
- expect(new Set(starts).size).toBe(1);
887
-
888
- const html = renderToStaticMarkup(<PassBars rows={rows} />);
889
- expect(html).toContain("87%");
890
- expect(html).toContain("—"); // 缺数据两面都是 —,不补 0
638
+ const hostCtx = { scope, results: resultsOf([]) };
639
+ const text = await renderReportToText(definition, hostCtx);
640
+ expect(text).toBe("visible");
641
+ const html = await renderReportToStaticHtml(definition, hostCtx);
642
+ expect(html).toContain("<style>");
891
643
  });
892
644
  });
893
645
 
894
- // ───────────────────────── defineReport 与两个宿主入口 ─────────────────────────
895
-
896
- function fakeContext(): { selection: Selection; results: Results } {
897
- const snapshot: Snapshot = (() => {
898
- const dir = "/results/compare_bub/snap-1";
899
- const base = {
900
- experimentId: "compare/bub",
901
- startedAt: "2026-07-01T10:00:00Z",
902
- completedAt: "2026-07-01T10:05:00Z",
903
- agent: "bub",
904
- schemaVersion: 1,
905
- dir,
906
- };
907
- const mk = (id: string, verdict: "passed" | "failed", index: number) => ({
908
- evalId: id,
909
- experimentId: "compare/bub",
910
- result: {
911
- id,
912
- agent: "bub",
913
- verdict,
914
- attempt: 0,
915
- startedAt: `2026-07-01T10:0${index}:00Z`,
916
- durationMs: 1000,
917
- assertions: [],
918
- },
919
- ref: { snapshot: "compare_bub/snap-1", attempt: `${id}/a0` },
920
- snapshot: base,
921
- events: async () => null,
922
- trace: async () => null,
923
- o11y: async () => null,
924
- diff: async () => null,
925
- sources: async () => null,
926
- });
927
- const attempts = [mk("algebra/x", "passed", 0), mk("algebra/y", "failed", 1)];
928
- return {
929
- ...base,
930
- evals: attempts.map((a) => ({ id: a.evalId, attempts: [a] })),
931
- attempts,
932
- } as unknown as Snapshot;
933
- })();
934
- const selection: Selection = {
935
- snapshots: [snapshot],
936
- warnings: [],
937
- filter: () => selection,
938
- };
939
- const results = {
940
- experiments: [{ id: "compare/bub", snapshots: [snapshot], latest: snapshot, evalIds: ["algebra/x", "algebra/y"] }],
941
- skipped: [],
942
- latest: () => selection,
943
- } as Results;
944
- return { selection, results };
945
- }
946
-
947
- /**
948
- * 五份快照、两个双配置目录组 + 一个根目录单例组,全部通过、无成本——
949
- * 一个多实验 Selection 夹具:ExperimentList 出五项,MetricScatter 五个点都无成本
950
- * (0 可画点,如实走空态)。`filter` 落实真实语义(不像 fakeContext 那样恒等返回自己)。
951
- */
952
- function fakeMultiGroupContext(): { selection: Selection; results: Results } {
953
- const mkSnapshot = (experimentId: string, agent: string, dirSuffix: string): Snapshot => {
954
- const dir = `/results/${dirSuffix}`;
955
- const base = {
956
- experimentId,
957
- startedAt: "2026-07-01T10:00:00Z",
958
- completedAt: "2026-07-01T10:05:00Z",
959
- agent,
960
- schemaVersion: 1,
961
- dir,
962
- };
963
- const attempt = {
964
- evalId: "algebra/x",
965
- experimentId,
966
- result: {
967
- id: "algebra/x",
968
- agent,
969
- verdict: "passed" as const,
970
- attempt: 0,
971
- startedAt: "2026-07-01T10:00:00Z",
972
- durationMs: 1000,
973
- assertions: [],
974
- },
975
- ref: { snapshot: dirSuffix, attempt: "algebra/x/a0" },
976
- snapshot: base,
977
- events: async () => null,
978
- trace: async () => null,
979
- o11y: async () => null,
980
- diff: async () => null,
981
- sources: async () => null,
982
- };
983
- return {
984
- ...base,
985
- evals: [{ id: attempt.evalId, attempts: [attempt] }],
986
- attempts: [attempt],
987
- } as unknown as Snapshot;
988
- };
989
-
990
- const snapshots = [
991
- mkSnapshot("compare/bub", "bub", "compare_bub/snap-1"),
992
- mkSnapshot("compare/codex", "codex", "compare_codex/snap-1"),
993
- mkSnapshot("other/codex", "codex", "other_codex/snap-1"),
994
- mkSnapshot("other/bub", "bub", "other_bub/snap-1"),
995
- mkSnapshot("solo", "bub", "solo/snap-1"),
996
- ];
997
-
998
- const makeSelection = (snaps: Snapshot[]): Selection => ({
999
- snapshots: snaps,
1000
- warnings: [],
1001
- filter: (predicate) => makeSelection(snaps.filter(predicate)),
1002
- });
1003
- const selection = makeSelection(snapshots);
1004
- const results = {
1005
- experiments: snapshots.map((s) => ({
1006
- id: s.experimentId,
1007
- snapshots: [s],
1008
- latest: s,
1009
- evalIds: ["algebra/x"],
1010
- })),
1011
- skipped: [],
1012
- latest: () => selection,
1013
- } as Results;
1014
- return { selection, results };
1015
- }
1016
-
1017
- describe("defineReport + 渲染入口", () => {
1018
- // ExperimentList 没有 selection-form:build() 里直接 await .data(selection) 拿到普通数组
1019
- // 再传 items;RunOverview / Scoreboard 同样是预计算的 data 形态。三者都在 build() 里
1020
- // await,一棵树验证同一个 renderReportToText/renderReportToStaticHtml 管线消费得动它们。
1021
- const report = defineReport(async ({ selection }) => {
1022
- const experiments = await ExperimentList.data(selection);
1023
- return (
1024
- <Col>
1025
- <RunOverview data={await RunOverview.data(selection)} />
1026
- <ExperimentList items={experiments} />
1027
- <Section title="考试成绩单">
1028
- <Scoreboard data={await Scoreboard.data(selection, { rows: "agent", subjects: "evalGroup" })} />
1029
- </Section>
1030
- </Col>
646
+ // ───────────────────────── Tabs ─────────────────────────
647
+
648
+ describe("Tabs", () => {
649
+ it("两面都输出全部 tab 完整内容:web tab 一个 <details> 且仅首个 open;text 按声明序分节不省略", async () => {
650
+ const scope = scopeOf([]);
651
+ const definition = defineReport(
652
+ <Tabs>
653
+ <Tab title="First">
654
+ <Text>alpha body</Text>
655
+ </Tab>
656
+ <Tab title="Second">
657
+ <Text>beta body</Text>
658
+ </Tab>
659
+ </Tabs>,
1031
660
  );
1032
- });
1033
-
1034
- it("isReportDefinition 识别产物;非函数报错", () => {
1035
- expect(isReportDefinition(report)).toBe(true);
1036
- expect(isReportDefinition({})).toBe(false);
1037
- // @ts-expect-error 非函数
1038
- expect(() => defineReport(42)).toThrow(/expects a build function/);
1039
- });
1040
-
1041
- it("renderReportToText:同一棵树走 text 面,build() 里 await 的实体列表数据原样渲染", async () => {
1042
- const out = await renderReportToText(report, fakeContext(), { width: 100 });
1043
- // RunOverview(data 形态,预计算)
1044
- expect(out).toContain("1 experiment · 2 evals · 2 attempts");
1045
- // ExperimentList:主行 + eval 级折叠计票 + 逐 Attempt locator 与失败诊断
1046
- expect(out).toMatch(/compare\/bub\s+default\s+bub/);
1047
- expect(out).toMatch(/1 passed[\s\S]*?\/ 1\s+failed/);
1048
- expect(out).toContain("50%");
1049
- expect(out).toMatch(/✗ failed\s+algebra\/y[\s\S]*└─ @[0-9a-z]+/);
1050
- // 自己的口径:成绩单
1051
- expect(out).toContain("考试成绩单");
1052
- expect(out).toContain("bub");
1053
- expect(out).toContain("50/100");
1054
- });
1055
-
1056
- it("renderReportToStaticHtml:同一棵树走 web 面,ExperimentList 自动接证据室深链", async () => {
1057
- const html = await renderReportToStaticHtml(report, fakeContext());
1058
- expect(html).toContain("nre-experiment-list");
1059
- expect(html).toContain("nre-scoreboard");
1060
- expect(html).toContain("考试成绩单");
1061
- // 宿主注入的 attemptHref:ExperimentList 的 locator 徽标是普通 <a>,默认 view 路由(单段 locator)
1062
- expect(html).toMatch(/href="#\/attempt\/@[0-9a-z]+"/);
1063
- expect(html).not.toContain("<script");
1064
- });
1065
-
1066
- it("RunOverview 已渲染 Selection warning 时,宿主不会在 text/web 顶部重复一遍", async () => {
1067
- const context = fakeContext();
1068
- const message = "snapshot is unfinished; completed attempts may be incomplete";
1069
- context.selection.warnings.push({
1070
- kind: "unfinished-snapshot",
1071
- experimentId: "compare/bub",
1072
- startedAt: "2026-07-01T10:00:00Z",
1073
- dir: "/results/compare_bub/snap-1",
1074
- message,
1075
- });
1076
- const textOut = await renderReportToText(report, context, { width: 100 });
1077
- const htmlOut = await renderReportToStaticHtml(report, context);
1078
- expect(textOut.split(message)).toHaveLength(2);
1079
- expect(htmlOut.split(message)).toHaveLength(2);
1080
- });
1081
-
1082
- it("树里混进 <div>:两个宿主 resolve 后同一遍校验拦住", async () => {
1083
- const bad = defineReport(() => (
1084
- <Col>
1085
- <div>nope</div>
1086
- </Col>
1087
- ));
1088
- await expect(renderReportToText(bad, fakeContext())).rejects.toThrow(/Raw HTML <div>/);
1089
- await expect(renderReportToStaticHtml(bad, fakeContext())).rejects.toThrow(/Raw HTML <div>/);
1090
- });
1091
-
1092
- it("selection 形态组件裸嵌进 React(不经宿主 resolve):web 面直说未解析", () => {
1093
- const { selection } = fakeContext();
1094
- // selection 形态在类型上合法,但裸调用路径(不过 resolveReportTree)只接收 data 形态,
1095
- // web 面拿不到 data —— 运行时直说,而不是画一张空组件。MetricScatter 是本文件里仍带
1096
- // selection-form 的官方组件(ExperimentList/EvalList/AttemptList 没有 selection-form,
1097
- // 这条契约不适用于它们)。
661
+ const hostCtx = { scope, results: resultsOf([]) };
662
+ const html = await renderReportToStaticHtml(definition, hostCtx);
663
+ expect(html.match(/<details/g)).toHaveLength(2);
664
+ expect(html.match(/<details[^>]* open/g)).toHaveLength(1);
665
+ expect(html).toContain("alpha body");
666
+ expect(html).toContain("beta body");
667
+ const text = await renderReportToText(definition, hostCtx);
668
+ expect(text.indexOf("First")).toBeLessThan(text.indexOf("Second"));
669
+ expect(text).toContain("alpha body");
670
+ expect(text).toContain("beta body"); // 不丢第二个 tab
671
+ });
672
+
673
+ it(" Tabs、普通组件混作直接子节点、游离 Tab 都在树校验期给出完整用户反馈", () => {
674
+ expect(() => validateReportTree(<Tabs>{null}</Tabs>)).toThrow(/at least one <Tab>/);
675
+ expect(() =>
676
+ validateReportTree(
677
+ <Tabs>
678
+ <Text>stray</Text>
679
+ </Tabs>,
680
+ ),
681
+ ).toThrow(/only accepts <Tab>/);
1098
682
  expect(() =>
1099
- renderToStaticMarkup(<MetricScatter selection={selection} points="experiment" x={costUSD} y={taskPassRate} />),
1100
- ).toThrow(/received unresolved \(selection-form\) props/);
683
+ validateReportTree(
684
+ <Col>
685
+ <Tab title="loose">
686
+ <Text>x</Text>
687
+ </Tab>
688
+ </Col>,
689
+ ),
690
+ ).toThrow(/direct child of <Tabs>/);
1101
691
  });
1102
692
  });
1103
693
 
1104
- // ───────────────────────── 数据组件互斥 props(类型层负向)─────────────────────────
1105
- // 只在 pnpm run typecheck 生效(src 纳入 tsconfig);函数从不执行,仅让 tsc 校验每条
1106
- // ts-expect-error 注释对应一处编译错误。直接标注 Props 类型(而非 JSX)——JSX 的 union
1107
- // 属性检查对「缺必填字段」较宽松,直接的类型赋值才严格钉住互斥不变量:同时传 data 与
1108
- // selection、或两者都不传、或 selection 形态缺必填计算选项,全部编译失败。正向 JSX 用法由
1109
- // ExperimentComparison / defineReport 报告在同一文件里编译验证。
1110
- function metricScatterPropsTypeChecks(selection: Selection, data: ScatterData): void {
1111
- const ok1: MetricScatterProps = { data }; // 合法:data 形态
1112
- const ok2: MetricScatterProps = { selection, points: "experiment", series: "agent", x: costUSD, y: taskPassRate }; // 合法:selection 形态
1113
- // @ts-expect-error 同时传 data 与 selection:非法
1114
- const bad1: MetricScatterProps = { data, selection, points: "experiment", x: costUSD, y: taskPassRate };
1115
- // @ts-expect-error data 与 selection 都不传:非法
1116
- const bad2: MetricScatterProps = { pointHref: () => "/x" };
1117
- // @ts-expect-error selection 形态缺必填的 x / y:非法
1118
- const bad3: MetricScatterProps = { selection, points: "experiment" };
1119
- void ok1;
1120
- void ok2;
1121
- void bad1;
1122
- void bad2;
1123
- void bad3;
1124
- }
694
+ // ───────────────────────── defineReport 装载规范化 ─────────────────────────
1125
695
 
1126
- void metricScatterPropsTypeChecks;
1127
-
1128
- // ───────────────────────── ExperimentComparison(内置默认报告)─────────────────────────
1129
-
1130
- describe("ExperimentComparison", () => {
1131
- it("是普通 ReportDefinition;单组 text 面 = 组摘要 + 成本×端到端成功率散点 + 实验列表", async () => {
1132
- expect(isReportDefinition(ExperimentComparison)).toBe(true);
1133
- const out = await renderReportToText(ExperimentComparison, fakeContext(), { width: 100 });
1134
- // 散点:fakeContext 无成本数据 0 可画点,显式说明缺哪两个指标(而不是画一张空图)
1135
- expect(out).toContain("No data to plot");
1136
- expect(out).not.toContain("better → upper right");
1137
- // 实验列表主行 + eval 级折叠计票 + 失败诊断(ExperimentList.data 在 build() 里直接 await)
1138
- // 组已由 section 标题 "compare" 标注,列表行传 relativeTo=组键 去掉前缀,只显示 id 末段
1139
- expect(out).toMatch(/^compare$/m);
1140
- expect(out).toMatch(/^\s*bub\s+default\s+bub/m);
1141
- expect(out).not.toContain("compare/bub");
1142
- expect(out).toMatch(/1 passed[\s\S]*?\/ 1\s+failed/);
1143
- expect(out).toContain("50%");
1144
- expect(out).toMatch(/✗ failed\s+algebra\/y[\s\S]*└─ @[0-9a-z]+/);
1145
- // 单组直接进入详情,不出现多组查看命令
1146
- expect(out).not.toContain("Current verdicts");
1147
- expect(out).not.toContain("niceeval show --experiment");
696
+ describe("defineReport 装载规范化", () => {
697
+ it("三种写法装载出等价的规范化结果:树 ≡ {content} ≡ pages [{id: report}]", () => {
698
+ const tree = <ScopeSummary />;
699
+ const fromTree = defineReport(tree);
700
+ const fromContent = defineReport({ content: tree });
701
+ const fromPages = defineReport({
702
+ pages: [{ id: "report", title: { en: "Report", "zh-CN": "报告" }, content: tree }],
703
+ });
704
+ for (const definition of [fromTree, fromContent, fromPages]) {
705
+ expect(definition.kind).toBe("report");
706
+ expect(definition.pages).toHaveLength(1);
707
+ expect(definition.pages[0]!.id).toBe("report");
708
+ expect(definition.pages[0]!.content).toBe(tree);
709
+ }
710
+ expect(fromTree.pages[0]!.title).toEqual(fromContent.pages[0]!.title);
1148
711
  });
1149
712
 
1150
- it("web 面:完整组索引 + 首组 panel + 实验列表 <details> 展开区,无 <script>", async () => {
1151
- const html = await renderReportToStaticHtml(ExperimentComparison, fakeContext());
1152
- expect(html).toContain("nre-experiment-group-tabs");
1153
- expect(html).toMatch(/<details[^>]*data-nre-experiment-group-panel="0"[^>]*\sopen=""/);
1154
- expect(html).toContain("nre-metric-scatter");
1155
- expect(html).toContain("nre-scatter-empty"); // 0 可画点的空态
1156
- expect(html).toContain('<details class="nre-experiment-entry">');
1157
- // 行标签去掉组前缀只显示 id 末段;完整 id 仍留在 data-sort-value 作排序/身份键
1158
- expect(html).toMatch(/nre-experiment-id[^>]*>\s*bub\s*<\/b>/);
1159
- expect(html).toContain('data-sort-value="compare/bub"');
1160
- expect(html).toContain("nre-experiment-head");
1161
- expect(html).toContain('data-nre-experiment-filter=""');
1162
- expect(html).toContain("nre-experiment-evals");
1163
- expect(html).not.toContain("<script");
713
+ it("content pages 同时声明或都省略,装载报错且文案含 <ExperimentComparison /> 下一步", () => {
714
+ expect(() => defineReport({ content: <ScopeSummary />, pages: [] } as never)).toThrow(/both "content" and "pages"/);
715
+ expect(() => defineReport({ title: "X" } as never)).toThrow(/<ExperimentComparison \/>/);
1164
716
  });
1165
717
 
1166
- it("locale 变体:en / zh-CN 都渲染(chrome 分语言),散点空态两面同一事实", async () => {
1167
- const zhHtml = await renderReportToStaticHtml(ExperimentComparison, fakeContext(), { locale: "zh-CN" });
1168
- expect(zhHtml).toContain("端到端成功率");
1169
- const enHtml = await renderReportToStaticHtml(ExperimentComparison, fakeContext(), { locale: "en" });
1170
- expect(enHtml).toContain("End-to-end pass rate");
1171
- const zhText = await renderReportToText(ExperimentComparison, fakeContext(), { locale: "zh-CN" });
1172
- expect(zhText).toContain("没有可绘制的数据"); // 散点空态 zh
1173
- const enText = await renderReportToText(ExperimentComparison, fakeContext(), { locale: "en" });
1174
- expect(enText).toContain("No data to plot");
718
+ it("defineReport 产物不是 ReportNode:页里放产物装载报错;树校验同样拒绝", () => {
719
+ const inner = defineReport(<ScopeSummary />);
720
+ expect(() => defineReport({ pages: [{ id: "a", title: "A", content: inner as never }] })).toThrow(
721
+ /shell cannot nest/,
722
+ );
723
+ expect(() => defineReport(inner as never)).toThrow(/shell cannot nest/);
724
+ expect(() => validateReportTree([inner] as never)).toThrow(/not a report node/);
1175
725
  });
1176
726
 
1177
- it("多组 fixture:text 只给组索引与命令;web 保留全部独立 panel", async () => {
1178
- const out = await renderReportToText(ExperimentComparison, fakeMultiGroupContext(), { width: 100 });
1179
- expect(out).toContain("Experiment groups");
1180
- expect(out).toContain("niceeval show --experiment compare");
1181
- expect(out).toContain("niceeval show --experiment other");
1182
- expect(out).toContain("niceeval show --experiment solo");
1183
- expect(out).not.toMatch(/compare\/bub\s+default\s+bub/);
1184
- expect(out).not.toContain("No data to plot");
1185
- const html = await renderReportToStaticHtml(ExperimentComparison, fakeMultiGroupContext());
1186
- expect(html.match(/data-nre-experiment-group-panel=/g)).toHaveLength(3);
1187
- expect(html.match(/<details[^>]*nre-experiment-group-panel[^>]*\sopen=""/g)).toHaveLength(1);
1188
- expect(html).toContain("compare/bub");
1189
- expect(html).toContain("other/codex");
1190
- expect(html).toContain("solo");
727
+ it("重复或非法 page id 装载报错并点名冲突;LocalizedText 全空对象报错", () => {
728
+ expect(() =>
729
+ defineReport({
730
+ pages: [
731
+ { id: "exam", title: "A", content: null },
732
+ { id: "exam", title: "B", content: null },
733
+ ],
734
+ }),
735
+ ).toThrow(/"exam" is declared twice/);
736
+ expect(() => defineReport({ pages: [{ id: "Bad/Id", title: "A", content: null }] })).toThrow(/invalid/);
737
+ expect(() => defineReport({ title: {}, content: null })).toThrow(/no non-empty value/);
738
+ });
739
+
740
+ it("{src} 资产拒绝 .. 路径段、绝对路径与 ~;inline/src 互斥", () => {
741
+ expect(() => defineReport({ content: null, scripts: [{ src: "../x.js" }] })).toThrow(/not allowed/);
742
+ expect(() => defineReport({ content: null, scripts: [{ src: "/abs.js" }] })).toThrow(/not allowed/);
743
+ expect(() => defineReport({ content: null, styles: [{ src: "~/x.css" }] })).toThrow(/not allowed/);
744
+ expect(() => defineReport({ content: null, scripts: [{ src: "./a.js", inline: "x" } as never] })).toThrow(
745
+ /exactly one/,
746
+ );
747
+ expect(() => defineReport({ content: null, scripts: [{ src: "./assets/a.js" }] })).not.toThrow();
1191
748
  });
1192
749
 
1193
- it(".data():组内三个子块 = 对该组单独调用 GroupSummary / MetricScatter / ExperimentList", async () => {
1194
- const { selection } = fakeContext();
1195
- const data = await ExperimentComparison.data(selection);
1196
- expect(data.groups).toHaveLength(1);
1197
- expect(data.groups[0]!.key).toBe("compare");
1198
- expect(data.groups[0]!.summary).toEqual(await GroupSummary.data(selection));
1199
- expect(data.groups[0]!.scatter).toEqual(
1200
- await MetricScatter.data(selection, { points: "experiment", series: "agent", x: costUSD, y: endToEndPassRate }),
750
+ it("--page 语义:pickReportPage 缺省第一页,未命中抛 ReportPageNotFoundError 列出可用页", () => {
751
+ const definition = defineReport({
752
+ pages: [
753
+ { id: "overview", title: "Overview", content: null },
754
+ { id: "exam", title: "Exam", content: null },
755
+ ],
756
+ });
757
+ expect(pickReportPage(definition).id).toBe("overview");
758
+ expect(pickReportPage(definition, "exam").id).toBe("exam");
759
+ try {
760
+ pickReportPage(definition, "typo");
761
+ expect.unreachable();
762
+ } catch (e) {
763
+ expect(e).toBeInstanceOf(ReportPageNotFoundError);
764
+ expect((e as ReportPageNotFoundError).available).toEqual(["overview", "exam"]);
765
+ }
766
+ // 树形态文件的唯一页 id 是缩写展开出的 report
767
+ const single = defineReport(<ScopeSummary />);
768
+ expect(pickReportPage(single, "report").id).toBe("report");
769
+ });
770
+
771
+ it("标题回退链:def.title → 唯一且相同的快照 name → 内置文案「Eval 运行结果 / Eval Results」;en 相同 zh 不同也落内置文案", () => {
772
+ const named = snap({ experimentId: "t/a", name: "Memory Evals", results: [res("q", "passed")] });
773
+ const definition = defineReport(<ScopeSummary />);
774
+ expect(resolveReportTitle(defineReport({ title: "Custom", content: null }), scopeOf([named]))).toBe("Custom");
775
+ expect(resolveReportTitle(definition, scopeOf([named]))).toBe("Memory Evals");
776
+ expect(resolveReportTitle(definition, scopeOf([snap({ experimentId: "t/b", results: [] })]))).toEqual(
777
+ FALLBACK_REPORT_TITLE,
1201
778
  );
1202
- expect(data.groups[0]!.experiments).toEqual(await ExperimentList.data(selection));
779
+ const zhA = snap({ experimentId: "t/c", name: { en: "Same", "zh-CN": "一" }, results: [] });
780
+ const zhB = snap({ experimentId: "t/d", name: { en: "Same", "zh-CN": "二" }, results: [] });
781
+ expect(resolveReportTitle(definition, scopeOf([zhA, zhB]))).toEqual(FALLBACK_REPORT_TITLE);
782
+ expect(FALLBACK_REPORT_TITLE).toEqual({ en: "Eval Results", "zh-CN": "Eval 运行结果" });
1203
783
  });
1204
784
 
1205
- it(".data():计算前按完整父路径分区,两个目录组和根目录单例互不串数据或 refs", async () => {
1206
- const { selection } = fakeMultiGroupContext();
1207
- const data = await ExperimentComparison.data(selection);
1208
- expect(data.groups.map((group) => group.key)).toEqual(["compare", "other", "solo"]);
1209
-
1210
- for (const group of data.groups) {
1211
- const groupSelection = selection.filter((snapshot) => {
1212
- const slash = snapshot.experimentId.lastIndexOf("/");
1213
- const key = slash === -1 ? snapshot.experimentId : snapshot.experimentId.slice(0, slash);
1214
- return key === group.key;
1215
- });
1216
- expect(group.summary).toEqual(await GroupSummary.data(groupSelection));
1217
- expect(group.scatter).toEqual(
1218
- await MetricScatter.data(groupSelection, {
1219
- points: "experiment",
1220
- series: "agent",
1221
- x: costUSD,
1222
- y: endToEndPassRate,
785
+ it("ReportLink.icon 是 { svg: string }:defineReport 接受合法形状;无类型 JS 传其它形状定义时报完整用户反馈", () => {
786
+ const svg = '<svg viewBox="0 0 16 16"><path d="M0 0h16v16z"/></svg>';
787
+ const withIcon = defineReport({
788
+ content: null,
789
+ links: [{ label: "GitHub", href: "https://example.com", icon: { svg } }],
790
+ });
791
+ expect(withIcon.links[0]!.icon).toEqual({ svg });
792
+
793
+ const reactNode = { $$typeof: Symbol.for("react.transitional.element"), type: "svg", props: {} };
794
+ for (const icon of [reactNode, "<svg/>", { svg: 42 }, { svg: "" }]) {
795
+ expect(() =>
796
+ defineReport({
797
+ content: null,
798
+ links: [{ label: "GitHub", href: "https://example.com", icon: icon as never }],
1223
799
  }),
1224
- );
1225
- expect(group.experiments).toEqual(await ExperimentList.data(groupSelection));
1226
- expect(group.scatter.rows.every((row) => group.experiments.some((item) => item.experimentId === row.key))).toBe(
1227
- true,
1228
- );
800
+ ).toThrow(/"icon" must be \{ svg: string \}/);
1229
801
  }
1230
-
1231
- expect(data.groups.find((group) => group.key === "compare")!.experiments.map((item) => item.experimentId)).toEqual([
1232
- "compare/bub",
1233
- "compare/codex",
1234
- ]);
1235
- expect(data.groups.find((group) => group.key === "other")!.experiments.map((item) => item.experimentId)).toEqual([
1236
- "other/bub",
1237
- "other/codex",
1238
- ]);
1239
- expect(data.groups.find((group) => group.key === "solo")!.experiments.map((item) => item.experimentId)).toEqual([
1240
- "solo",
1241
- ]);
1242
802
  });
803
+ });
1243
804
 
1244
- it("组合件形态:<ExperimentComparison data={await .data(selection)}/> 与裸跑(build 面)渲染同一事实", async () => {
1245
- const data = await ExperimentComparison.data(fakeContext().selection);
1246
- const asComponent = defineReport(() => <ExperimentComparison data={data} />);
1247
- // text 面逐字相等:同一份数据、同一套子组件口径
1248
- const componentText = await renderReportToText(asComponent, fakeContext(), { width: 100 });
1249
- const bareText = await renderReportToText(ExperimentComparison, fakeContext(), { width: 100 });
1250
- expect(componentText).toBe(bareText);
1251
- // web 面同一事实
1252
- const componentHtml = await renderReportToStaticHtml(asComponent, fakeContext());
1253
- const bareHtml = await renderReportToStaticHtml(ExperimentComparison, fakeContext());
1254
- expect(componentHtml).toBe(bareHtml);
805
+ // ───────────────────────── 内建报告 ─────────────────────────
806
+
807
+ describe("内建报告", () => {
808
+ it("niceeval/report/built-in 默认导出是普通 defineReport 产物,与用户写的 defineReport(<ExperimentComparison />) 等价", async () => {
809
+ expect(builtInReport.kind).toBe("report");
810
+ expect(builtInReport.pages).toHaveLength(1);
811
+
812
+ const s1 = snap({ experimentId: "compare/a", agent: "bub", results: [res("q", "passed")] });
813
+ const s2 = snap({ experimentId: "compare/b", agent: "codex", results: [res("q", "failed")] });
814
+ const scope = scopeOf([s1, s2]);
815
+ const ctx = { scope, results: resultsOf([s1, s2]) };
816
+ const builtin = await renderReportToText(builtInReport, ctx, { width: 120 });
817
+ const user = await renderReportToText(defineReport(<ExperimentComparison />), ctx, { width: 120 });
818
+ expect(builtin).toBe(user);
819
+ });
820
+
821
+ it("text 面多组时只输出组索引与单组查看命令;单组时输出散点与实验列表", async () => {
822
+ const g1 = snap({ experimentId: "compare/a", results: [res("q", "passed")] });
823
+ const g2 = snap({ experimentId: "dev/b", results: [res("q", "failed")] });
824
+ const multi = await renderReportToText(
825
+ builtInReport,
826
+ { scope: scopeOf([g1, g2]), results: resultsOf([g1, g2]) },
827
+ { width: 120 },
828
+ );
829
+ expect(multi).toContain("niceeval show --experiment compare");
830
+ expect(multi).toContain("niceeval show --experiment dev");
831
+ expect(multi).not.toContain("Eval / Attempt"); // 不倾倒组内 experiment 明细
832
+
833
+ const priced = snap({
834
+ experimentId: "compare/priced",
835
+ results: [res("q", "passed", { usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.2 } })],
836
+ });
837
+ const single = await renderReportToText(
838
+ builtInReport,
839
+ { scope: scopeOf([priced]), results: resultsOf([priced]) },
840
+ { width: 140 },
841
+ );
842
+ expect(single).toContain("Eval / Attempt"); // 单组直接进入组内详情
843
+ // 成本轴(better: lower)反向渲染,「更好」恒指向右上;两轴都声明 better → 提示在场
844
+ expect(single).toContain("better → upper right");
1255
845
  });
1256
846
  });