niceeval 0.5.4 → 0.6.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 (342) hide show
  1. package/INDEX.md +55 -0
  2. package/dist/agents/types.d.ts +317 -0
  3. package/dist/context/types.d.ts +335 -0
  4. package/dist/i18n/core.d.ts +11 -0
  5. package/dist/i18n/en.d.ts +181 -0
  6. package/dist/i18n/index.d.ts +5 -0
  7. package/dist/i18n/zh-CN.d.ts +183 -0
  8. package/dist/o11y/execution-tree.d.ts +103 -0
  9. package/dist/o11y/otlp/parse.d.ts +3 -0
  10. package/dist/o11y/otlp/receiver.d.ts +21 -0
  11. package/dist/o11y/otlp/select.d.ts +22 -0
  12. package/dist/o11y/otlp/turn-otel.d.ts +43 -0
  13. package/dist/o11y/types.d.ts +190 -0
  14. package/dist/report/aggregate.d.ts +57 -0
  15. package/dist/report/aggregate.js +212 -0
  16. package/dist/report/built-ins/cost-pass-rate-comparison.d.ts +1 -0
  17. package/dist/report/built-ins/cost-pass-rate-comparison.js +17 -0
  18. package/dist/report/built-ins/index.d.ts +1 -0
  19. package/dist/report/built-ins/index.js +2 -0
  20. package/dist/report/components.d.ts +178 -0
  21. package/dist/report/components.js +132 -0
  22. package/dist/report/compute.d.ts +106 -0
  23. package/dist/report/compute.js +618 -0
  24. package/dist/report/flag.d.ts +6 -0
  25. package/dist/report/flag.js +8 -0
  26. package/dist/report/format.d.ts +32 -0
  27. package/dist/report/format.js +126 -0
  28. package/dist/report/index.d.ts +19 -0
  29. package/dist/report/index.js +30 -0
  30. package/dist/report/load.d.ts +12 -0
  31. package/dist/report/load.js +56 -0
  32. package/dist/report/locale.d.ts +83 -0
  33. package/dist/report/locale.js +160 -0
  34. package/dist/report/metrics.d.ts +21 -0
  35. package/dist/report/metrics.js +115 -0
  36. package/dist/report/primitives.d.ts +64 -0
  37. package/dist/report/primitives.js +113 -0
  38. package/dist/report/react/AttemptList.d.ts +23 -0
  39. package/dist/report/react/AttemptList.js +20 -0
  40. package/dist/report/react/DeltaTable.d.ts +8 -0
  41. package/dist/report/react/DeltaTable.js +24 -0
  42. package/dist/report/react/EvalList.d.ts +10 -0
  43. package/dist/report/react/EvalList.js +0 -0
  44. package/dist/report/react/ExperimentList.d.ts +10 -0
  45. package/dist/report/react/ExperimentList.js +15 -0
  46. package/dist/report/react/GroupSummary.d.ts +8 -0
  47. package/dist/report/react/GroupSummary.js +8 -0
  48. package/dist/report/react/MetricBars.d.ts +10 -0
  49. package/dist/report/react/MetricBars.js +48 -0
  50. package/dist/report/react/MetricLine.d.ts +9 -0
  51. package/dist/report/react/MetricLine.js +68 -0
  52. package/dist/report/react/MetricMatrix.d.ts +10 -0
  53. package/dist/report/react/MetricMatrix.js +23 -0
  54. package/dist/report/react/MetricScatter.d.ts +9 -0
  55. package/dist/report/react/MetricScatter.js +100 -0
  56. package/dist/report/react/MetricTable.d.ts +11 -0
  57. package/dist/report/react/MetricTable.js +26 -0
  58. package/dist/report/react/RunOverview.d.ts +8 -0
  59. package/dist/report/react/RunOverview.js +12 -0
  60. package/dist/report/react/Scoreboard.d.ts +8 -0
  61. package/dist/report/react/Scoreboard.js +26 -0
  62. package/dist/report/react/cell.d.ts +9 -0
  63. package/dist/report/react/cell.js +9 -0
  64. package/dist/report/react/chart-math.d.ts +14 -0
  65. package/dist/report/react/chart-math.js +68 -0
  66. package/dist/report/react/colors.d.ts +14 -0
  67. package/dist/report/react/colors.js +45 -0
  68. package/dist/report/react/fixtures.d.ts +19 -0
  69. package/dist/report/react/fixtures.js +393 -0
  70. package/dist/report/react/format.d.ts +3 -0
  71. package/dist/report/react/format.js +7 -0
  72. package/dist/report/react/index.d.ts +6 -0
  73. package/dist/report/react/index.js +14 -0
  74. package/dist/report/report.d.ts +24 -0
  75. package/dist/report/report.js +49 -0
  76. package/dist/report/text/faces.d.ts +29 -0
  77. package/dist/report/text/faces.js +503 -0
  78. package/dist/report/text/layout.d.ts +75 -0
  79. package/dist/report/text/layout.js +173 -0
  80. package/dist/report/text/plot.d.ts +37 -0
  81. package/dist/report/text/plot.js +81 -0
  82. package/dist/report/text/table.d.ts +8 -0
  83. package/dist/report/text/table.js +86 -0
  84. package/dist/report/tree.d.ts +99 -0
  85. package/dist/report/tree.js +228 -0
  86. package/dist/report/types.d.ts +420 -0
  87. package/dist/report/types.js +4 -0
  88. package/dist/report/web.d.ts +15 -0
  89. package/dist/report/web.js +37 -0
  90. package/dist/results/annotated-source.d.ts +61 -0
  91. package/dist/results/attempt-evidence.d.ts +69 -0
  92. package/dist/results/attempt-source.d.ts +15 -0
  93. package/dist/results/locator.d.ts +75 -0
  94. package/dist/results/locator.js +0 -0
  95. package/dist/results/select.d.ts +45 -0
  96. package/dist/results/select.js +230 -0
  97. package/dist/results/source-hash.d.ts +9 -0
  98. package/dist/results/types.d.ts +204 -0
  99. package/dist/runner/eval-source.d.ts +22 -0
  100. package/dist/runner/fingerprint.d.ts +23 -0
  101. package/dist/runner/types.d.ts +332 -0
  102. package/dist/sandbox/docker-stream.d.ts +24 -0
  103. package/dist/sandbox/docker.d.ts +84 -0
  104. package/dist/sandbox/e2b.d.ts +31 -0
  105. package/dist/sandbox/errors.d.ts +4 -0
  106. package/dist/sandbox/local-files.d.ts +6 -0
  107. package/dist/sandbox/paths.d.ts +4 -0
  108. package/dist/sandbox/registry.d.ts +13 -0
  109. package/dist/sandbox/resolve.d.ts +40 -0
  110. package/dist/sandbox/retry.d.ts +10 -0
  111. package/dist/sandbox/shell.d.ts +11 -0
  112. package/dist/sandbox/source-files.d.ts +13 -0
  113. package/dist/sandbox/types.d.ts +203 -0
  114. package/dist/sandbox/vercel.d.ts +37 -0
  115. package/dist/scoring/types.d.ts +70 -0
  116. package/dist/shared/aggregate.d.ts +29 -0
  117. package/dist/shared/aggregate.js +55 -0
  118. package/dist/shared/types.d.ts +27 -0
  119. package/dist/shared/verdict.d.ts +27 -0
  120. package/dist/shared/verdict.js +34 -0
  121. package/dist/tty-line.d.ts +6 -0
  122. package/dist/types.d.ts +7 -0
  123. package/dist/util.d.ts +22 -0
  124. package/docs-site/zh/concepts/adapter.mdx +5 -5
  125. package/docs-site/zh/concepts/assert.mdx +8 -8
  126. package/docs-site/zh/concepts/evals.mdx +5 -5
  127. package/docs-site/zh/concepts/experiment.mdx +2 -2
  128. package/docs-site/zh/concepts/judge.mdx +9 -9
  129. package/docs-site/zh/concepts/overview.mdx +2 -2
  130. package/docs-site/zh/concepts/tier.mdx +6 -6
  131. package/docs-site/zh/example/claude-code-codex-plugin.mdx +9 -5
  132. package/docs-site/zh/example/claude-code-codex-skill.mdx +12 -7
  133. package/docs-site/zh/guides/agent-feedback-loop.mdx +235 -78
  134. package/docs-site/zh/guides/connect-otel.mdx +3 -3
  135. package/docs-site/zh/guides/connect-your-agent.mdx +2 -2
  136. package/docs-site/zh/guides/custom-reports.mdx +196 -36
  137. package/docs-site/zh/guides/experiments.mdx +7 -1
  138. package/docs-site/zh/guides/fixtures.mdx +1 -1
  139. package/docs-site/zh/guides/official-adapters.mdx +31 -9
  140. package/docs-site/zh/guides/publish-report.mdx +3 -3
  141. package/docs-site/zh/guides/report-components.mdx +194 -55
  142. package/docs-site/zh/guides/reporters.mdx +2 -2
  143. package/docs-site/zh/guides/results-data.mdx +35 -34
  144. package/docs-site/zh/guides/runner.mdx +2 -2
  145. package/docs-site/zh/guides/sandbox-agent.mdx +2 -2
  146. package/docs-site/zh/guides/{sandbox-backends.mdx → sandbox-providers.mdx} +11 -11
  147. package/docs-site/zh/guides/viewing-results.mdx +199 -79
  148. package/docs-site/zh/guides/write-experiment.mdx +9 -1
  149. package/docs-site/zh/guides/write-send.mdx +2 -2
  150. package/docs-site/zh/index.mdx +4 -3
  151. package/docs-site/zh/introduction.mdx +2 -1
  152. package/docs-site/zh/quickstart.mdx +5 -3
  153. package/docs-site/zh/reference/builtin-agents.mdx +38 -13
  154. package/docs-site/zh/reference/capabilities.mdx +1 -1
  155. package/docs-site/zh/reference/cli.mdx +36 -13
  156. package/docs-site/zh/reference/define-agent.mdx +12 -12
  157. package/docs-site/zh/reference/define-config.mdx +1 -1
  158. package/docs-site/zh/reference/define-eval.mdx +3 -3
  159. package/docs-site/zh/reference/events.mdx +7 -1
  160. package/docs-site/zh/reference/expect.mdx +3 -3
  161. package/package.json +10 -6
  162. package/src/agents/bub.ts +129 -42
  163. package/src/agents/claude-code.test.ts +152 -0
  164. package/src/agents/claude-code.ts +127 -13
  165. package/src/agents/codex.test.ts +192 -0
  166. package/src/agents/codex.ts +151 -12
  167. package/src/agents/index.ts +9 -3
  168. package/src/agents/manifest.ts +41 -0
  169. package/src/agents/plugin-config.test.ts +94 -0
  170. package/src/agents/shared.ts +33 -2
  171. package/src/agents/skills.test.ts +214 -0
  172. package/src/agents/skills.ts +304 -0
  173. package/src/agents/streaming.ts +5 -5
  174. package/src/agents/types.ts +54 -2
  175. package/src/cli.ts +74 -47
  176. package/src/context/context.test.ts +20 -2
  177. package/src/context/control-flow.ts +1 -1
  178. package/src/context/session.test.ts +92 -1
  179. package/src/context/session.ts +23 -1
  180. package/src/context/types.ts +4 -4
  181. package/src/define.test.ts +8 -8
  182. package/src/define.ts +11 -11
  183. package/src/expect/index.ts +5 -5
  184. package/src/i18n/en.ts +34 -15
  185. package/src/i18n/zh-CN.ts +37 -21
  186. package/src/index.ts +1 -1
  187. package/src/o11y/execution-tree.test.ts +452 -0
  188. package/src/o11y/execution-tree.ts +367 -0
  189. package/src/o11y/otlp/receiver.ts +1 -1
  190. package/src/o11y/otlp/select.ts +7 -3
  191. package/src/o11y/parsers/claude-code.test.ts +175 -0
  192. package/src/o11y/parsers/claude-code.ts +32 -0
  193. package/src/o11y/types.ts +6 -1
  194. package/src/report/aggregate.ts +26 -20
  195. package/src/report/built-in-user-parity.test.tsx +643 -0
  196. package/src/report/built-ins/cost-pass-rate-comparison.tsx +23 -0
  197. package/src/report/built-ins/index.ts +2 -0
  198. package/src/report/components.tsx +223 -50
  199. package/src/report/compute.ts +358 -53
  200. package/src/report/dual-render.test.tsx +1125 -0
  201. package/src/report/flag.ts +1 -1
  202. package/src/report/format.ts +50 -2
  203. package/src/report/index.ts +48 -14
  204. package/src/report/load.ts +1 -1
  205. package/src/report/locale.ts +206 -0
  206. package/src/report/metrics.ts +39 -15
  207. package/src/report/primitives.tsx +100 -2
  208. package/src/report/react/AttemptList.tsx +115 -0
  209. package/src/report/react/DeltaTable.tsx +9 -6
  210. package/src/report/react/EvalList.tsx +0 -0
  211. package/src/report/react/ExperimentList.tsx +108 -0
  212. package/src/report/react/GroupSummary.tsx +66 -0
  213. package/src/report/react/MetricBars.tsx +24 -13
  214. package/src/report/react/MetricLine.tsx +21 -21
  215. package/src/report/react/MetricMatrix.tsx +8 -4
  216. package/src/report/react/MetricScatter.tsx +136 -96
  217. package/src/report/react/MetricTable.tsx +115 -15
  218. package/src/report/react/RunOverview.tsx +48 -26
  219. package/src/report/react/Scoreboard.tsx +15 -7
  220. package/src/report/react/cell.tsx +18 -9
  221. package/src/{view/app/lib/chart.ts → report/react/chart-math.ts} +2 -6
  222. package/src/report/react/colors.ts +24 -15
  223. package/src/report/react/enhance.js +129 -0
  224. package/src/report/react/fixtures.ts +163 -34
  225. package/src/report/react/format.ts +1 -1
  226. package/src/report/react/index.tsx +22 -6
  227. package/src/report/react/render.test.tsx +164 -31
  228. package/src/report/react/styles.css +405 -213
  229. package/src/report/report.test.ts +403 -94
  230. package/src/report/report.ts +29 -11
  231. package/src/report/text/faces.ts +303 -95
  232. package/src/report/text/layout.ts +76 -11
  233. package/src/report/text/table.ts +98 -0
  234. package/src/report/tree.ts +103 -20
  235. package/src/report/types.ts +196 -25
  236. package/src/report/web.ts +46 -19
  237. package/src/results/annotated-source.test.ts +146 -0
  238. package/src/results/annotated-source.ts +128 -0
  239. package/src/results/attempt-evidence.test.ts +158 -0
  240. package/src/results/attempt-evidence.ts +158 -0
  241. package/src/results/attempt-source.ts +48 -0
  242. package/src/results/copy.ts +154 -139
  243. package/src/results/format.ts +54 -47
  244. package/src/results/host-equivalence.test.ts +527 -0
  245. package/src/results/index.ts +56 -14
  246. package/src/results/locator.test.ts +198 -0
  247. package/src/results/locator.ts +0 -0
  248. package/src/results/open.ts +392 -175
  249. package/src/results/results.test.ts +943 -494
  250. package/src/results/select.ts +129 -23
  251. package/src/results/skipped-notice.ts +0 -0
  252. package/src/results/source-hash.ts +28 -0
  253. package/src/results/types.ts +98 -62
  254. package/src/results/writer.ts +304 -176
  255. package/src/runner/attempt.test.ts +170 -0
  256. package/src/runner/attempt.ts +48 -13
  257. package/src/runner/discover.test.ts +65 -0
  258. package/src/runner/discover.ts +6 -2
  259. package/src/runner/eval-source.test.ts +121 -0
  260. package/src/runner/eval-source.ts +45 -0
  261. package/src/runner/fingerprint.ts +54 -1
  262. package/src/runner/remote-sandbox.ts +1 -1
  263. package/src/runner/report.test.ts +6 -8
  264. package/src/runner/report.ts +5 -4
  265. package/src/runner/reporters/artifacts.ts +27 -41
  266. package/src/runner/reporters/braintrust.test.ts +3 -3
  267. package/src/runner/reporters/braintrust.ts +1 -1
  268. package/src/runner/reporters/console.ts +6 -6
  269. package/src/runner/reporters/json.ts +4 -4
  270. package/src/runner/reporters/live.test.ts +56 -0
  271. package/src/runner/reporters/live.ts +36 -10
  272. package/src/runner/reporters/quiet.test.ts +66 -0
  273. package/src/runner/reporters/quiet.ts +49 -0
  274. package/src/runner/reporters/shared.ts +5 -5
  275. package/src/runner/reporters/table.ts +17 -17
  276. package/src/runner/run.ts +61 -102
  277. package/src/runner/types.ts +43 -32
  278. package/src/sandbox/checkpoint.ts +1 -1
  279. package/src/sandbox/docker.ts +15 -2
  280. package/src/sandbox/e2b.ts +11 -5
  281. package/src/sandbox/errors.ts +13 -0
  282. package/src/sandbox/index.ts +3 -3
  283. package/src/sandbox/local-files.ts +1 -1
  284. package/src/sandbox/registry.ts +6 -5
  285. package/src/sandbox/resolve.ts +38 -23
  286. package/src/sandbox/retry.test.ts +68 -0
  287. package/src/sandbox/retry.ts +44 -0
  288. package/src/sandbox/types.ts +24 -24
  289. package/src/sandbox/vercel.ts +17 -3
  290. package/src/scoring/collector.ts +3 -3
  291. package/src/scoring/judge.ts +2 -2
  292. package/src/scoring/scoped.ts +13 -1
  293. package/src/scoring/types.ts +7 -7
  294. package/src/scoring/verdict.ts +4 -4
  295. package/src/shared/aggregate.ts +23 -3
  296. package/src/shared/types.ts +2 -2
  297. package/src/shared/{outcome.ts → verdict.ts} +15 -15
  298. package/src/show/compose.ts +12 -132
  299. package/src/show/index.ts +125 -94
  300. package/src/show/render.ts +660 -160
  301. package/src/show/show.test.ts +472 -253
  302. package/src/source-loc.ts +1 -1
  303. package/src/tty-line.ts +32 -0
  304. package/src/types.ts +2 -2
  305. package/src/util.test.ts +21 -1
  306. package/src/util.ts +5 -1
  307. package/src/view/app/App.tsx +33 -198
  308. package/src/view/app/components/AttemptModal.tsx +6 -6
  309. package/src/view/app/components/CodeView.tsx +2 -2
  310. package/src/view/app/components/CopyControls.tsx +32 -36
  311. package/src/view/app/components/SkippedRunsBanner.tsx +140 -0
  312. package/src/view/app/components/ui/badge.tsx +1 -1
  313. package/src/view/app/components/ui/dialog.tsx +2 -2
  314. package/src/view/app/i18n.ts +40 -31
  315. package/src/view/app/lib/artifact-url.ts +1 -1
  316. package/src/view/app/lib/attempt-route.test.ts +37 -47
  317. package/src/view/app/lib/attempt-route.ts +26 -26
  318. package/src/view/app/lib/rows.ts +9 -112
  319. package/src/view/app/lib/verdict.ts +25 -0
  320. package/src/view/app/main.tsx +9 -12
  321. package/src/view/app/pages/RunsPage.tsx +4 -4
  322. package/src/view/app/pages/TracesPage.tsx +3 -3
  323. package/src/view/app/shared.ts +0 -1
  324. package/src/view/app/types.ts +6 -51
  325. package/src/view/artifact-serving.test.ts +136 -0
  326. package/src/view/client-dist/app.css +1 -1
  327. package/src/view/client-dist/app.js +21 -21
  328. package/src/view/data.test.ts +214 -143
  329. package/src/view/data.ts +166 -120
  330. package/src/view/index.ts +23 -11
  331. package/src/view/server.ts +52 -17
  332. package/src/view/shared/types.ts +22 -31
  333. package/src/view/styles.css +117 -327
  334. package/src/view/view-report.test.ts +146 -98
  335. package/src/report/default-report.tsx +0 -222
  336. package/src/report/dual-face.test.tsx +0 -527
  337. package/src/report/react/CaseList.tsx +0 -70
  338. package/src/view/app/components/CostScoreChart.tsx +0 -147
  339. package/src/view/app/components/ExperimentTable.tsx +0 -284
  340. package/src/view/app/components/GroupSelector.tsx +0 -61
  341. package/src/view/app/components/primitives.tsx +0 -43
  342. package/src/view/app/lib/outcome.ts +0 -82
