niceeval 0.6.2 → 0.7.1
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.
- package/INDEX.md +23 -23
- package/README.zh.md +6 -6
- package/dist/agents/types.d.ts +2 -2
- package/dist/i18n/zh-CN.d.ts +3 -3
- package/dist/report/aggregate.d.ts +32 -26
- package/dist/report/aggregate.js +157 -76
- package/dist/report/built-in/index.d.ts +2 -0
- package/dist/report/built-in/index.js +8 -0
- package/dist/report/components.d.ts +91 -164
- package/dist/report/components.js +377 -114
- package/dist/report/compute.d.ts +86 -73
- package/dist/report/compute.js +592 -432
- package/dist/report/flag.d.ts +28 -17
- package/dist/report/flag.js +86 -16
- package/dist/report/format.d.ts +11 -11
- package/dist/report/format.js +17 -15
- package/dist/report/index.d.ts +16 -17
- package/dist/report/index.js +20 -22
- package/dist/report/load.js +3 -2
- package/dist/report/locale.d.ts +49 -34
- package/dist/report/locale.js +106 -58
- package/dist/report/metrics.d.ts +10 -3
- package/dist/report/metrics.js +46 -12
- package/dist/report/primitives.d.ts +42 -15
- package/dist/report/primitives.js +135 -26
- package/dist/report/react/AttemptList.d.ts +10 -8
- package/dist/report/react/AttemptList.js +18 -10
- package/dist/report/react/DeltaTable.js +19 -18
- package/dist/report/react/EvalList.d.ts +3 -3
- package/dist/report/react/EvalList.js +0 -0
- package/dist/report/react/ExperimentComparison.d.ts +4 -2
- package/dist/report/react/ExperimentComparison.js +5 -4
- package/dist/report/react/ExperimentList.d.ts +3 -3
- package/dist/report/react/ExperimentList.js +16 -15
- package/dist/report/react/MetricBars.js +5 -4
- package/dist/report/react/MetricLine.js +12 -5
- package/dist/report/react/MetricMatrix.js +1 -1
- package/dist/report/react/MetricScatter.js +54 -17
- package/dist/report/react/MetricTable.js +2 -12
- package/dist/report/react/ScopeSummary.d.ts +10 -0
- package/dist/report/react/ScopeSummary.js +17 -0
- package/dist/report/react/Scoreboard.js +6 -6
- package/dist/report/react/cell.js +2 -2
- package/dist/report/react/fixtures.d.ts +5 -9
- package/dist/report/react/fixtures.js +105 -149
- package/dist/report/react/index.d.ts +15 -5
- package/dist/report/react/index.js +18 -7
- package/dist/report/report.d.ts +137 -20
- package/dist/report/report.js +261 -34
- package/dist/report/text/faces.d.ts +17 -19
- package/dist/report/text/faces.js +225 -157
- package/dist/report/text/plot.js +1 -1
- package/dist/report/text/table.js +2 -2
- package/dist/report/tree.d.ts +90 -40
- package/dist/report/tree.js +252 -94
- package/dist/report/types.d.ts +245 -300
- package/dist/report/types.js +4 -3
- package/dist/report/web.d.ts +21 -5
- package/dist/report/web.js +42 -16
- package/dist/results/select.d.ts +38 -16
- package/dist/results/select.js +73 -25
- package/dist/results/types.d.ts +38 -14
- package/dist/shared/aggregate.d.ts +3 -2
- package/dist/shared/aggregate.js +5 -4
- package/docs-site/zh/README.md +44 -0
- package/docs-site/zh/examples/ai-agent-application.mdx +63 -0
- package/docs-site/zh/examples/coding-agent-extensions.mdx +57 -0
- package/docs-site/zh/examples/index.mdx +50 -0
- package/docs-site/zh/{concepts → explanation}/adapter.mdx +11 -11
- package/docs-site/zh/{concepts → explanation}/assert.mdx +7 -7
- package/docs-site/zh/{concepts → explanation}/drive.mdx +8 -8
- package/docs-site/zh/{concepts → explanation}/evals.mdx +4 -4
- package/docs-site/zh/{concepts → explanation}/experiment.mdx +8 -8
- package/docs-site/zh/{concepts → explanation}/hitl.mdx +8 -8
- package/docs-site/zh/{concepts → explanation}/judge.mdx +5 -5
- package/docs-site/zh/{concepts → explanation}/overview.mdx +5 -5
- package/docs-site/zh/{guides → explanation}/runner.mdx +1 -1
- package/docs-site/zh/{concepts → explanation}/tier.mdx +6 -6
- package/docs-site/zh/{guides → how-to}/agent-feedback-loop.mdx +7 -7
- package/docs-site/zh/{guides → how-to}/authoring.mdx +2 -2
- package/docs-site/zh/{guides → how-to}/connect-otel.mdx +6 -6
- package/docs-site/zh/{guides → how-to}/connect-your-agent.mdx +18 -18
- package/docs-site/zh/{guides → how-to}/custom-reports.mdx +6 -6
- package/docs-site/zh/{guides → how-to}/experiments.mdx +3 -3
- package/docs-site/zh/{guides → how-to}/publish-report.mdx +2 -2
- package/docs-site/zh/{guides → how-to}/sandbox-agent.mdx +2 -2
- package/docs-site/zh/{guides → how-to}/sandbox-providers.mdx +1 -1
- package/docs-site/zh/{guides → how-to}/viewing-results.mdx +6 -6
- package/docs-site/zh/{guides → how-to}/write-experiment.mdx +3 -3
- package/docs-site/zh/{guides → how-to}/write-send.mdx +13 -13
- package/docs-site/zh/index.mdx +23 -25
- package/docs-site/zh/introduction.mdx +8 -8
- package/docs-site/zh/reference/builtin-agents.mdx +5 -5
- package/docs-site/zh/reference/capabilities.mdx +6 -6
- package/docs-site/zh/reference/cli.mdx +9 -7
- package/docs-site/zh/reference/define-agent.mdx +1 -1
- package/docs-site/zh/reference/events.mdx +3 -3
- package/docs-site/zh/{guides → reference}/official-adapters.mdx +7 -7
- package/docs-site/zh/{guides → reference}/report-components.mdx +5 -5
- package/docs-site/zh/{guides → reference}/results-data.mdx +5 -5
- package/docs-site/zh/{guides → troubleshooting}/debug-sandbox.mdx +2 -2
- package/docs-site/zh/{guides → troubleshooting}/debugging.mdx +4 -2
- package/docs-site/zh/{quickstart.mdx → tutorials/quickstart.mdx} +5 -17
- package/package.json +6 -2
- package/src/agents/index.ts +2 -2
- package/src/agents/openai-compat.ts +1 -1
- package/src/agents/streaming.ts +2 -2
- package/src/agents/types.ts +3 -3
- package/src/cli.ts +42 -23
- package/src/context/context.ts +1 -1
- package/src/context/session.test.ts +1 -1
- package/src/context/session.ts +1 -1
- package/src/i18n/en.ts +18 -16
- package/src/i18n/zh-CN.ts +16 -15
- package/src/report/aggregate.ts +175 -87
- package/src/report/built-in/index.tsx +9 -0
- package/src/report/components.tsx +625 -285
- package/src/report/compute.ts +717 -515
- package/src/report/dual-render.test.tsx +738 -1148
- package/src/report/flag.ts +97 -33
- package/src/report/format.ts +18 -22
- package/src/report/index.ts +113 -58
- package/src/report/load.ts +3 -2
- package/src/report/locale.ts +120 -69
- package/src/report/metrics.ts +42 -12
- package/src/report/primitives.tsx +190 -45
- package/src/report/react/AttemptList.tsx +32 -20
- package/src/report/react/DeltaTable.tsx +63 -45
- package/src/report/react/EvalList.tsx +0 -0
- package/src/report/react/ExperimentComparison.tsx +12 -7
- package/src/report/react/ExperimentList.tsx +38 -26
- package/src/report/react/MetricBars.tsx +5 -4
- package/src/report/react/MetricLine.tsx +13 -8
- package/src/report/react/MetricMatrix.tsx +2 -2
- package/src/report/react/MetricScatter.tsx +74 -20
- package/src/report/react/MetricTable.tsx +4 -76
- package/src/report/react/ScopeSummary.tsx +86 -0
- package/src/report/react/Scoreboard.tsx +28 -10
- package/src/report/react/cell.tsx +2 -2
- package/src/report/react/enhance.js +57 -5
- package/src/report/react/fixtures.ts +109 -156
- package/src/report/react/index.tsx +24 -39
- package/src/report/react/render.test.tsx +139 -104
- package/src/report/react/styles.css +181 -91
- package/src/report/report.test.ts +761 -1031
- package/src/report/report.ts +425 -47
- package/src/report/text/faces.ts +257 -164
- package/src/report/text/plot.ts +1 -1
- package/src/report/text/table.ts +2 -2
- package/src/report/tree.ts +362 -104
- package/src/report/types.ts +257 -287
- package/src/report/web.ts +63 -20
- package/src/results/attempt-evidence.test.ts +4 -4
- package/src/results/attempt-evidence.ts +5 -5
- package/src/results/copy.ts +6 -6
- package/src/results/host-equivalence.test.ts +26 -14
- package/src/results/index.ts +10 -4
- package/src/results/open.ts +8 -4
- package/src/results/results.test.ts +4 -3
- package/src/results/select.ts +104 -34
- package/src/results/types.ts +36 -14
- package/src/runner/feedback/human.test.ts +1 -1
- package/src/runner/run.ts +1 -1
- package/src/sandbox/cli-commands.ts +2 -2
- package/src/scoring/judge.test.ts +1 -1
- package/src/shared/aggregate.ts +5 -4
- package/src/show/compose.ts +50 -67
- package/src/show/index.ts +107 -56
- package/src/show/render.ts +43 -27
- package/src/show/report-host.test.ts +188 -0
- package/src/show/report-host.ts +375 -0
- package/src/show/show.test.ts +86 -36
- package/src/view/app/App.test.tsx +69 -0
- package/src/view/app/App.tsx +144 -48
- package/src/view/app/components/AttemptModal.tsx +324 -63
- package/src/view/app/components/CodeView.tsx +10 -4
- package/src/view/app/i18n.ts +31 -17
- package/src/view/app/lib/artifact-url.ts +14 -3
- package/src/view/app/main.tsx +13 -8
- package/src/view/app/pages/{RunsPage.tsx → AttemptsPage.tsx} +6 -6
- package/src/view/app/types.ts +4 -1
- package/src/view/artifact-serving.test.ts +21 -1
- package/src/view/client-dist/app.css +1 -1
- package/src/view/client-dist/app.js +6 -6
- package/src/view/data.test.ts +9 -3
- package/src/view/data.ts +145 -49
- package/src/view/index.ts +48 -44
- package/src/view/server.ts +35 -15
- package/src/view/shared/types.ts +34 -5
- package/src/view/styles.css +224 -0
- package/src/view/view-report.test.ts +161 -57
- package/dist/report/built-ins/experiment-comparison.d.ts +0 -39
- package/dist/report/built-ins/experiment-comparison.js +0 -119
- package/dist/report/built-ins/index.d.ts +0 -2
- package/dist/report/built-ins/index.js +0 -2
- package/dist/report/react/GroupSummary.d.ts +0 -8
- package/dist/report/react/GroupSummary.js +0 -8
- package/dist/report/react/RunOverview.d.ts +0 -8
- package/dist/report/react/RunOverview.js +0 -12
- package/docs-site/zh/example/ai-agent-application.mdx +0 -152
- package/docs-site/zh/example/claude-code-codex-plugin.mdx +0 -167
- package/docs-site/zh/example/claude-code-codex-skill.mdx +0 -152
- package/docs-site/zh/example/showcase.mdx +0 -39
- package/src/report/built-in-user-parity.test.tsx +0 -597
- package/src/report/built-ins/experiment-comparison.tsx +0 -179
- package/src/report/built-ins/index.ts +0 -7
- package/src/report/react/GroupSummary.tsx +0 -66
- package/src/report/react/RunOverview.tsx +0 -109
- /package/docs-site/zh/{example/tier1-ai-sdk-v7.mdx → examples/integrations/ai-sdk-v7.mdx} +0 -0
- /package/docs-site/zh/{example/tier1-claude-sdk.mdx → examples/integrations/claude-sdk.mdx} +0 -0
- /package/docs-site/zh/{example/tier1-codex-sdk.mdx → examples/integrations/codex-sdk.mdx} +0 -0
- /package/docs-site/zh/{example/tier1-langgraph.mdx → examples/integrations/langgraph.mdx} +0 -0
- /package/docs-site/zh/{example/tier1-pi-sdk.mdx → examples/integrations/pi-sdk.mdx} +0 -0
- /package/docs-site/zh/{guides → how-to}/ci-integration.mdx +0 -0
- /package/docs-site/zh/{guides → how-to}/dataset-fanout.mdx +0 -0
- /package/docs-site/zh/{guides → how-to}/fixtures.mdx +0 -0
- /package/docs-site/zh/{guides → how-to}/reporters.mdx +0 -0
- /package/docs-site/zh/{guides → how-to}/scoring-guide.mdx +0 -0
package/src/report/text/plot.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// text 面的字符坐标图:MetricScatter / MetricLine 共用。
|
|
2
|
-
// 形态照 docs-site/zh/
|
|
2
|
+
// 形态照 docs-site/zh/reference/report-components.mdx 的示例块:y 轴刻度 + │ 边框,
|
|
3
3
|
// 点用字母标注、图例列在图下;lower-better 的轴反向,「好」的角落恒在右上。
|
|
4
4
|
|
|
5
5
|
import { padDisplay, padStartDisplay, stringWidth } from "./layout.ts";
|
package/src/report/text/table.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { TableProps, TableRow } from "../primitives.tsx";
|
|
10
10
|
import type { TextContext } from "../tree.ts";
|
|
11
|
-
import { countText, localeText } from "../locale.ts";
|
|
11
|
+
import { countText, localeText, resolveLocalizedText } from "../locale.ts";
|
|
12
12
|
import { charDisplayWidth, renderAlignedRows, stringWidth, wrapDisplay, type ColumnAlign } from "./layout.ts";
|
|
13
13
|
|
|
14
14
|
const MISSING_MARK = "—";
|
|
@@ -110,7 +110,7 @@ export function renderTableText(props: TableProps, ctx: TextContext): string {
|
|
|
110
110
|
const hasLocator = props.rows.some((row) => row.locator !== undefined);
|
|
111
111
|
if (props.columns.length === 0 && !hasLocator) return "";
|
|
112
112
|
|
|
113
|
-
const header = props.columns.map((column) => column.header);
|
|
113
|
+
const header = props.columns.map((column) => resolveLocalizedText(column.header, locale));
|
|
114
114
|
const align: ColumnAlign[] = props.columns.map((column) => column.align ?? "left");
|
|
115
115
|
const body = props.rows.map((row) => props.columns.map((column) => cellText(row, column.key)));
|
|
116
116
|
if (hasLocator) {
|
package/src/report/tree.ts
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
|
-
//
|
|
1
|
+
// 报告的元素树、组件模型与 resolve 管线(docs/feature/reports/architecture.md「组件模型」
|
|
2
|
+
// 「报告树与两个宿主」、docs/feature/reports/library/layout.md)。
|
|
2
3
|
//
|
|
3
4
|
// 报告函数返回的树不是「React 树」,只是 { type, props } 节点 —— 标准 react
|
|
4
5
|
// jsx-runtime 产的元素恰好就是这个形状。本文件是基础实现:零 react 运行时依赖
|
|
5
6
|
// (只有类型层的 `import type`,编译后擦除);text 宿主遍历渲染不需要 react-dom,
|
|
6
|
-
// web 宿主(web.ts)才真正 import react
|
|
7
|
-
//
|
|
7
|
+
// web 宿主(web.ts)才真正 import react。管线固定为 装载 → resolve(组合展开 + spec 取数,
|
|
8
|
+
// 同层并行保声明序,按「同引用 input + 深相等 spec」记忆化)→ validate(两面资格)→
|
|
9
|
+
// render(纯同步)。渲染面是纯同步函数:零 IO、零 await —— 可达百 MB 的 artifact
|
|
10
|
+
// 只在 resolve 阶段被懒加载,永远不进渲染路径。
|
|
8
11
|
|
|
9
12
|
import type { ReactNode } from "react";
|
|
10
13
|
import type { AttemptLocator } from "../results/locator.ts";
|
|
14
|
+
import type { Results, Scope } from "../results/types.ts";
|
|
11
15
|
import { DEFAULT_REPORT_LOCALE, type ReportLocale } from "./locale.ts";
|
|
16
|
+
import type { ReportInput } from "./types.ts";
|
|
17
|
+
import type { ReportMeta } from "./report.ts";
|
|
12
18
|
|
|
13
19
|
// ───────────────────────── 节点形状 ─────────────────────────
|
|
14
20
|
|
|
@@ -19,8 +25,12 @@ export interface ReportElement {
|
|
|
19
25
|
key?: unknown;
|
|
20
26
|
}
|
|
21
27
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
28
|
+
/**
|
|
29
|
+
* 报告树节点,形状穷尽(docs/feature/reports/library/layout.md「树的节点」):
|
|
30
|
+
* 元素、数组 / Fragment(展平保序),或条件渲染的空分支(渲染为空)。
|
|
31
|
+
* 裸字符串与数字**不是**节点——自由文本必须经 <Text> 携带,树校验遇到时按完整用户反馈拒绝。
|
|
32
|
+
*/
|
|
33
|
+
export type ReportNode = ReportElement | readonly ReportNode[] | null | undefined | boolean;
|
|
24
34
|
|
|
25
35
|
// react/jsx-runtime 的 Fragment 是注册符号,跨 react 版本稳定;不 import react 也认得它
|
|
26
36
|
const REACT_FRAGMENT = Symbol.for("react.fragment");
|
|
@@ -36,10 +46,19 @@ function isReportElement(node: unknown): node is ReportElement {
|
|
|
36
46
|
);
|
|
37
47
|
}
|
|
38
48
|
|
|
39
|
-
// ─────────────────────────
|
|
49
|
+
// ───────────────────────── 组件模型 ─────────────────────────
|
|
40
50
|
|
|
41
|
-
/** 挂 faces
|
|
51
|
+
/** 挂 faces 的私有键:管线与树校验靠它识别双面组件。 */
|
|
42
52
|
export const COMPONENT_FACES: unique symbol = Symbol.for("niceeval.report.faces");
|
|
53
|
+
/** 挂组合函数的私有键:resolve 阶段靠它识别组合组件。 */
|
|
54
|
+
export const COMPONENT_COMPOSE: unique symbol = Symbol.for("niceeval.report.compose");
|
|
55
|
+
/** Tabs / Tab 的结构角色标记:树校验的配对规则靠它,不 import primitives(避免环)。 */
|
|
56
|
+
export const COMPONENT_ROLE: unique symbol = Symbol.for("niceeval.report.role");
|
|
57
|
+
/**
|
|
58
|
+
* children 是不透明值(自由文本 / CSS 字符串)而非报告树的组件标记(Text / Style):
|
|
59
|
+
* resolve 与 validate 不下钻它们的 children——那是组件自己的 props,不是树。
|
|
60
|
+
*/
|
|
61
|
+
export const COMPONENT_RAW_CHILDREN: unique symbol = Symbol.for("niceeval.report.rawChildren");
|
|
43
62
|
|
|
44
63
|
export interface TextContext {
|
|
45
64
|
/** 可用列宽;Row 分栏后变窄。 */
|
|
@@ -50,6 +69,11 @@ export interface TextContext {
|
|
|
50
69
|
render(node: ReportNode, width?: number): string;
|
|
51
70
|
/** 下钻命令,通证据室:`niceeval show @<locator>`。 */
|
|
52
71
|
attemptCommand(locator: AttemptLocator): string;
|
|
72
|
+
/**
|
|
73
|
+
* 组索引一类「按实验收窄」命令的生成;宿主注入以携带完整上下文(--results / --report /
|
|
74
|
+
* --page 与位置参数),默认 `niceeval show --experiment <id>`。非契约字段,官方组件内部用。
|
|
75
|
+
*/
|
|
76
|
+
experimentCommand(experimentIdPrefix: string): string;
|
|
53
77
|
}
|
|
54
78
|
|
|
55
79
|
export interface WebContext {
|
|
@@ -59,38 +83,49 @@ export interface WebContext {
|
|
|
59
83
|
locale: ReportLocale;
|
|
60
84
|
}
|
|
61
85
|
|
|
86
|
+
/** 双面组件解析面的上下文:宿主注入的数据来源;props 显式给出 input 时以 props 为准。 */
|
|
87
|
+
export interface ResolveContext {
|
|
88
|
+
input: ReportInput;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 组合组件的上下文:宿主 Scope、结果根完整读取面与规范化后的报告声明。 */
|
|
92
|
+
export interface ComposeContext {
|
|
93
|
+
/** 宿主注入的 Scope。 */
|
|
94
|
+
scope: Scope;
|
|
95
|
+
/** 结果根完整读取面;历史视图从这里自行挑 Snapshot[]。 */
|
|
96
|
+
results: Results;
|
|
97
|
+
/** 规范化后的报告声明,只读(docs/feature/reports/library/layout.md「自定义组件」)。 */
|
|
98
|
+
report: ReportMeta;
|
|
99
|
+
}
|
|
100
|
+
|
|
62
101
|
export interface ComponentFaces<P, R = P> {
|
|
63
102
|
/**
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* 不实现 resolve 时 R = P,组件被当作纯数据组件直接渲染。
|
|
103
|
+
* 组件唯一的异步 / IO 面:把作者写下的 props 规范化成渲染 props(R)。宿主管线在
|
|
104
|
+
* resolve 阶段调用它,按「同引用 input + 深相等 spec」在一次页渲染内记忆化;
|
|
105
|
+
* 渲染面(web / text)只看已解析的 R,保持同步、零 IO。不实现 resolve 时 R = P。
|
|
68
106
|
*/
|
|
69
|
-
resolve?(props: P): Promise<R>;
|
|
107
|
+
resolve?(props: P, context: ResolveContext): R | Promise<R>;
|
|
70
108
|
/** 真 React JSX 在这个面里;返回静态可渲染的 ReactNode。只看已解析的 R。 */
|
|
71
109
|
web(props: R, ctx: WebContext): ReactNode;
|
|
72
110
|
text(props: R, ctx: TextContext): string;
|
|
73
111
|
}
|
|
74
112
|
|
|
75
113
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
114
|
+
* 报告组件的产物:可直接用于 JSX。双面组件当普通 React 组件调用时走 web 面(只接受
|
|
115
|
+
* 数据形态 props);组合组件只在报告管线内展开,宿主外直接渲染报错。R 在存储边界抹成 any——
|
|
78
116
|
* 树遍历只按 ComponentFaces 的结构调用 resolve / web / text,不需要在类型层追踪 R。
|
|
79
117
|
*/
|
|
80
118
|
export type ReportComponent<P> = ((props: P) => ReactNode) & {
|
|
81
119
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
82
|
-
[COMPONENT_FACES]
|
|
120
|
+
[COMPONENT_FACES]?: ComponentFaces<P, any>;
|
|
121
|
+
[COMPONENT_COMPOSE]?: (props: P, ctx: ComposeContext) => ReportNode | Promise<ReportNode>;
|
|
122
|
+
[COMPONENT_ROLE]?: "tabs" | "tab";
|
|
123
|
+
[COMPONENT_RAW_CHILDREN]?: true;
|
|
83
124
|
displayName?: string;
|
|
84
125
|
};
|
|
85
126
|
|
|
86
127
|
// web 面的环境上下文:web 宿主渲染前设好;宿主之外(组件直接嵌进用户 React 应用)
|
|
87
|
-
// 用默认值 —— attemptHref 默认 view 的 attempt
|
|
88
|
-
// 时总有去处);官方组件的「宿主里自动接证据室」只在宿主上下文激活时发生,
|
|
89
|
-
// 宿主外不传 attemptHref 就是纯展示,不发明断链。
|
|
90
|
-
//
|
|
91
|
-
// URL 格式取 `#/attempt/${locator}`:AttemptLocator 本身已经是 `@` 前缀的不透明短串
|
|
92
|
-
// (如 "@1x7f3q9"),原样嵌进路径段就得到 `#/attempt/@1x7f3q9`——与 docs/feature/reports/view.md「用
|
|
93
|
-
// Reports 积木重建 view」定稿的单段路由逐字一致,不额外拆分或去掉 `@`。
|
|
128
|
+
// 用默认值 —— attemptHref 默认 view 的 attempt 路由格式。
|
|
94
129
|
const DEFAULT_WEB_CONTEXT: WebContext = {
|
|
95
130
|
attemptHref: (locator) => `#/attempt/${locator}`,
|
|
96
131
|
locale: DEFAULT_REPORT_LOCALE,
|
|
@@ -113,83 +148,240 @@ export function isHostWebContextActive(): boolean {
|
|
|
113
148
|
return activeWebContext !== null;
|
|
114
149
|
}
|
|
115
150
|
|
|
116
|
-
/**
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
151
|
+
/** 函数形态:组合组件,只装配已有组件,可以异步;ctx 携带 scope / results / report。 */
|
|
152
|
+
export function defineComponent<P>(
|
|
153
|
+
compose: (props: P, context: ComposeContext) => ReportNode | Promise<ReportNode>,
|
|
154
|
+
): ReportComponent<P>;
|
|
155
|
+
/** 对象形态:双面组件,自己渲染;text 与 web 两面必填,可选 resolve 承担取数。 */
|
|
156
|
+
export function defineComponent<P, R = P>(faces: ComponentFaces<P, R>): ReportComponent<P>;
|
|
157
|
+
export function defineComponent<P, R = P>(
|
|
158
|
+
input: ComponentFaces<P, R> | ((props: P, context: ComposeContext) => ReportNode | Promise<ReportNode>),
|
|
159
|
+
): ReportComponent<P> {
|
|
160
|
+
if (typeof input === "function") {
|
|
161
|
+
const component = ((_props: P): ReactNode => {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`Compose component ${componentDisplayName(input) ?? "(anonymous)"} can only render inside the report pipeline ` +
|
|
164
|
+
"(niceeval show / view, or renderReportToText / renderReportToStaticHtml): it assembles other components " +
|
|
165
|
+
"and needs the host context (scope, results, report). To embed in your own React page, compute data with " +
|
|
166
|
+
"the *Data functions and render the pure components from niceeval/report/react instead.",
|
|
167
|
+
);
|
|
168
|
+
}) as ReportComponent<P>;
|
|
169
|
+
component[COMPONENT_COMPOSE] = input;
|
|
170
|
+
if (componentDisplayName(input)) component.displayName = componentDisplayName(input);
|
|
171
|
+
return component;
|
|
172
|
+
}
|
|
173
|
+
if (typeof input?.web !== "function" || typeof input?.text !== "function") {
|
|
122
174
|
throw new Error(
|
|
123
|
-
"defineComponent requires both faces: { web(props, ctx), text(props, ctx) }. " +
|
|
124
|
-
"Every report component must render in both hosts (niceeval view and niceeval show)
|
|
175
|
+
"defineComponent requires both faces: { web(props, ctx), text(props, ctx) } (resolve is optional). " +
|
|
176
|
+
"Every report component must render in both hosts (niceeval view and niceeval show); " +
|
|
177
|
+
"define the missing face, or pass a compose function to assemble existing components instead.",
|
|
125
178
|
);
|
|
126
179
|
}
|
|
180
|
+
const faces = input;
|
|
127
181
|
// 直接调用路径:把组件当普通 React 组件嵌进用户自己的页面时走这里,web 面只接收数据形态
|
|
128
|
-
// props(R)。带 resolve 的组件若拿
|
|
182
|
+
// props(R)。带 resolve 的组件若拿 spec 形态 props 走这条裸路径,web 面会缺 data ——
|
|
129
183
|
// 这类组件只有经宿主的 resolveReportTree 解析后才安全渲染;纯数据 props 一直可以裸嵌。
|
|
130
184
|
const component = ((props: P) =>
|
|
131
185
|
faces.web(props as unknown as R, activeWebContext ?? DEFAULT_WEB_CONTEXT)) as ReportComponent<P>;
|
|
132
|
-
component[COMPONENT_FACES] = faces
|
|
186
|
+
component[COMPONENT_FACES] = faces as ComponentFaces<P, unknown>;
|
|
133
187
|
return component;
|
|
134
188
|
}
|
|
135
189
|
|
|
190
|
+
function componentDisplayName(fn: unknown): string | undefined {
|
|
191
|
+
const named = fn as { displayName?: string; name?: string };
|
|
192
|
+
return named.displayName || named.name || undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
136
195
|
export function facesOf(type: unknown): ComponentFaces<unknown> | undefined {
|
|
137
196
|
if (typeof type !== "function") return undefined;
|
|
138
|
-
return (type as
|
|
197
|
+
return (type as ReportComponent<unknown>)[COMPONENT_FACES];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function composeOf(
|
|
201
|
+
type: unknown,
|
|
202
|
+
): ((props: unknown, ctx: ComposeContext) => ReportNode | Promise<ReportNode>) | undefined {
|
|
203
|
+
if (typeof type !== "function") return undefined;
|
|
204
|
+
return (type as ReportComponent<unknown>)[COMPONENT_COMPOSE] as
|
|
205
|
+
| ((props: unknown, ctx: ComposeContext) => ReportNode | Promise<ReportNode>)
|
|
206
|
+
| undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function roleOf(type: unknown): "tabs" | "tab" | undefined {
|
|
210
|
+
if (typeof type !== "function") return undefined;
|
|
211
|
+
return (type as ReportComponent<unknown>)[COMPONENT_ROLE];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function hasRawChildren(type: unknown): boolean {
|
|
215
|
+
return typeof type === "function" && (type as ReportComponent<unknown>)[COMPONENT_RAW_CHILDREN] === true;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ───────────────────────── 深相等(记忆化的 spec 比较)─────────────────────────
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* resolve 记忆化的深相等:只递归比较可序列化值(普通对象 / 数组 / 原始值);
|
|
222
|
+
* 函数与 Metric / Dimension / NumericAxis 一类携带函数的实例按引用比较——
|
|
223
|
+
* 共享计算的成立条件是引用同一实例,引用不同的等价定义只是各算一次,不构成错误。
|
|
224
|
+
*/
|
|
225
|
+
export function deepEqualSpec(a: unknown, b: unknown): boolean {
|
|
226
|
+
if (a === b) return true;
|
|
227
|
+
if (typeof a !== typeof b) return false;
|
|
228
|
+
if (typeof a !== "object" || a === null || b === null) return false;
|
|
229
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
230
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
231
|
+
return a.every((item, i) => deepEqualSpec(item, (b as unknown[])[i]));
|
|
232
|
+
}
|
|
233
|
+
const protoA = Object.getPrototypeOf(a);
|
|
234
|
+
const protoB = Object.getPrototypeOf(b);
|
|
235
|
+
if ((protoA !== Object.prototype && protoA !== null) || (protoB !== Object.prototype && protoB !== null)) {
|
|
236
|
+
return false; // 非纯对象(类实例等)按引用比较,=== 已在开头判过
|
|
237
|
+
}
|
|
238
|
+
const keysA = Object.keys(a as Record<string, unknown>).filter(
|
|
239
|
+
(k) => (a as Record<string, unknown>)[k] !== undefined,
|
|
240
|
+
);
|
|
241
|
+
const keysB = Object.keys(b as Record<string, unknown>).filter(
|
|
242
|
+
(k) => (b as Record<string, unknown>)[k] !== undefined,
|
|
243
|
+
);
|
|
244
|
+
if (keysA.length !== keysB.length) return false;
|
|
245
|
+
return keysA.every((k) => deepEqualSpec((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ───────────────────────── resolve 管线 ─────────────────────────
|
|
249
|
+
|
|
250
|
+
interface MemoEntry {
|
|
251
|
+
input: unknown;
|
|
252
|
+
spec: unknown;
|
|
253
|
+
result: Promise<unknown>;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** 一次页渲染内的记忆化缓存:键 = 计算函数引用 → (同引用 input + 深相等 spec) 命中。 */
|
|
257
|
+
export class ResolveMemo {
|
|
258
|
+
private readonly entries = new Map<unknown, MemoEntry[]>();
|
|
259
|
+
|
|
260
|
+
fetch(fn: unknown, input: unknown, spec: unknown, compute: () => Promise<unknown>): Promise<unknown> {
|
|
261
|
+
let list = this.entries.get(fn);
|
|
262
|
+
if (!list) this.entries.set(fn, (list = []));
|
|
263
|
+
for (const entry of list) {
|
|
264
|
+
if (entry.input === input && deepEqualSpec(entry.spec, spec)) return entry.result;
|
|
265
|
+
}
|
|
266
|
+
const result = compute();
|
|
267
|
+
list.push({ input, spec, result });
|
|
268
|
+
return result;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** resolveReportTree 的环境:宿主注入的 Scope / 读取面 / 规范化声明。 */
|
|
273
|
+
export interface ResolveEnv {
|
|
274
|
+
scope: Scope;
|
|
275
|
+
results: Results;
|
|
276
|
+
report: ReportMeta;
|
|
277
|
+
/** 一次页渲染一份;跨页共享缓存时由宿主显式传同一实例。 */
|
|
278
|
+
memo?: ResolveMemo;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** 官方 spec 组件在 resolve 内取数的记忆化入口;经内部扩展的 ResolveContext 传递。 */
|
|
282
|
+
export interface InternalResolveContext extends ResolveContext {
|
|
283
|
+
/** 按 (计算函数引用, 同引用 input, 深相等 options) 记忆化地调用计算函数。 */
|
|
284
|
+
memoFetch<T>(dataFn: unknown, input: unknown, options: unknown, compute: () => Promise<T>): Promise<T>;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** 组件 resolve 里安全取记忆化入口:经宿主管线时在场,手工直调 resolve 时退化为直接计算。 */
|
|
288
|
+
export function memoFetchOf(ctx: ResolveContext): InternalResolveContext["memoFetch"] {
|
|
289
|
+
const internal = ctx as Partial<InternalResolveContext>;
|
|
290
|
+
return internal.memoFetch ?? (async (_fn, _input, _options, compute) => compute());
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function illegalNodeError(type: unknown, path: string[]): Error {
|
|
294
|
+
const where = path.length > 0 ? ` (in ${path.join(" > ")})` : "";
|
|
295
|
+
if (typeof type === "string") {
|
|
296
|
+
return new Error(
|
|
297
|
+
`Report trees cannot contain raw HTML <${type}>${where}. Every node must render in both hosts (niceeval show and niceeval view), and raw HTML has no text face. ` +
|
|
298
|
+
"Use <Text> for free text, the layout primitives (<Row>/<Col>/<Section>/<Table>), or move the markup into the web face of a defineComponent component.",
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
const label = componentLabel(type);
|
|
302
|
+
return new Error(
|
|
303
|
+
`${label} is not a report component${where}: plain functions and React components cannot join a report tree because the hosts cannot render them in both faces. ` +
|
|
304
|
+
"Wrap it with defineComponent — a compose function defineComponent((props, ctx) => tree) to assemble existing components, " +
|
|
305
|
+
"or an object form defineComponent({ resolve?, text, web }) to render itself.",
|
|
306
|
+
);
|
|
139
307
|
}
|
|
140
308
|
|
|
141
|
-
|
|
309
|
+
function bareTextError(value: string | number, path: string[]): Error {
|
|
310
|
+
const where = path.length > 0 ? ` (in ${path.join(" > ")})` : "";
|
|
311
|
+
const preview = typeof value === "string" ? JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}…` : value) : String(value);
|
|
312
|
+
return new Error(
|
|
313
|
+
`Report trees cannot contain a bare ${typeof value} (${preview})${where}: free text needs an explicit carrier for terminal line-wrapping and HTML escaping. ` +
|
|
314
|
+
"Wrap it in <Text>…</Text>.",
|
|
315
|
+
);
|
|
316
|
+
}
|
|
142
317
|
|
|
143
318
|
/**
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* await 的组件级步骤,连同报告函数体自己的 `build()`),两个渲染面
|
|
149
|
-
* 之后都只看已解析的树、保持同步零 IO。遍历形状与 {@link validateReportTree} /
|
|
150
|
-
* {@link renderNodeToText} 一致:
|
|
151
|
-
*
|
|
152
|
-
* - 同层数组兄弟并行解析(`Promise.all`),保持原始顺序 / keys;
|
|
153
|
-
* - 双面组件带 resolve 的:调 `faces.resolve(props)` 换 props(有 children 再递归解析);
|
|
154
|
-
* - 双面组件无 resolve 的(Row / Col / Section / RunOverview…):只递归 children,自身 props
|
|
155
|
-
* 原样保留(title / className 等不能动);
|
|
156
|
-
* - 普通函数组件:同步调用展开,展开结果继续解析(与 validate / render 对函数组件的处理一致);
|
|
157
|
-
* - 字符串 intrinsic(<div>):原样返回,交给随后的 validateReportTree 报同一条错误。
|
|
158
|
-
*
|
|
159
|
-
* resolver 跑完后,树里已没有函数组件 / 未解析 props;validate / render 里对这两者的分支在
|
|
160
|
-
* 正常管线下是 no-op,但保留着——手搭树直接调 validate / render(不过 resolver)的低层用法仍需要。
|
|
319
|
+
* 管线的 resolve 阶段:递归展开组合组件(以 (props, ctx) 调用并 await 返回树)、执行双面
|
|
320
|
+
* 组件的解析面;同层 sibling 并行取数且不改变节点顺序;带 resolve 的组件按「同引用 input +
|
|
321
|
+
* 深相等 spec」记忆化。非法节点(React 组件、未经 defineComponent 的普通函数、任意 HTML
|
|
322
|
+
* intrinsic)在展开遇到时立即以完整用户反馈拒绝,不为非法节点取数。
|
|
161
323
|
*/
|
|
162
|
-
export async function resolveReportTree(node: ReportNode): Promise<ReportNode> {
|
|
324
|
+
export async function resolveReportTree(node: ReportNode, env: ResolveEnv): Promise<ReportNode> {
|
|
325
|
+
const memo = env.memo ?? new ResolveMemo();
|
|
326
|
+
const composeCtx: ComposeContext = { scope: env.scope, results: env.results, report: env.report };
|
|
327
|
+
return resolveNode(node, { memo, composeCtx }, []);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
interface ResolveState {
|
|
331
|
+
memo: ResolveMemo;
|
|
332
|
+
composeCtx: ComposeContext;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function resolveNode(node: ReportNode | string | number, state: ResolveState, path: string[]): Promise<ReportNode> {
|
|
163
336
|
if (node === null || node === undefined || typeof node === "boolean") return node;
|
|
164
|
-
if (typeof node === "string" || typeof node === "number")
|
|
165
|
-
|
|
337
|
+
if (typeof node === "string" || typeof node === "number") {
|
|
338
|
+
// 裸字符串在 validate 阶段以带指引的完整反馈拒绝;这里先如实透传,不为它取数。
|
|
339
|
+
return node as unknown as ReportNode;
|
|
340
|
+
}
|
|
341
|
+
if (Array.isArray(node)) {
|
|
342
|
+
return Promise.all(node.map((child) => resolveNode(child as ReportNode, state, path)));
|
|
343
|
+
}
|
|
166
344
|
if (!isReportElement(node)) return node;
|
|
167
345
|
const { type, props } = node;
|
|
168
|
-
// 字符串 intrinsic:不在这里报错,让 resolve 后的 validateReportTree 抛统一的那条。
|
|
169
|
-
if (typeof type === "string") return node;
|
|
170
346
|
if (type === REACT_FRAGMENT) {
|
|
171
|
-
const children = await
|
|
347
|
+
const children = await resolveNode(props.children as ReportNode, state, path);
|
|
172
348
|
return { ...node, props: { ...props, children } };
|
|
173
349
|
}
|
|
350
|
+
const compose = composeOf(type);
|
|
351
|
+
if (compose) {
|
|
352
|
+
// 组合组件不记忆化:它只装配、不承担取数;数据层的去重由其内部 *Data 调用经 memoFetch 命中。
|
|
353
|
+
const expanded = await compose(props, state.composeCtx);
|
|
354
|
+
return resolveNode(expanded, state, [...path, componentLabel(type)]);
|
|
355
|
+
}
|
|
174
356
|
const faces = facesOf(type);
|
|
175
357
|
if (faces) {
|
|
176
358
|
if (typeof faces.resolve === "function") {
|
|
177
|
-
const
|
|
359
|
+
const resolveFn = faces.resolve;
|
|
360
|
+
const input = ((props as { input?: unknown }).input as ReportInput | undefined) ?? state.composeCtx.scope;
|
|
361
|
+
const ctx: InternalResolveContext = {
|
|
362
|
+
input,
|
|
363
|
+
memoFetch: <T,>(dataFn: unknown, dataInput: unknown, options: unknown, compute: () => Promise<T>) =>
|
|
364
|
+
state.memo.fetch(dataFn, dataInput, options, compute) as Promise<T>,
|
|
365
|
+
};
|
|
366
|
+
const resolved = await state.memo.fetch(resolveFn, input, props, async () =>
|
|
367
|
+
resolveFn(props, ctx),
|
|
368
|
+
);
|
|
369
|
+
const resolvedProps = { ...(resolved as Record<string, unknown>) };
|
|
178
370
|
if (resolvedProps.children !== undefined) {
|
|
179
|
-
resolvedProps.children = await
|
|
371
|
+
resolvedProps.children = await resolveNode(resolvedProps.children as ReportNode, state, [
|
|
372
|
+
...path,
|
|
373
|
+
componentLabel(type),
|
|
374
|
+
]);
|
|
180
375
|
}
|
|
181
376
|
return { ...node, props: resolvedProps };
|
|
182
377
|
}
|
|
183
|
-
// 无 resolve 的容器 /
|
|
184
|
-
|
|
378
|
+
// 无 resolve 的容器 / 纯数据组件:只解析 children,自身 props 原样保留(title / className 不能动)。
|
|
379
|
+
if (hasRawChildren(type)) return node; // Text / Style 的 children 是不透明值,不是树
|
|
380
|
+
const children = await resolveNode(props.children as ReportNode, state, [...path, componentLabel(type)]);
|
|
185
381
|
return { ...node, props: { ...props, children } };
|
|
186
382
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const expanded = (type as (p: unknown) => ReportNode)(props);
|
|
190
|
-
return resolveReportTree(expanded);
|
|
191
|
-
}
|
|
192
|
-
return node;
|
|
383
|
+
// 非法节点:HTML intrinsic、React 组件、未经 defineComponent 的普通函数 —— 立即拒绝,不取数。
|
|
384
|
+
throw illegalNodeError(type, path);
|
|
193
385
|
}
|
|
194
386
|
|
|
195
387
|
// ───────────────────────── 树校验 ─────────────────────────
|
|
@@ -204,46 +396,109 @@ function componentLabel(type: unknown): string {
|
|
|
204
396
|
return `<${String(type)}>`;
|
|
205
397
|
}
|
|
206
398
|
|
|
399
|
+
function directChildElements(children: unknown): ReportElement[] {
|
|
400
|
+
const out: ReportElement[] = [];
|
|
401
|
+
const visit = (node: unknown): void => {
|
|
402
|
+
if (node === null || node === undefined || typeof node === "boolean") return;
|
|
403
|
+
if (Array.isArray(node)) {
|
|
404
|
+
for (const child of node) visit(child);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (isReportElement(node)) {
|
|
408
|
+
if (node.type === REACT_FRAGMENT) {
|
|
409
|
+
visit(node.props.children);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
out.push(node);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
out.push(node as never);
|
|
416
|
+
};
|
|
417
|
+
visit(children);
|
|
418
|
+
return out;
|
|
419
|
+
}
|
|
420
|
+
|
|
207
421
|
/**
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
* 普通函数组件调用展开(渲染面纯同步,重复调用无副作用)。
|
|
422
|
+
* 管线的 validate 阶段:确保展开后树中每个组件都有 text 和 web 两面。校验只看节点资格,
|
|
423
|
+
* 不限定树形:根节点可以是单个组件、Col 或 Tabs,宿主不强制任何最外层容器。
|
|
424
|
+
* 裸字符串 / 数字、HTML intrinsic、无两面资格的函数都以完整用户反馈拒绝;
|
|
425
|
+
* Tabs / Tab 的结构配对(空 Tabs、游离 Tab、非 Tab 直接子节点)也在这里校验。
|
|
213
426
|
*/
|
|
214
|
-
export function validateReportTree(node: ReportNode, path: string[] = []): void {
|
|
427
|
+
export function validateReportTree(node: ReportNode, path: string[] = [], insideTabs = false): void {
|
|
215
428
|
if (node === null || node === undefined || typeof node === "boolean") return;
|
|
216
|
-
if (typeof node === "string" || typeof node === "number")
|
|
429
|
+
if (typeof node === "string" || typeof node === "number") {
|
|
430
|
+
throw bareTextError(node, path);
|
|
431
|
+
}
|
|
217
432
|
if (Array.isArray(node)) {
|
|
218
|
-
for (const child of node) validateReportTree(child, path);
|
|
433
|
+
for (const child of node) validateReportTree(child, path, insideTabs);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (!isReportElement(node)) {
|
|
437
|
+
if (typeof node === "object" && node !== null && (node as { kind?: unknown }).kind === "report") {
|
|
438
|
+
const where = path.length > 0 ? ` (in ${path.join(" > ")})` : "";
|
|
439
|
+
throw new Error(
|
|
440
|
+
`A defineReport(...) product is not a report node${where}: the shell cannot nest. ` +
|
|
441
|
+
"Put the page content (a tree or component) here, and keep defineReport for the file's default export only.",
|
|
442
|
+
);
|
|
443
|
+
}
|
|
219
444
|
return;
|
|
220
445
|
}
|
|
221
|
-
if (!isReportElement(node)) return;
|
|
222
446
|
const { type, props } = node;
|
|
223
|
-
if (
|
|
447
|
+
if (type === REACT_FRAGMENT) {
|
|
448
|
+
validateReportTree(props.children as ReportNode, path, insideTabs);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (typeof type === "string") throw illegalNodeError(type, path);
|
|
452
|
+
const label = componentLabel(type);
|
|
453
|
+
const role = roleOf(type);
|
|
454
|
+
if (role === "tab" && !insideTabs) {
|
|
224
455
|
const where = path.length > 0 ? ` (in ${path.join(" > ")})` : "";
|
|
225
456
|
throw new Error(
|
|
226
|
-
|
|
457
|
+
`<Tab> can only be a direct child of <Tabs>${where}. Wrap sibling views in <Tabs><Tab title="…">…</Tab></Tabs>, or drop the <Tab> wrapper if you only have one view.`,
|
|
227
458
|
);
|
|
228
459
|
}
|
|
229
|
-
if (
|
|
230
|
-
|
|
460
|
+
if (role === "tabs") {
|
|
461
|
+
const children = directChildElements(props.children);
|
|
462
|
+
const nonTab = children.find((child) => roleOf(child.type) !== "tab");
|
|
463
|
+
if (nonTab !== undefined) {
|
|
464
|
+
throw new Error(
|
|
465
|
+
`<Tabs> only accepts <Tab> as direct children (found ${
|
|
466
|
+
isReportElement(nonTab) ? componentLabel(nonTab.type) : typeof nonTab
|
|
467
|
+
} in ${[...path, label].join(" > ")}). Move the content inside a <Tab title="…">, or lift it out of <Tabs>.`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
if (children.length === 0) {
|
|
471
|
+
throw new Error(
|
|
472
|
+
`<Tabs> needs at least one <Tab> child (in ${[...path, label].join(" > ")}). Add <Tab title="…">…</Tab>, or remove the empty <Tabs>.`,
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
for (const tab of children) {
|
|
476
|
+
validateReportTree((tab.props as { children?: ReportNode }).children, [...path, label, componentLabel(tab.type)], false);
|
|
477
|
+
}
|
|
231
478
|
return;
|
|
232
479
|
}
|
|
233
|
-
const
|
|
234
|
-
if (
|
|
480
|
+
const faces = facesOf(type);
|
|
481
|
+
if (faces) {
|
|
482
|
+
// 双面资格:无类型 JS 输入可能绕过 defineComponent 的定义期校验,这里再拦一遍
|
|
483
|
+
if (typeof faces.web !== "function" || typeof faces.text !== "function") {
|
|
484
|
+
const missing = typeof faces.web !== "function" ? "web" : "text";
|
|
485
|
+
throw new Error(
|
|
486
|
+
`${label} is missing its ${missing} face${path.length > 0 ? ` (in ${path.join(" > ")})` : ""}: every component in a report tree must render in both hosts (niceeval show and niceeval view). Define both { text, web } in defineComponent.`,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
235
489
|
// 双面组件是校验的信任边界之内的叶子,但 children 仍是报告树的一部分
|
|
236
|
-
|
|
490
|
+
// (Text / Style 除外:它们的 children 是不透明的自由文本 / CSS 字符串)
|
|
491
|
+
if (!hasRawChildren(type)) validateReportTree(props.children as ReportNode, [...path, label], false);
|
|
237
492
|
return;
|
|
238
493
|
}
|
|
239
|
-
if (
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
494
|
+
if (composeOf(type)) {
|
|
495
|
+
// 正常管线下 resolve 已把组合组件展开;手搭树直接 validate 时如实指出它还没展开
|
|
496
|
+
throw new Error(
|
|
497
|
+
`${label} is a compose component and must be expanded by the resolve pipeline before validation. ` +
|
|
498
|
+
"Render through defineReport + niceeval show/view (or renderReportToText/renderReportToStaticHtml).",
|
|
499
|
+
);
|
|
244
500
|
}
|
|
245
|
-
|
|
246
|
-
throw new Error(`Unsupported node type ${label} in report tree.${where}`);
|
|
501
|
+
throw illegalNodeError(type, path);
|
|
247
502
|
}
|
|
248
503
|
|
|
249
504
|
// ───────────────────────── text 渲染 ─────────────────────────
|
|
@@ -253,21 +508,29 @@ export interface TextRenderOptions {
|
|
|
253
508
|
width?: number;
|
|
254
509
|
/** 下钻命令的生成;宿主注入,默认 `niceeval show @<locator>`(真实可跑的 CLI 语法)。 */
|
|
255
510
|
attemptCommand?: (locator: AttemptLocator) => string;
|
|
511
|
+
/** 组索引命令的生成;宿主注入以携带 --results / --report / --page 等上下文。 */
|
|
512
|
+
experimentCommand?: (experimentIdPrefix: string) => string;
|
|
256
513
|
/** chrome 文案的 locale;默认 "en"(`niceeval show` 现有输出不变)。 */
|
|
257
514
|
locale?: ReportLocale;
|
|
258
515
|
}
|
|
259
516
|
|
|
517
|
+
function shellQuote(value: string): string {
|
|
518
|
+
return /^[A-Za-z0-9._/@-]+$/.test(value) ? value : `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
519
|
+
}
|
|
520
|
+
|
|
260
521
|
export function createTextContext(options?: TextRenderOptions): TextContext {
|
|
261
522
|
const width = Math.max(20, options?.width ?? 80);
|
|
262
523
|
const locale = options?.locale ?? DEFAULT_REPORT_LOCALE;
|
|
263
524
|
// 默认下钻命令:AttemptLocator 是 `@` 前缀的不透明短串,`niceeval show @<locator>` 是
|
|
264
|
-
// show
|
|
265
|
-
// 反查 eval id 再拼一条近似命令。
|
|
525
|
+
// show 已实现的真实 CLI 语法,不需要反查 eval id 再拼一条近似命令。
|
|
266
526
|
const attemptCommand = options?.attemptCommand ?? ((locator: AttemptLocator) => `niceeval show ${locator}`);
|
|
527
|
+
const experimentCommand =
|
|
528
|
+
options?.experimentCommand ?? ((prefix: string) => `niceeval show --experiment ${shellQuote(prefix)}`);
|
|
267
529
|
const make = (w: number): TextContext => ({
|
|
268
530
|
width: w,
|
|
269
531
|
locale,
|
|
270
532
|
attemptCommand,
|
|
533
|
+
experimentCommand,
|
|
271
534
|
render(node, childWidth) {
|
|
272
535
|
return renderNodeToText(node, childWidth === undefined ? this : make(Math.max(10, childWidth)));
|
|
273
536
|
},
|
|
@@ -275,10 +538,13 @@ export function createTextContext(options?: TextRenderOptions): TextContext {
|
|
|
275
538
|
return make(width);
|
|
276
539
|
}
|
|
277
540
|
|
|
278
|
-
/** text 宿主的遍历渲染:双面组件走 text
|
|
541
|
+
/** text 宿主的遍历渲染:双面组件走 text 面,块之间以换行相接。只吃已 resolve + validate 的树。 */
|
|
279
542
|
export function renderNodeToText(node: ReportNode, ctx: TextContext): string {
|
|
280
543
|
if (node === null || node === undefined || typeof node === "boolean") return "";
|
|
281
|
-
if (typeof node === "string" || typeof node === "number")
|
|
544
|
+
if (typeof node === "string" || typeof node === "number") {
|
|
545
|
+
// 校验先行会拦住;渲染路径自身也不宽容
|
|
546
|
+
throw bareTextError(node, []);
|
|
547
|
+
}
|
|
282
548
|
if (Array.isArray(node)) {
|
|
283
549
|
return node
|
|
284
550
|
.map((child) => renderNodeToText(child, ctx))
|
|
@@ -287,17 +553,9 @@ export function renderNodeToText(node: ReportNode, ctx: TextContext): string {
|
|
|
287
553
|
}
|
|
288
554
|
if (!isReportElement(node)) return "";
|
|
289
555
|
const { type, props } = node;
|
|
290
|
-
if (typeof type === "string")
|
|
291
|
-
// 校验先行会拦住;这里兜底同一条错误,渲染路径自身也不宽容
|
|
292
|
-
throw new Error(
|
|
293
|
-
`Raw HTML <${type}> has no terminal face; use <Text>, layout primitives, or a defineComponent component.`,
|
|
294
|
-
);
|
|
295
|
-
}
|
|
556
|
+
if (typeof type === "string") throw illegalNodeError(type, []);
|
|
296
557
|
if (type === REACT_FRAGMENT) return renderNodeToText(props.children as ReportNode, ctx);
|
|
297
558
|
const faces = facesOf(type);
|
|
298
559
|
if (faces) return faces.text(props, ctx);
|
|
299
|
-
|
|
300
|
-
return renderNodeToText((type as (p: unknown) => ReportNode)(props), ctx);
|
|
301
|
-
}
|
|
302
|
-
return "";
|
|
560
|
+
throw illegalNodeError(type, []);
|
|
303
561
|
}
|