diff --git a/apps/api/src/modules/interaction/__tests__/view-render-nested-editable.test.ts b/apps/api/src/modules/interaction/__tests__/view-render-nested-editable.test.ts new file mode 100644 index 0000000..546f14f --- /dev/null +++ b/apps/api/src/modules/interaction/__tests__/view-render-nested-editable.test.ts @@ -0,0 +1,99 @@ +/** + * **可编辑区域套两层 `data-rows` 时,渲染器真产出什么** —— W05 数据工单版面探针 + * (2026-08-30,「核心参数值」想做成"一个候选一个 section、section 里铺参数瓷贴"这形状, + * 先问渲染管线真产出什么,再问 `collect()` 拿它怎么办)。 + * + * `collect()` 住在 `packages/ui-kit`,这份文件住在 `apps/api`——`ui-kit` 是 tsconfig.web.json + * 那一支(`jsx: react-jsx` + DOM lib),`apps/api` 归 tsconfig.node.json(两者都没有对方要的 + * 编译选项);`packages-are-leaves` 也不许 `packages/*` 反向 import `apps/*`。两侧各钉一条、 + * 中间用**同一段渲染出来的 HTML 字符串**接力,防止两份夹具各写各的、悄悄漂移。 + * + * **这一条只钉 `renderView` 的真实输出**——collect() 拿这段 HTML 怎么归行, + * 见 `packages/ui-kit/__tests__/view-container-collect-nested.test.ts` + * (把下面 `NESTED_RENDERED` 这段字符串原样复制过去当夹具)。 + */ +import { describe, expect, it } from 'vitest'; +import { renderView } from '../internal/domain/view-render.ts'; + +/** + * 外层 `data-edit="parts.parameter"` 与外层 `data-rows="mine:sub_eval.part"` 共处一个标签 + * (照仓里既有的可编辑表格惯例,如 ``)—— + * 每颗候选料复制一个 `
`;section 里再嵌一层 `data-rows="mine:parts.parameter" + * data-by="internal_pn=internal_pn"` 铺这颗料自己的参数瓷贴,每瓷贴三格 + * (`param_key` / `param_value` / 主键 `internal_pn`)。 + * + * 行容器标签特意避开重名(`article` 外层、`div` 内层、瓷贴用 `span`)—— + * `data-rows` 的正则按标签名非贪婪找**同名**闭合标签,内层若也叫 `article` + * 会被外层第一个提前撞见的闭合标签截断(`view-render.test.ts` 379 行那条注释记过同一个坑)。 + */ +const NESTED_TPL = + '
' + + '
' + + '

' + + '
' + + '' + + '' + + '' + + '' + + '' + + '
' + + '
' + + '
'; + +const NESTED_SCOPE = { + 'mine:sub_eval.part': [{ internal_pn: 'LM2596S-5.0' }, { internal_pn: 'XL2596S-5.0' }], + 'mine:parts.parameter': [ + { internal_pn: 'LM2596S-5.0', param_key: 'input_voltage', param_value: '4.5~40' }, + { internal_pn: 'LM2596S-5.0', param_key: 'output_voltage', param_value: '5' }, + { internal_pn: 'LM2596S-5.0', param_key: 'enable_threshold', param_value: '2.5' }, + { internal_pn: 'XL2596S-5.0', param_key: 'input_voltage', param_value: '4.5~40' }, + { internal_pn: 'XL2596S-5.0', param_key: 'output_voltage', param_value: '5' }, + { internal_pn: 'XL2596S-5.0', param_key: 'enable_threshold', param_value: '3.0' }, + ], +}; + +/** + * `renderView` 真实产出的逐字 HTML —— 与下面第一条用例的断言必须**逐字节一致**。 + * 这段字符串原样复制进 `view-container-collect-nested.test.ts` 当夹具(注释互相点名)。 + */ +export const NESTED_RENDERED = + '

LM2596S-5.0

' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '

XL2596S-5.0

' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
'; + +describe('renderView:外层 data-edit+data-rows 共处一个标签,内层 data-rows+data-by 铺瓷贴', () => { + it('产出两个
(host =
的直接子元素),各自嵌一层
才到瓷贴 —— 逐字节核对', () => { + const out = renderView(NESTED_TPL, NESTED_SCOPE); + expect(out).toBe(NESTED_RENDERED); + }); + + it('变异验证:换成 3 颗候选料,输出的
数跟着变成 3 —— 证明上面的断言真的在读结构,不是凑巧写对了字符串', () => { + const out = renderView(NESTED_TPL, { + 'mine:sub_eval.part': [{ internal_pn: 'A' }, { internal_pn: 'B' }, { internal_pn: 'C' }], + 'mine:parts.parameter': [ + { internal_pn: 'A', param_key: 'k1', param_value: 'v1' }, + { internal_pn: 'B', param_key: 'k1', param_value: 'v1' }, + { internal_pn: 'C', param_key: 'k1', param_value: 'v1' }, + ], + }); + expect(out.split('
')).toHaveLength(4); // split 出 [头, s1, s2, s3] + }); +}); diff --git a/packages/ui-kit/__tests__/view-container-collect-div-rows.test.ts b/packages/ui-kit/__tests__/view-container-collect-div-rows.test.ts new file mode 100644 index 0000000..09d23c8 --- /dev/null +++ b/packages/ui-kit/__tests__/view-container-collect-div-rows.test.ts @@ -0,0 +1,106 @@ +/** + * **平坦退路形状:瓷贴是 `data-edit` 宿主的直接子元素,行容器是 `
` 不是 ``** + * (2026-08-30,W05 数据工单版面探针)。 + * + * 对照 `view-container-collect-nested.test.ts` 那条「外层按料分组、内层铺瓷贴」的嵌套形状—— + * 那种形状里瓷贴与 host 之间隔了两层(候选料的 `
` + 内层 `data-rows` 留下的 `
`), + * `rowOf()` 爬到 host 直接子元素时只能到 `
`,把一颗料的 3 个瓷贴全部并成一条记录、 + * 后写的列覆盖先写的,悄悄丢数据。 + * + * 这里是**能用的退路**:`data-rows="mine:parts.parameter"` 直接铺在 host(`data-edit` 那个元素) + * 身上,不分料、不套内层 —— 瓷贴(`
`)因此是 host 的**直接子元素**,`rowOf()` + * 第一步往上爬就撞到 host,一个瓷贴就是一行,互不干扰。W05 若要「一个候选一个卡片」的排版, + * 换个思路做(每颗候选各给一张单独的 `data-edit` 宿主,或纯展示的分组包着这一层可编辑的平铺表), + * 不能靠嵌套 `data-rows` 省一层。 + * + * `rowOf()`(`../src/ViewContainer.tsx:178`)按 `parentElement` 一路爬,不认标签名 —— + * 现有 `view-container-collect.test.ts` 的用例全是 `` 行(表格),这里换成 `
` + * 的瓷贴卡,证明它不是"认表格"这条窄路,是通用的"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 = + '

    LM2596S-5.0

    ' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '

    XL2596S-5.0

    ' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
    '; + +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'); + }); +});