@@ -0,0 +1,643 @@
1
+ // Phase 4 验收:证明内置报告 CostPassRateComparison 与一份普通用户 --report 文件走的是
2
+ // 完全同一套机制 —— 不是长得像,而是同一条 build → resolveReportTree → validate → render 管线。
3
+ //
4
+ // 证据链(plan/built-in-reports-user-parity.md「Phase 4」):
5
+ // 1. test/fixtures/report/cost-pass-rate-comparison-public-copy.tsx 只从公开 barrel import,
6
+ // 与 built-in 逐节点同构(见文件头)。
7
+ // 2. 同一 ReportContext 下分别 build + resolveReportTree,深比较 resolved 树:组件类型 / 顺序 /
8
+ // resolved data / props keys 全部结构化相等(不靠 definition 引用相等,两份是独立文件)。
9
+ // 3. 两份分别过 renderReportToText / renderReportToStaticHtml,比较事实(散点键 / 表格行 /
10
+ // attempt 深链),不做 HTML 字面 snapshot。
11
+ // 4. 覆盖 plan 的 9 个必测场景。
12
+ // 5. 架构不变量:renderer 源码零 "CostPassRateComparison" / "built-ins" 分支。
13
+ // 6. show / view 只在「无 --report」分支选内置报告,--report 只换 definition,不碰
14
+ // Selection / resolve / validate / render。
15
+
16
+ import { readFileSync } from "node:fs";
17
+ import { renderToStaticMarkup } from "react-dom/server";
18
+ import { describe, expect, it, vi } from "vitest";
19
+
20
+ import type { EvalResult, Verdict } from "../types.ts";
21
+ import type { AttemptHandle, Results, Selection, Snapshot } from "../results/index.ts";
22
+ import type { ExperimentListItem, ScatterData } from "./types.ts";
23
+ import { encodeAttemptLocator } from "../results/locator.ts";
24
+ import { Col, ExperimentList, defineReport } from "./index.ts";
25
+ import { CostPassRateComparison } from "./built-ins/index.ts";
26
+ import publicCopy from "../../test/fixtures/report/cost-pass-rate-comparison-public-copy.tsx";
27
+ import { renderReportToText } from "./report.ts";
28
+ import { renderReportToStaticHtml } from "./web.ts";
29
+ import {
30
+ createTextContext,
31
+ renderNodeToText,
32
+ resolveReportTree,
33
+ runWithWebContext,
34
+ validateReportTree,
35
+ type ReportNode,
36
+ } from "./tree.ts";
37
+ import * as compute from "./compute.ts";
38
+
39
+ // ───────────────────────── fake Selection / Snapshot(按 results 读取契约造)─────────────────────────
40
+
41
+ // 夹具刻意做成纯确定性:同一 spec 每次都产出逐字节相同的 Snapshot —— 每个场景的 ctx() 被
42
+ // built-in / public-copy 分别调用一次,只有两份快照的身份元组(experimentId / snapshot
43
+ // startedAt / evalId / attempt)完全一致,locatorOf() 兜底算出的 AttemptLocator 才会相同,
44
+ // 跨 definition 的 attempt 深链集合与 resolved data 才可能相等。所以不用任何全局自增计数器。
45
+ function res(id: string, verdict: Verdict, extra: Partial<EvalResult> = {}): EvalResult {
46
+ return {
47
+ id,
48
+ agent: "agent-x",
49
+ verdict,
50
+ attempt: 0,
51
+ startedAt: "2026-07-01T10:00:00Z",
52
+ durationMs: 1000,
53
+ assertions: [],
54
+ ...extra,
55
+ };
56
+ }
57
+
58
+ interface SnapSpec {
59
+ experimentId: string;
60
+ agent: string;
61
+ model?: string;
62
+ results: EvalResult[];
63
+ }
64
+
65
+ function snap(spec: SnapSpec): Snapshot {
66
+ // 每个 experiment 在一次 Selection 里只出现一次,固定 snap-1 目录即可确定且不冲突。
67
+ const dirSuffix = `${spec.experimentId.replace(/\//g, "_")}/snap-1`;
68
+ const base = {
69
+ experimentId: spec.experimentId,
70
+ startedAt: "2026-07-01T10:00:00Z",
71
+ completedAt: "2026-07-01T10:00:30Z",
72
+ agent: spec.agent,
73
+ model: spec.model,
74
+ schemaVersion: 1,
75
+ dir: `/results/${dirSuffix}`,
76
+ };
77
+ const attempts: AttemptHandle[] = spec.results.map((r) => ({
78
+ evalId: r.id,
79
+ experimentId: spec.experimentId,
80
+ result: { ...r, agent: spec.agent },
81
+ ref: { snapshot: dirSuffix, attempt: `${r.id}/a${r.attempt}` },
82
+ snapshot: base as unknown as Snapshot,
83
+ events: async () => null,
84
+ trace: async () => null,
85
+ o11y: async () => null,
86
+ agentSetup: async () => null,
87
+ diff: async () => null,
88
+ sources: async () => null,
89
+ }));
90
+ const evals = new Map<string, AttemptHandle[]>();
91
+ for (const a of attempts) {
92
+ const list = evals.get(a.evalId);
93
+ if (list) list.push(a);
94
+ else evals.set(a.evalId, [a]);
95
+ }
96
+ return {
97
+ ...base,
98
+ evals: [...evals.entries()].map(([id, list]) => ({ id, attempts: list })),
99
+ attempts,
100
+ } as unknown as Snapshot;
101
+ }
102
+
103
+ function selection(snapshots: Snapshot[]): Selection {
104
+ const self: Selection = {
105
+ snapshots,
106
+ warnings: [],
107
+ filter: (predicate) => selection(snapshots.filter(predicate)),
108
+ };
109
+ return self;
110
+ }
111
+
112
+ function ctxOf(sel: Selection): { selection: Selection; results: Results } {
113
+ return { selection: sel, results: {} as Results };
114
+ }
115
+
116
+ // ── 场景夹具 ──
117
+
118
+ /**
119
+ * 场景 1 + 2 + 5 + 6:三个 experiment、两个 agent。
120
+ * - compare/bub-low(bub):algebra/x 两轮(fail→pass,多轮 attempt)、algebra/y 通过,都有成本 → 可画点。
121
+ * - compare/codex-mid(codex):errored + skipped + passed 混合,有估算/实测成本 → 可画点。
122
+ * - solo/no-cost(bub):通过 + 失败,无任何成本 → scatter 缺 x(不可画),表格仍出行。
123
+ */
124
+ function richContext() {
125
+ return ctxOf(
126
+ selection([
127
+ snap({
128
+ experimentId: "compare/bub-low",
129
+ agent: "bub",
130
+ model: "gpt-bub",
131
+ results: [
132
+ res("algebra/x", "failed", { attempt: 0, usage: { inputTokens: 10, outputTokens: 5, costUSD: 0.1 } }),
133
+ res("algebra/x", "passed", { attempt: 1, usage: { inputTokens: 20, outputTokens: 10, costUSD: 0.2 } }),
134
+ res("algebra/y", "passed", { attempt: 0, usage: { inputTokens: 5, outputTokens: 5, costUSD: 0.05 } }),
135
+ ],
136
+ }),
137
+ snap({
138
+ experimentId: "compare/codex-mid",
139
+ agent: "codex",
140
+ model: "gpt-codex",
141
+ results: [
142
+ res("algebra/x", "errored", { error: "adapter crashed", estimatedCostUSD: 0.3 }),
143
+ res("algebra/y", "skipped"),
144
+ res("algebra/z", "passed", { usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.15 } }),
145
+ ],
146
+ }),
147
+ snap({
148
+ experimentId: "solo/no-cost",
149
+ agent: "bub",
150
+ results: [res("algebra/x", "passed"), res("algebra/y", "failed")],
151
+ }),
152
+ ]),
153
+ );
154
+ }
155
+
156
+ /** 场景 3:恰好一个可画点(A 有成本 → 可画;B 无成本 → 不可画)。 */
157
+ function oneDrawableContext() {
158
+ return ctxOf(
159
+ selection([
160
+ snap({
161
+ experimentId: "only/priced",
162
+ agent: "bub",
163
+ results: [res("algebra/x", "passed", { usage: { inputTokens: 1, outputTokens: 1, costUSD: 0.2 } })],
164
+ }),
165
+ snap({
166
+ experimentId: "only/free",
167
+ agent: "codex",
168
+ results: [res("algebra/x", "passed")],
169
+ }),
170
+ ]),
171
+ );
172
+ }
173
+
174
+ /** 场景 4:空 Selection —— 无任何快照,散点 0 可画点走空态,表格无行。 */
175
+ function emptyContext() {
176
+ return ctxOf(selection([]));
177
+ }
178
+
179
+ /** 场景 4 变体:全部 skipped —— 指标全不可测(x/y 全 null),同样走散点空态。 */
180
+ function allSkippedContext() {
181
+ return ctxOf(
182
+ selection([
183
+ snap({ experimentId: "exp/a", agent: "bub", results: [res("algebra/x", "skipped"), res("algebra/y", "skipped")] }),
184
+ ]),
185
+ );
186
+ }
187
+
188
+ const SCENARIOS: { name: string; ctx: () => ReturnType<typeof ctxOf> }[] = [
189
+ { name: "多 experiment/agent、成本与通过率都有值(≥2 可画点)", ctx: richContext },
190
+ { name: "恰好一个可画点", ctx: oneDrawableContext },
191
+ { name: "空 Selection", ctx: emptyContext },
192
+ { name: "全部 skipped(指标全不可测)", ctx: allSkippedContext },
193
+ ];
194
+
195
+ // ───────────────────────── 树遍历工具 ─────────────────────────
196
+
197
+ const FRAGMENT = Symbol.for("react.fragment");
198
+
199
+ function isEl(n: unknown): n is { type: unknown; props: Record<string, unknown> } {
200
+ return typeof n === "object" && n !== null && !Array.isArray(n) && "type" in n && "props" in n;
201
+ }
202
+
203
+ function labelOf(type: unknown): string {
204
+ if (typeof type === "string") return `<${type}>`;
205
+ if (type === FRAGMENT) return "<>";
206
+ if (typeof type === "function") {
207
+ const fn = type as { displayName?: string; name?: string };
208
+ return fn.displayName ?? fn.name ?? "anonymous";
209
+ }
210
+ return String(type);
211
+ }
212
+
213
+ /** 把 resolved 树抹成可结构化比较的事实:组件标签 + props(去掉 children、函数值)+ children。 */
214
+ function factify(node: ReportNode): unknown {
215
+ if (Array.isArray(node)) return node.map(factify);
216
+ if (isEl(node)) {
217
+ const { children, ...rest } = node.props;
218
+ const props = JSON.parse(JSON.stringify(rest, (_k, v) => (typeof v === "function" ? "[fn]" : v)));
219
+ return {
220
+ component: labelOf(node.type),
221
+ props,
222
+ children: children === undefined ? null : factify(children as ReportNode),
223
+ };
224
+ }
225
+ return node ?? null;
226
+ }
227
+
228
+ /** 前序遍历收集组件节点(标签 + 原始 props),用于按顺序 / 按类型取 resolved data。 */
229
+ function collect(node: ReportNode, acc: { label: string; props: Record<string, unknown> }[] = []) {
230
+ if (Array.isArray(node)) {
231
+ for (const c of node) collect(c, acc);
232
+ return acc;
233
+ }
234
+ if (isEl(node)) {
235
+ acc.push({ label: labelOf(node.type), props: node.props });
236
+ collect(node.props.children as ReportNode, acc);
237
+ }
238
+ return acc;
239
+ }
240
+
241
+ async function resolvedTreeOf(def: { build: (c: never) => unknown }, ctx: ReturnType<typeof ctxOf>): Promise<ReportNode> {
242
+ const node = (await (def as { build: (c: unknown) => ReportNode | Promise<ReportNode> }).build(ctx)) as ReportNode;
243
+ return resolveReportTree(node);
244
+ }
245
+
246
+ function scatterOf(tree: ReportNode): ScatterData | undefined {
247
+ return collect(tree).find((c) => c.label === "MetricScatter")?.props.data as ScatterData | undefined;
248
+ }
249
+ /** ExperimentList 没有 selection-form,树里的 props.items 就是 `await ExperimentList.data(selection)` 的产物。 */
250
+ function experimentListOf(tree: ReportNode): ExperimentListItem[] | undefined {
251
+ return collect(tree).find((c) => c.label === "ExperimentList")?.props.items as ExperimentListItem[] | undefined;
252
+ }
253
+
254
+ /** HTML 里所有 attempt 深链,排序后便于集合比较。 */
255
+ function hrefsOf(html: string): string[] {
256
+ return [...html.matchAll(/href="(#\/attempt\/[^"]+)"/g)].map((m) => m[1]).sort();
257
+ }
258
+ /** 散点 web 面每个点的 data-key。 */
259
+ function scatterKeysOf(html: string): string[] {
260
+ return [...html.matchAll(/data-key="([^"]+)"/g)].map((m) => m[1]).sort();
261
+ }
262
+
263
+ /**
264
+ * 这份夹具的 AttemptHandle 都不带 `.locator` 字段(snap() 手工构造,故意留白),
265
+ * report 侧的 locatorOf() 按身份元组兜底算一份——这里复算同一个身份元组,验证深链确实
266
+ * 指向 Selection 里真实存在的那个 attempt,而不是随便一个字符串。
267
+ */
268
+ function expectedLocatorFor(attempt: AttemptHandle): string {
269
+ return encodeAttemptLocator({
270
+ experimentId: attempt.experimentId,
271
+ snapshotStartedAt: attempt.snapshot.startedAt,
272
+ evalId: attempt.evalId,
273
+ attempt: attempt.result.attempt,
274
+ });
275
+ }
276
+
277
+ // ═════════════════════════ 1. built-in 与 public-copy 逐节点同构 ═════════════════════════
278
+
279
+ describe("resolved 树结构化等价(built-in ≡ 包外 public-copy)", () => {
280
+ it("两份是独立 ReportDefinition,不是同一引用", () => {
281
+ expect(CostPassRateComparison).not.toBe(publicCopy);
282
+ expect(typeof CostPassRateComparison.build).toBe("function");
283
+ expect(typeof publicCopy.build).toBe("function");
284
+ });
285
+
286
+ for (const { name, ctx } of SCENARIOS) {
287
+ it(`同一 ReportContext 下 resolved 树逐节点相等 — ${name}`, async () => {
288
+ const c = ctx();
289
+ const builtIn = await resolvedTreeOf(CostPassRateComparison, c);
290
+ const user = await resolvedTreeOf(publicCopy, ctx()); // 各用一份等价 ctx,证明不依赖共享状态
291
+
292
+ // 组件类型与顺序:<Col> 包 [MetricScatter, ExperimentList],别无它物。
293
+ const labels = collect(builtIn).map((n) => n.label);
294
+ expect(labels).toEqual(["Col", "MetricScatter", "ExperimentList"]);
295
+ expect(collect(user).map((n) => n.label)).toEqual(labels);
296
+
297
+ // 逐节点结构化相等:组件类型、顺序、resolved data、props keys(pointHref/filter/…)全一致。
298
+ expect(factify(user)).toEqual(factify(builtIn));
299
+
300
+ // resolved data 本身也逐字段相等(不只是树同构)。
301
+ expect(scatterOf(user)).toEqual(scatterOf(builtIn));
302
+ expect(experimentListOf(user)).toEqual(experimentListOf(builtIn));
303
+ });
304
+ }
305
+ });
306
+
307
+ // ═════════════════════════ 2. 9 个必测场景的 resolved 事实 ═════════════════════════
308
+
309
+ describe("必测场景:resolved data 事实", () => {
310
+ it("场景 1+2:多 experiment/agent,成本与通过率都有值;缺成本的点如实 x=null,实验列表仍出项", async () => {
311
+ const tree = await resolvedTreeOf(CostPassRateComparison, richContext());
312
+ const scatter = scatterOf(tree)!;
313
+ const experiments = experimentListOf(tree)!;
314
+
315
+ // 散点:points=experiment、series=agent、x=cost、y=pass-rate
316
+ expect(scatter.points).toBe("experiment");
317
+ expect(scatter.series).toBe("agent");
318
+ expect(scatter.x.key).toBe("cost");
319
+ expect(scatter.y.key).toBe("pass-rate");
320
+ expect(scatter.rows.map((r) => r.key).sort()).toEqual(["compare/bub-low", "compare/codex-mid", "solo/no-cost"]);
321
+
322
+ const bub = scatter.rows.find((r) => r.key === "compare/bub-low")!;
323
+ expect(bub.series).toBe("bub");
324
+ // 两级聚合:algebra/x=(0.1,0.2)→0.15、algebra/y=0.05 → across mean 0.1;pass (0,1)→0.5、1 → 0.75
325
+ expect(bub.x.value).toBeCloseTo(0.1, 10);
326
+ expect(bub.y.value).toBeCloseTo(0.75, 10);
327
+ const codex = scatter.rows.find((r) => r.key === "compare/codex-mid")!;
328
+ expect(codex.series).toBe("codex");
329
+ expect(codex.x.value).toBeCloseTo(0.225, 10); // errored 0.3 与 passed 0.15,skipped 不计
330
+ expect(codex.y.value).toBeCloseTo(0.5, 10);
331
+
332
+ // 场景 2:solo/no-cost 缺成本 → x 为 null(点在,不可画),y 仍有值
333
+ const solo = scatter.rows.find((r) => r.key === "solo/no-cost")!;
334
+ expect(solo.x.value).toBeNull();
335
+ expect(solo.y.value).toBeCloseTo(0.5, 10);
336
+ // ≥2 可画点
337
+ expect(scatter.rows.filter((r) => r.x.value !== null && r.y.value !== null)).toHaveLength(2);
338
+
339
+ // 实验列表仍为每个 experiment 出一项,包括缺成本的 solo
340
+ expect(experiments.map((e) => e.experimentId).sort()).toEqual(["compare/bub-low", "compare/codex-mid", "solo/no-cost"]);
341
+ expect(experiments.find((e) => e.experimentId === "solo/no-cost")!.cost.value).toBeNull();
342
+ });
343
+
344
+ it("场景 3:恰好一个可画点(另一实验缺成本)", async () => {
345
+ const tree = await resolvedTreeOf(CostPassRateComparison, oneDrawableContext());
346
+ const scatter = scatterOf(tree)!;
347
+ const drawable = scatter.rows.filter((r) => r.x.value !== null && r.y.value !== null);
348
+ expect(drawable).toHaveLength(1);
349
+ expect(drawable[0].key).toBe("only/priced");
350
+ });
351
+
352
+ it("场景 4:空 Selection —— 散点无 rows、实验列表为空数组", async () => {
353
+ const tree = await resolvedTreeOf(CostPassRateComparison, emptyContext());
354
+ expect(scatterOf(tree)!.rows).toEqual([]);
355
+ expect(experimentListOf(tree)).toEqual([]);
356
+ });
357
+
358
+ it("场景 4 变体:全 skipped —— 每个点 x/y 都不可测,0 可画点", async () => {
359
+ const tree = await resolvedTreeOf(CostPassRateComparison, allSkippedContext());
360
+ const scatter = scatterOf(tree)!;
361
+ expect(scatter.rows.every((r) => r.x.value === null && r.y.value === null)).toBe(true);
362
+ expect(scatter.rows.filter((r) => r.x.value !== null && r.y.value !== null)).toHaveLength(0);
363
+ });
364
+
365
+ it("场景 5:failed/errored/skipped 混合 —— ExperimentList 展开区结果摘要正确(eval 级折叠计票)", async () => {
366
+ const tree = await resolvedTreeOf(CostPassRateComparison, richContext());
367
+ const codex = experimentListOf(tree)!.find((e) => e.experimentId === "compare/codex-mid")!;
368
+ // algebra/x errored、algebra/y skipped、algebra/z passed → eval 级折叠 {passed:1, errored:1, skipped:1}
369
+ expect(codex.verdicts).toEqual({ passed: 1, failed: 0, errored: 1, skipped: 1 });
370
+ const errored = codex.evalRows.find((e) => e.evalId === "algebra/x")!;
371
+ expect(errored.verdict).toBe("errored");
372
+ expect(errored.reason).toBe("adapter crashed");
373
+ expect(codex.evalRows.find((e) => e.evalId === "algebra/y")!.verdict).toBe("skipped");
374
+ });
375
+
376
+ it("场景 6:同 experiment 多 eval、多 attempts —— 展开层与 attempt locator 正确", async () => {
377
+ const tree = await resolvedTreeOf(CostPassRateComparison, richContext());
378
+ const bub = experimentListOf(tree)!.find((e) => e.experimentId === "compare/bub-low")!;
379
+ expect(bub.evalRows.map((e) => e.evalId).sort()).toEqual(["algebra/x", "algebra/y"]);
380
+ const multi = bub.evalRows.find((e) => e.evalId === "algebra/x")!;
381
+ // 多轮 attempt 折成 passed(任一轮通过),两次 attempt 都在展开区的 attempts 里
382
+ expect(multi.verdict).toBe("passed");
383
+ expect(multi.attempts.map((a) => a.attempt)).toEqual([0, 1]);
384
+ expect(multi.attempts.map((a) => a.verdict)).toEqual(["failed", "passed"]);
385
+ // attempt locator 唯一、格式正确 —— 回到证据的引用不丢
386
+ const locators = multi.attempts.map((a) => a.locator);
387
+ expect(new Set(locators).size).toBe(2);
388
+ for (const locator of locators) expect(locator).toMatch(/^@1[0-9a-z]{7}$/);
389
+ });
390
+ });
391
+
392
+ // ═════════════════════════ 3. text / web 渲染的事实相同 ═════════════════════════
393
+
394
+ describe("built-in 与 public-copy 渲染出的事实相同", () => {
395
+ for (const { name, ctx } of SCENARIOS) {
396
+ it(`text / web 事实一致 — ${name}`, async () => {
397
+ const builtinText = await renderReportToText(CostPassRateComparison, ctx(), { width: 100 });
398
+ const userText = await renderReportToText(publicCopy, ctx(), { width: 100 });
399
+ // 同一机制、同一 options:text 输出逐字相等(最强的事实级证据)。
400
+ expect(userText).toBe(builtinText);
401
+
402
+ const builtinHtml = await renderReportToStaticHtml(CostPassRateComparison, ctx());
403
+ const userHtml = await renderReportToStaticHtml(publicCopy, ctx());
404
+ // 事实比较(不做整段 HTML snapshot):散点点键、attempt 深链集合相同。
405
+ expect(scatterKeysOf(userHtml)).toEqual(scatterKeysOf(builtinHtml));
406
+ expect(hrefsOf(userHtml)).toEqual(hrefsOf(builtinHtml));
407
+ });
408
+ }
409
+
410
+ it("场景 8:view 的 attemptHref 深链格式与用户 --report 完全一致", async () => {
411
+ const ctx = richContext();
412
+ // renderReportToStaticHtml 默认 attemptHref = view 的 `#/attempt/@<locator>`(不透明单段路由,
413
+ // 与 view/data.ts renderReportSlot 不传 attemptHref 时同一条默认路径)。
414
+ const builtinHtml = await renderReportToStaticHtml(CostPassRateComparison, ctx);
415
+ const userHtml = await renderReportToStaticHtml(publicCopy, richContext());
416
+ const builtinHrefs = hrefsOf(builtinHtml);
417
+ const userHrefs = hrefsOf(userHtml);
418
+ expect(userHrefs).toEqual(builtinHrefs);
419
+ expect(builtinHrefs.length).toBeGreaterThan(0);
420
+ for (const href of builtinHrefs) {
421
+ expect(href).toMatch(/^#\/attempt\/@[0-9a-z]+$/);
422
+ }
423
+ // 每个深链都指向 Selection 里真实存在的 attempt(身份元组确定性派生同一个 locator)。
424
+ const realDeepLinks = new Set(
425
+ ctx.selection.snapshots.flatMap((s) => s.attempts.map((a) => `#/attempt/${expectedLocatorFor(a)}`)),
426
+ );
427
+ for (const href of builtinHrefs) expect(realDeepLinks.has(href)).toBe(true);
428
+ // errored eval 一定被深链(codex-mid 的 algebra/x)
429
+ const erroredAttempt = ctx.selection.snapshots
430
+ .flatMap((s) => s.attempts)
431
+ .find((a) => a.experimentId === "compare/codex-mid" && a.evalId === "algebra/x")!;
432
+ expect(builtinHrefs).toContain(`#/attempt/${expectedLocatorFor(erroredAttempt)}`);
433
+ });
434
+ });
435
+
436
+ // ═════════════════════════ 4. 散点空 / 单点 / 多点行为(text + web 同一事实)═════════════════════════
437
+
438
+ describe("散点空态 / 单点 / 多点行为", () => {
439
+ it("0 可画点(空 Selection):text 说 No data、web 走 nre-scatter-empty nre-missing", async () => {
440
+ const text = await renderReportToText(CostPassRateComparison, emptyContext(), { width: 100 });
441
+ expect(text).toContain("No data to plot");
442
+ expect(text).not.toContain("better → upper right");
443
+ const html = await renderReportToStaticHtml(CostPassRateComparison, emptyContext());
444
+ expect(html).toContain("nre-scatter-empty");
445
+ expect(html).toContain("nre-missing");
446
+ });
447
+
448
+ it("恰好 1 可画点:text/web 都说「至少 2 个实验」而不是画孤点", async () => {
449
+ const text = await renderReportToText(CostPassRateComparison, oneDrawableContext(), { width: 100 });
450
+ expect(text).toContain("At least 2 experiments needed to compare");
451
+ expect(text).not.toContain("better → upper right");
452
+ const html = await renderReportToStaticHtml(CostPassRateComparison, oneDrawableContext());
453
+ expect(html).toContain("nre-scatter-empty");
454
+ // 单点不是「缺数据」空态:散点空态本身不带 nre-missing 变体类(与 0 可画点的
455
+ // "nre-scatter-empty nre-missing" 区分开)。ExperimentList 展开区自己的缺成本格子
456
+ // 也合法渲染 nre-missing,不属于这条散点专属的断言范围。
457
+ expect(html).not.toContain("nre-scatter-empty nre-missing");
458
+ expect(html).not.toContain("<svg");
459
+ });
460
+
461
+ it("≥2 可画点:正常绘图(text 有 better → upper right,web 有 svg)", async () => {
462
+ const text = await renderReportToText(CostPassRateComparison, richContext(), { width: 100 });
463
+ expect(text).toContain("better → upper right");
464
+ const html = await renderReportToStaticHtml(CostPassRateComparison, richContext());
465
+ expect(html).toContain("<svg");
466
+ expect(html).not.toContain("nre-scatter-empty");
467
+ });
468
+ });
469
+
470
+ // ═════════════════════════ 5. 场景 7:数据只 resolve 一次,locale 只改 chrome ═════════════════════════
471
+
472
+ describe("场景 7:en / zh-CN 数据 resolve 一次、chrome 分别本地化", () => {
473
+ it("resolve 一次的同一棵树,en / zh-CN 各渲染一遍:data 事实相同,只有 chrome 文案不同", async () => {
474
+ // 关键:build + resolveReportTree 只做一次,拿到唯一的 resolved 树;两个 locale 复用它。
475
+ const node = (await CostPassRateComparison.build(richContext())) as ReportNode;
476
+ const resolved = await resolveReportTree(node);
477
+ validateReportTree(resolved);
478
+
479
+ // 同一棵 resolved 树 → data 只算了一次(下方 spy 版另证调用次数)。
480
+ const scatter = scatterOf(resolved)!;
481
+ const experiments = experimentListOf(resolved)!;
482
+
483
+ const enText = renderNodeToText(resolved, createTextContext({ width: 100, locale: "en" }));
484
+ const zhText = renderNodeToText(resolved, createTextContext({ width: 100, locale: "zh-CN" }));
485
+ const enHtml = runWithWebContext(
486
+ { attemptHref: (locator) => `#/attempt/${locator}`, locale: "en" },
487
+ () => renderToStaticMarkup(resolved as never),
488
+ );
489
+ const zhHtml = runWithWebContext(
490
+ { attemptHref: (locator) => `#/attempt/${locator}`, locale: "zh-CN" },
491
+ () => renderToStaticMarkup(resolved as never),
492
+ );
493
+
494
+ // chrome 分语言
495
+ expect(enText).toContain("Pass rate");
496
+ expect(zhText).toContain("成功率");
497
+ expect(enHtml).toContain("Pass rate");
498
+ expect(zhHtml).toContain("成功率");
499
+ expect(enText).not.toEqual(zhText);
500
+
501
+ // 但两语言下的事实(散点键 / x / y、attempt 深链)完全相同 —— 数据没被重算成不同结果。
502
+ expect(scatterKeysOf(zhHtml)).toEqual(scatterKeysOf(enHtml));
503
+ expect(hrefsOf(zhHtml)).toEqual(hrefsOf(enHtml));
504
+ // 从同一棵 resolved 树取的 data,en/zh 渲染不改变它
505
+ expect(scatter.rows.map((r) => r.key)).toEqual(["compare/bub-low", "compare/codex-mid", "solo/no-cost"]);
506
+ const [bub, codex, solo] = scatter.rows;
507
+ expect(bub.x.value).toBeCloseTo(0.1, 10);
508
+ expect(bub.y.value).toBeCloseTo(0.75, 10);
509
+ expect(codex.x.value).toBeCloseTo(0.225, 10);
510
+ expect(codex.y.value).toBeCloseTo(0.5, 10);
511
+ expect(solo.x.value).toBeNull();
512
+ expect(solo.y.value).toBeCloseTo(0.5, 10);
513
+ expect(experiments.map((e) => e.experimentId)).toEqual(["compare/bub-low", "compare/codex-mid", "solo/no-cost"]);
514
+ });
515
+
516
+ it("spy 佐证:build() 里各计算函数恰好调用一次,随后两 locale 渲染不再重算", async () => {
517
+ const scatterSpy = vi.spyOn(compute, "scatterData");
518
+ // 注意:spy 挂在 ExperimentList.data 本身,不是 compute.experimentListData —— components.tsx
519
+ // 用 `Object.assign(component, { data: experimentListData })` 把函数值复制成一个普通属性,
520
+ // 那份拷贝在 components.tsx 求值时(模块加载期)就定死了,晚于此的 vi.spyOn(compute, …) патch
521
+ // 不到已经复制走的引用。直接调用方(built-ins/cost-pass-rate-comparison.tsx)读的是
522
+ // `ExperimentList.data` 这个属性,所以要在这个属性上打桩。MetricScatter 不受影响:它的
523
+ // resolve() 住在 components.tsx 里,调用的是 scatterData 这个 import 绑定本身(ESM 实时绑定),
524
+ // vi.spyOn(compute, "scatterData") 打得中。
525
+ const experimentListSpy = vi.spyOn(ExperimentList, "data");
526
+ try {
527
+ // ExperimentList 没有 resolve 面,.data() 直接在 build() 里 await——计算发生在 build,
528
+ // 不是 resolveReportTree;MetricScatter 仍是 selection-form,resolve 阶段才调它的 .data()。
529
+ const node = (await CostPassRateComparison.build(richContext())) as ReportNode;
530
+ expect(experimentListSpy).toHaveBeenCalledTimes(1);
531
+ const resolved = await resolveReportTree(node);
532
+ expect(scatterSpy).toHaveBeenCalledTimes(1);
533
+ expect(experimentListSpy).toHaveBeenCalledTimes(1);
534
+
535
+ // 渲染面纯同步、零 IO:两 locale 渲染同一棵 resolved 树,不再触发任何计算。
536
+ renderNodeToText(resolved, createTextContext({ locale: "en" }));
537
+ renderNodeToText(resolved, createTextContext({ locale: "zh-CN" }));
538
+ expect(scatterSpy).toHaveBeenCalledTimes(1);
539
+ expect(experimentListSpy).toHaveBeenCalledTimes(1);
540
+ } finally {
541
+ scatterSpy.mockRestore();
542
+ experimentListSpy.mockRestore();
543
+ }
544
+ });
545
+ });
546
+
547
+ // ═════════════════════════ 6. 场景 9:只放 ExperimentList 的最小报告不触发散点计算 ═════════════════════════
548
+
549
+ describe("场景 9:最小用户报告只放 <ExperimentList> —— 不计算散点", () => {
550
+ const minimal = defineReport(async ({ selection }) => {
551
+ const experiments = await ExperimentList.data(selection);
552
+ return (
553
+ <Col>
554
+ <ExperimentList items={experiments} />
555
+ </Col>
556
+ );
557
+ });
558
+
559
+ it("结构性缺席:resolved 树里没有 MetricScatter 节点", async () => {
560
+ const tree = await resolvedTreeOf(minimal, richContext());
561
+ expect(collect(tree).map((n) => n.label)).toEqual(["Col", "ExperimentList"]);
562
+ expect(scatterOf(tree)).toBeUndefined();
563
+ expect(experimentListOf(tree)).toBeDefined();
564
+ });
565
+
566
+ it("spy 佐证:scatterData 从未被调用,experimentListData 调一次", async () => {
567
+ const scatterSpy = vi.spyOn(compute, "scatterData");
568
+ // 见上一个 describe 块的注释:直接调用方读的是 ExperimentList.data 属性,打桩要打在这上面。
569
+ const experimentListSpy = vi.spyOn(ExperimentList, "data");
570
+ try {
571
+ await resolvedTreeOf(minimal, richContext());
572
+ expect(scatterSpy).not.toHaveBeenCalled();
573
+ expect(experimentListSpy).toHaveBeenCalledTimes(1);
574
+ } finally {
575
+ scatterSpy.mockRestore();
576
+ experimentListSpy.mockRestore();
577
+ }
578
+ });
579
+
580
+ it("用户换指标/维度无需框架新增分支:改 points/series 只改 resolved data,不改机制", async () => {
581
+ // 一份「用户自定义」报告:同一 MetricScatter 组件,换成 model 维度 + 只画一个指标轴组合。
582
+ const custom = defineReport(async ({ selection }) => {
583
+ const { MetricScatter, costUSD, tokens } = await import("./index.ts");
584
+ return (
585
+ <Col>
586
+ <MetricScatter selection={selection} points="agent" series="model" x={tokens} y={costUSD} />
587
+ </Col>
588
+ );
589
+ });
590
+ const tree = await resolvedTreeOf(custom, richContext());
591
+ const scatter = scatterOf(tree)!;
592
+ expect(scatter.points).toBe("agent");
593
+ expect(scatter.series).toBe("model");
594
+ expect(scatter.x.key).toBe("tokens");
595
+ expect(scatter.y.key).toBe("cost");
596
+ // 走的仍是同一条 resolve 管线,不需要任何 built-in 专属处理。
597
+ expect(collect(tree).map((n) => n.label)).toEqual(["Col", "MetricScatter"]);
598
+ });
599
+ });
600
+
601
+ // ═════════════════════════ 7. 架构不变量:renderer 无 built-in 分支 ═════════════════════════
602
+
603
+ describe("架构不变量:renderer 不认识 built-in", () => {
604
+ const RENDERER_FILES = ["./report.ts", "./web.ts", "./tree.ts"];
605
+ for (const rel of RENDERER_FILES) {
606
+ it(`${rel} 源码里没有 "CostPassRateComparison" / "built-ins" 分支`, () => {
607
+ const src = readFileSync(new URL(rel, import.meta.url), "utf8");
608
+ expect(src).not.toContain("CostPassRateComparison");
609
+ expect(src).not.toContain("built-ins");
610
+ });
611
+ }
612
+ });
613
+
614
+ // ═════════════════════════ 8. 宿主选择:show 索引与可替换报告槽分开 ═════════════════════════
615
+
616
+ describe("宿主选择:裸 show 是诊断索引,--report 与 view 走报告 definition", () => {
617
+ it("show / view 源码:裸 show 选择专用索引,--report 才装载 definition", () => {
618
+ const showSrc = readFileSync(new URL("../show/index.ts", import.meta.url), "utf8");
619
+ expect(showSrc).toMatch(/flags\.report === undefined[\s\S]*showIndexText\(selection, io\.width\)/);
620
+ expect(showSrc).toMatch(/const definition = await loadReportFile\(cwd, flags\.report\)/);
621
+ expect(showSrc).not.toMatch(/import \{ CostPassRateComparison \}/);
622
+ // Selection 在报告槽之前、与 report 无关地由 selectCurrentResults 合成。
623
+ expect(showSrc).toMatch(/selectCurrentResults\(results, \{ experiment: flags\.experiment, patterns \}\)/);
624
+
625
+ const viewSrc = readFileSync(new URL("../view/data.ts", import.meta.url), "utf8");
626
+ // view 的报告槽:report 有值走 loadReportFile,否则 CostPassRateComparison;Selection 同样先于报告槽合成。
627
+ expect(viewSrc).toMatch(/report\s*\?[\s\S]*loadReportFile[\s\S]*:\s*\(await import\([^)]*built-ins[^)]*\)\)\.CostPassRateComparison/);
628
+ expect(viewSrc).toMatch(/selectCurrentResults\(results, \{ experiment: opts\.experiment, patterns \}\)/);
629
+ });
630
+
631
+ it("CostPassRateComparison 与 public-copy 作为显式报告时事实完全相同", async () => {
632
+ for (const { ctx } of SCENARIOS) {
633
+ const bareText = await renderReportToText(CostPassRateComparison, ctx(), { width: 100 });
634
+ const reportText = await renderReportToText(publicCopy, ctx(), { width: 100 });
635
+ expect(reportText).toBe(bareText);
636
+
637
+ const bareHtml = await renderReportToStaticHtml(CostPassRateComparison, ctx());
638
+ const reportHtml = await renderReportToStaticHtml(publicCopy, ctx());
639
+ expect(scatterKeysOf(reportHtml)).toEqual(scatterKeysOf(bareHtml));
640
+ expect(hrefsOf(reportHtml)).toEqual(hrefsOf(bareHtml));
641
+ }
642
+ });
643
+ });
@@ -0,0 +1,23 @@
1
+ // CostPassRateComparison:show / view 裸跑时的出厂报告槽。
2
+ //
3
+ // 它是一份普通 ReportDefinition,零特权:只声明「摆什么」——成本 × 通过率的散点(比较
4
+ // experiments)和实验列表。散点走 selection-form(`MetricScatter` 声明了 `resolve`,宿主
5
+ // 渲染前的 resolveReportTree 会替它调 `.data`);`ExperimentList` 没有 selection-form,
6
+ // 报告函数体里直接 `await ExperimentList.data(selection)` 拿到普通数组再传 `items`——
7
+ // 这正是三个实体列表统一的用法,内置默认报告不搞一套特权写法。包外用户复制这段 TSX
8
+ // (只改 import 路径与 export 形式)会走完全相同的解析与渲染管线。
9
+
10
+ import { Col } from "../primitives.tsx";
11
+ import { ExperimentList, MetricScatter } from "../components.tsx";
12
+ import { costUSD, passRate } from "../metrics.ts";
13
+ import { defineReport } from "../report.ts";
14
+
15
+ export const CostPassRateComparison = defineReport(async ({ selection }) => {
16
+ const experiments = await ExperimentList.data(selection);
17
+ return (
18
+ <Col>
19
+ <MetricScatter selection={selection} points="experiment" series="agent" x={costUSD} y={passRate} />
20
+ <ExperimentList items={experiments} />
21
+ </Col>
22
+ );
23
+ });
@@ -0,0 +1,2 @@
1
+ // 内置报告目录:一个文件一个按展示内容命名的普通 ReportDefinition,显式导出值,不建字符串 registry。
2
+ export { CostPassRateComparison } from "./cost-pass-rate-comparison.tsx";