`
+ * 的瓷贴卡,证明它不是"认表格"这条窄路,是通用的"host 的直接子元素就是行"。
+ */
+// @vitest-environment jsdom
+import { describe, expect, it } from 'vitest';
+import { collect } from '../src/ViewContainer.tsx';
+
+/** 两颗候选料、每颗 3 个参数瓷贴(与嵌套形状那条用同一组事实数据,方便对照) */
+const TILES_HOST =
+ '
';
+
+function docOf(html: string): Document {
+ const doc = document.implementation.createHTMLDocument('');
+ doc.body.innerHTML = html;
+ return doc;
+}
+
+describe('collect():瓷贴(
)是 data-edit 宿主的直接子元素 —— 一瓷贴一记录,互不覆盖', () => {
+ it('6 个瓷贴收到 6 条记录(不是并成 2 条候选级记录)', () => {
+ const got = collect(docOf(TILES_HOST), 'parts.parameter');
+ expect(got).toHaveLength(6);
+ });
+
+ it('每条记录三格各自带对:主键 internal_pn + param_key + param_value,不互相覆盖', () => {
+ const got = collect(docOf(TILES_HOST), 'parts.parameter');
+ // 同一颗料的 3 条参数各自独立成行,3 个键值全部保留(与嵌套形状那条"只剩最后一瓷贴"相反)
+ const lm = got.filter((r) => r['internal_pn'] === 'LM2596S-5.0');
+ expect(lm).toHaveLength(3);
+ const byKey = new Map(lm.map((r) => [r['param_key'], r['param_value']]));
+ expect(byKey.get('input_voltage')).toBe('4.5~40');
+ expect(byKey.get('output_voltage')).toBe('5');
+ expect(byKey.get('enable_threshold')).toBe('2.5');
+
+ const xl = got.filter((r) => r['internal_pn'] === 'XL2596S-5.0');
+ expect(xl).toHaveLength(3);
+ expect(new Map(xl.map((r) => [r['param_key'], r['param_value']])).get('enable_threshold')).toBe('3.0');
+ });
+
+ /*
+ * 行容器换一个跟表格毫无关系的标签(`
`)结果不变 —— 证明 `rowOf` 认的是
+ * "host 的直接子元素",不是专挑某个标签名。
+ *
+ * 特意不拿 `` 来比:裸 `
`(不包在 `` 里)会撞上 HTML 解析算法的
+ * "foster parenting"(表格内容规则规定 `` 只能直接装 `| / | `,`` 这类
+ * 不合规内容会被解析器整体挪到 ` |
` 外面)——真拿裸 tr 包 input 试过,jsdom 吐出的是
+ * 9 条(3 个空 `
` + 6 个被挪出来的 `` 各自成行),这是 HTML 解析的固有行为,
+ * 与 `rowOf` 是否"认标签"无关,拿来当反例只会误导。已有的 `view-container-collect.test.ts`
+ * 用 `` 包着的 `` 早就证明过 tr 这条路是对的。
+ */
+ it('行容器换成完全不相干的标签()结果不变 —— 证明 rowOf 认的是"host 的直接子元素",不是专挑某个标签名', () => {
+ const liHost =
+ '' +
+ '
' +
+ '' +
+ '' +
+ '';
+ const got = collect(docOf(liHost), 'parts.parameter');
+ expect(got).toHaveLength(3);
+ expect(new Set(got.map((r) => r['param_key']))).toEqual(new Set(['input_voltage', 'output_voltage', 'enable_threshold']));
+ });
+
+ it('变异验证:去掉一个瓷贴整行,记录数跟着从 6 变成 5 —— 证明「6 条」不是巧合,是按瓷贴数记', () => {
+ const oneLess = TILES_HOST.replace(
+ '',
+ '',
+ );
+ const got = collect(docOf(oneLess), 'parts.parameter');
+ expect(got).toHaveLength(5);
+ });
+
+ it('变异验证:摘掉一个瓷贴的主键 data-col(internal_pn)—— 只有那一条记录缺这一列,其余 5 条不受影响', () => {
+ const withoutOnePk = TILES_HOST.replace(
+ '',
+ '',
+ );
+ const got = collect(docOf(withoutOnePk), 'parts.parameter');
+ expect(got).toHaveLength(6);
+ const missingPk = got.filter((r) => !('internal_pn' in r));
+ expect(missingPk).toHaveLength(1);
+ expect(missingPk[0]?.['param_key']).toBe('enable_threshold');
+ expect(missingPk[0]?.['param_value']).toBe('2.5');
+ });
+});
diff --git a/packages/ui-kit/__tests__/view-container-collect-nested.test.ts b/packages/ui-kit/__tests__/view-container-collect-nested.test.ts
new file mode 100644
index 0000000..e02ff76
--- /dev/null
+++ b/packages/ui-kit/__tests__/view-container-collect-nested.test.ts
@@ -0,0 +1,90 @@
+/**
+ * **嵌套 `data-rows`(外层按料分组、内层铺参数瓷贴)喂给真实 `collect()`,会并成一条记录**
+ * (2026-08-30,W05 数据工单版面探针 —— 探的是「一个候选一个 section、section 里铺参数瓷贴」
+ * 这形状能不能拿来做可编辑版面)。
+ *
+ * 这段夹具字符串 **原样照抄自** `apps/api/src/modules/interaction/__tests__/
+ * view-render-nested-editable.test.ts` 里 `renderView(NESTED_TPL, NESTED_SCOPE)` 的真实产出
+ * (该文件断言过逐字节相等,变量名同为 `NESTED_RENDERED`)——两侧各钉一条是因为 `collect()`
+ * 住在 `@ui`(tsconfig.web.json:`jsx: react-jsx` + DOM lib)、`renderView` 住在 `apps/api`
+ * (tsconfig.node.json:两者都没有对方要的编译选项),`packages-are-leaves` 门禁也不许
+ * `packages/*` 反向 import `apps/*`——两侧不能用同一份代码/常量接力,只能用**同一段字符串**
+ * 接力,不一致就靠这条注释与两处的断言互相盯着。
+ *
+ * ── 结论 ──────────────────────────────────────────────────────────────────
+ * `rowOf()` 从格子往上爬,爬到 host(`[data-edit="parts.parameter"]`,即 ``)的
+ * **直接子元素**为止——嵌套形状里那正是每颗候选料的 ``,不是瓷贴自己的 ``
+ * (瓷贴与 section 之间还隔着内层 `data-rows` 留下的那层 ``)。于是一颗料的 3 个瓷贴全部
+ * 归到同一个 `
` 名下:`collect()` 只吐出 **2 条记录**(candidate 数,不是瓷贴数),
+ * 且同一列被后一个瓷贴的值**悄悄覆盖**前一个——`param_key`/`param_value` 最终只剩最后一颗瓷贴
+ * (`enable_threshold`)的值,`input_voltage`/`output_voltage` 两条参数的编辑结果整个丢失,
+ * 界面上却看不出任何报错。**所以数据工单版面不许这么嵌**——要么瓷贴与 host 之间只隔一层
+ * (见 `view-container-collect-div-rows.test.ts` 的平坦退路形状),要么给每个瓷贴单独一张
+ * `data-edit` 宿主。
+ */
+// @vitest-environment jsdom
+import { describe, expect, it } from 'vitest';
+import { collect } from '../src/ViewContainer.tsx';
+
+/** 与 view-render-nested-editable.test.ts 的 `NESTED_RENDERED` 逐字节相同 —— 见该文件头注 */
+const NESTED_RENDERED =
+ '';
+
+function docOf(html: string): Document {
+ const doc = document.implementation.createHTMLDocument('');
+ doc.body.innerHTML = html;
+ return doc;
+}
+
+describe('collect():外层 data-rows 分组、内层 data-rows 铺瓷贴 —— 一个候选的三个瓷贴并成一条记录', () => {
+ it('两颗候选料只收到 2 条记录,不是 6 条(3 瓷贴 × 2 料)——瓷贴被 section 并行掉了', () => {
+ const got = collect(docOf(NESTED_RENDERED), 'parts.parameter');
+ expect(got).toHaveLength(2);
+ });
+
+ it('同一 section 内后一个瓷贴的列值覆盖前一个:param_key/param_value 只剩最后一瓷贴(enable_threshold),前两条参数的编辑结果丢失', () => {
+ const got = collect(docOf(NESTED_RENDERED), 'parts.parameter');
+ const byPn = new Map(got.map((r) => [r['internal_pn'], r]));
+ // 3 个瓷贴按 DOM 序是 input_voltage → output_voltage → enable_threshold,最后写的赢
+ expect(byPn.get('LM2596S-5.0')).toEqual({ param_key: 'enable_threshold', param_value: '2.5', internal_pn: 'LM2596S-5.0' });
+ expect(byPn.get('XL2596S-5.0')).toEqual({ param_key: 'enable_threshold', param_value: '3.0', internal_pn: 'XL2596S-5.0' });
+ // input_voltage / output_voltage 两条参数的值,在 collect() 的输出里完全找不到痕迹
+ expect(JSON.stringify(got)).not.toContain('input_voltage');
+ expect(JSON.stringify(got)).not.toContain('output_voltage');
+ });
+
+ it('变异验证:去掉一整颗候选料的 3 个瓷贴,记录数跟着从 2 变成 1 —— 证明上面「2 条」不是巧合,是真的按 section 数记', () => {
+ const oneSection = NESTED_RENDERED.slice(0, NESTED_RENDERED.indexOf('') + ''.length) + '';
+ const got = collect(docOf(oneSection), 'parts.parameter');
+ expect(got).toHaveLength(1);
+ });
+
+ it('变异验证:摘掉主键 data-col(internal_pn)—— 记录里就再也找不到这一列(证明断言真的在读 data-col,不是巧合)', () => {
+ const withoutPk = NESTED_RENDERED.replaceAll('
', '
').replaceAll(
+ '
',
+ '
',
+ );
+ const got = collect(docOf(withoutPk), 'parts.parameter');
+ for (const row of got) expect(row).not.toHaveProperty('internal_pn');
+ });
+});