diff --git a/apps/api/src/modules/interaction/__tests__/view-render-addrow-thead.test.ts b/apps/api/src/modules/interaction/__tests__/view-render-addrow-thead.test.ts index 3ec348b..8a8c5e1 100644 --- a/apps/api/src/modules/interaction/__tests__/view-render-addrow-thead.test.ts +++ b/apps/api/src/modules/interaction/__tests__/view-render-addrow-thead.test.ts @@ -26,15 +26,27 @@ * 就再也碰不到表头行(表头行在文档序上比所有数据行/种子行都晚,只有当两个 `` * 都彻底没有 `` 时才轮得到它——而静态种子行保证了这种情况不会发生)。 * - * 这份测试**不能** `import` `packages/ui-kit/src/ViewContainer.tsx` 的 `bodyOf`/`addRow`—— - * 它们没有 `export`,而且 `activities/part-substitution-assessment` 自己的包内测试按 - * ADR-004 的 `activities-isolated` depcruise 规则只准 `import @sdk/@contracts/@sfeel`, - * 够不着 `apps/api` 这一侧的 `renderView`;反过来 `apps-not-into-activities` 规则也不许 - * `apps/api` 侧的文件静态 `import activities/**`。所以这里用**真实的 `renderView()`** - * (与 report.ts 的 `CHIP_TYPE_TABLE`/`TYPE_PARAM_TABLE` 结构逐字节对齐的镜像 fixture, - * 见下面 `CHIP_TYPE_FRAGMENT`/`TYPE_PARAM_FRAGMENT` 头注)跑出「服务端会真的吐出什么 - * HTML」,再在 jsdom 里**逐行复刻** `bodyOf`/`addRow` 那两行选择器(标了对应的源码行号) - * —— 测的是选择器路径本身的行为,不是字符串包含。 + * **这份测试守的是「W05 这两张表此刻的具体 HTML 形状是对的」,不是「addRow/bodyOf 这两个 + * 函数本身对不对」——两件事分给了两处测试,缺一不可**(2026-08-30 复审第二轮逼出来的教训: + * 第一版这里的 `findAddRowTarget` 是纯手抄的选择器复刻,复审把真实 `ViewContainer.tsx:305` + * 的 `tr:last-of-type` 换成明摆着错的 `tr:first-of-type` 重跑这 6 条,6/6 全绿零反应—— + * 全仓当时没有任何测试真的执行过 `bodyOf`/`addRow`)。 + * + * `bodyOf`/`addRow` 现在**已经 `export`**(`packages/ui-kit/src/ViewContainer.tsx`, + * 零行为改动,只是加宽可见性)——**真实函数的行为**由 + * `packages/ui-kit/__tests__/view-container-addrow.test.ts` 直接 `import` 它们去测 + * (合成的最小 fixture,不碰任何活动的 HTML;那份文件的变异验证证明了它真的会因为 + * `ViewContainer.tsx` 里选错选择器而变红)。**这份文件依然不 `import` 那两个函数**—— + * 不是 depcruise 拦着(`packages-are-leaves` 只禁 `packages → apps` 这一个方向,没禁 + * `apps → packages`),是**类型系统**拦着:`ViewContainer.tsx` 是 `.tsx`(含 JSX), + * 编译它要 `"jsx": "react-jsx"` 这个 compiler option,而 `apps/api/**` 只受 + * `tsconfig.node.json` 管——那份配置没有 `jsx` 选项(它是 Node 后端,本不该解析 JSX)。 + * 所以这里仍然用**真实的 `renderView()`**(与 report.ts 的 `CHIP_TYPE_TABLE`/ + * `TYPE_PARAM_TABLE` 结构逐字节对齐的镜像 fixture,见下面 `CHIP_TYPE_FRAGMENT`/ + * `TYPE_PARAM_FRAGMENT` 头注)跑出「服务端会真的吐出什么 HTML」,再在 jsdom 里镜像 + * `bodyOf`/`addRow` 那两行选择器(标了对应的源码行号,与 `view-container-addrow.test.ts` + * 里那份文本上完全一致)去读它——这里测的是**渲染结果的形状**,选择器路径本身的正确性已经 + * 交给上面那份文件去守,这里不重复承担那份责任。 */ // @vitest-environment jsdom /* @@ -51,9 +63,13 @@ import { renderView } from '../internal/domain/view-render.ts'; /** * 复刻 `ViewContainer.bodyOf()` + `addRow()` 的选择器链(逐行对照 - * `packages/ui-kit/src/ViewContainer.tsx:164-167` 与 `:303-306`)——不 import 私有函数 - * (它们没有 `export`,且 `apps/api` 不许静态 import `activities/**`,这份测试也够不着 - * `packages/ui-kit`),只镜像**选择器本身**,在真实 jsdom Document 上跑。 + * `packages/ui-kit/src/ViewContainer.tsx:164-167` 与 `:303-306`)——两函数现在已经 + * `export` 了,但这份文件仍不 `import` 它们:`ViewContainer.tsx` 是 `.tsx`(含 JSX), + * 编译要 `"jsx": "react-jsx"`,而 `apps/api/**` 只受 `tsconfig.node.json` 管、没有这个 + * compiler option——类型系统这一关过不去,不是 depcruise 拦着。真实函数的行为已经交给 + * `packages/ui-kit/__tests__/view-container-addrow.test.ts` 去直接 import 测了,这里 + * 只镜像**选择器本身**(与那份文件里的写法逐字一致),在真实 jsdom Document 上跑, + * 用来验证 `renderView()` 吐出来的 HTML 形状。 */ function findAddRowTarget(doc: Document, address: string): HTMLElement | null { // 镜像 bodyOf():单个 querySelector,按文档序返回第一个匹配 diff --git a/packages/ui-kit/__tests__/view-container-addrow.test.ts b/packages/ui-kit/__tests__/view-container-addrow.test.ts new file mode 100644 index 0000000..64df7d3 --- /dev/null +++ b/packages/ui-kit/__tests__/view-container-addrow.test.ts @@ -0,0 +1,144 @@ +/** + * **`addRow()`/`bodyOf()` 对 `` 位置敏感 —— 这条测试守的是通用机制,不是某个活动的 + * HTML**(2026-08-30,复审用变异逮到:`view-render-addrow-thead.test.ts` 里那 6 条只 import + * 了一份手抄的选择器复刻,把 `addRow` 真实实现里的 `tr:last-of-type` 换成明摆着错的 + * `tr:first-of-type` 重跑,6/6 照样全绿零反应 —— 全仓当时没有任何测试真的执行 `bodyOf`/ + * `addRow` 这两个函数)。 + * + * 起因(ACT-WORK-05 Task 7b):当 `data-edit="域.对象"` 挂在**外层 ``**(不是只挂在 + * `` 上)时,`bodyOf()` 的单个 `document.querySelector` 解析到的是这个 + * `
` 本身;`addRow()` 接着在这个 host 里找 `tr:last-of-type`。CSS `:last-of-type` + * **按各自父元素**判定,`` 里孤零零的那个 `` 在它自己的父元素(`thead`)里必然 + * 「最后」——若 `` 排在数据行**前面**(常规写法),`querySelectorAll` 会**优先命中它** + * (文档序在前):克隆出来的是表头文字、零个 `input[data-col]`,不是一行能填的空白。 + * 把 `` 挪到数据行**之后**就避开了(CSS `table-header-group` 视觉上仍永远排最前, + * 呈现不变,`querySelector` 认的是文档序不是视觉序)。 + * + * `bodyOf`/`addRow` 原本没有 `export`(零行为改动,只是加宽可见性)——两函数本就是纯粹按 + * `Document`/字符串地址操作,不依赖任何活动的具体内容,合成的最小 fixture 就测得出这条 + * 通用机制,不必碰任何真活动的 HTML(那条路会撞 `apps-not-into-activities`)。 + * + * **重要一点(写这份测试时自己踩过两次,记下来免得下一个人重踩)**: + * ① `addRow()` 的 `host.appendChild(fresh)` 把克隆出来的新行直接挂在 `host` + * (即那张 `
`)下面,不是插回原来那个 `` 或 ``——所以判断 + * 「克隆对不对」不能查新行的**落点**(它永远是 `
` 的直接子节点,这本身不是 + * bug,HTML5 允许 `` 直接挂在 `
` 下,浏览器按匿名行组渲染),只能查新行的 + * **内容**(有没有 `` 都只放一行,拿 `tr:first-of-type` 去跑照样全绿,等于白测。要让两个 + * 选择器分道扬镳,动态 `` 必须**至少两行**,且两行要带得出「究竟克隆了哪一行」 + * 的证据(`data-marker`)——`withDataRows: 2` 那组就是干这个的。 + * + * **这份文件与另外两处测试各守各的**(缺一不可,写清楚免得再被合并成一条): + * ①这里守**机制**——`addRow`/`bodyOf` 面对「data-edit 挂外层 table + thead 在前/在后 + + * 多行数据」这几种通用形状时的真实行为;②`apps/api/src/modules/interaction/__tests__/ + * view-render-addrow-thead.test.ts` 守**W05 这两张表此刻的具体 HTML 形状是对的** + * (真实 `renderView()` 输出,但选择器那一步仍是手抄复刻,不覆盖机制本身); + * ③`activities/part-substitution-assessment/__tests__/activity.test.ts` 里那条 + * 「回归安全网」守**源码字符串层面** thead 确实排在两个 tbody 之后。 + */ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; +import { addRow, bodyOf } from '../src/ViewContainer.tsx'; + +/** + * 最小合成 fixture:`data-edit="d.o"` 只挂外层 `
`、`input[data-col]` 携带的是哪一行的值)。 + * ② `tr:last-of-type`(真实实现)与 `tr:first-of-type`(变异后)在**只有一行**的 + * 行组里**永远等价**(只有一个元素,天然同时是「第一个」与「最后一个」)——早期版本 + * 每个 `
`(W05 那两张表的真实挂法, + * 为了让 `collect()` 同时收到静态种子行与动态行);`` 位置可控,动态 `` + * 里的数据行数可控(每行带 `data-marker` 标出「我是第几行」,用来证明克隆的到底是哪一行)。 + */ +function table(opts: { theadFirst: boolean; dataRows: number }): Document { + const doc = document.implementation.createHTMLDocument(''); + const thead = ''; + const dataRowsHtml = Array.from({ length: opts.dataRows }, (_, i) => i + 1) + .map((n) => ``) + .join(''); + const dynamicTbody = `${dataRowsHtml}`; + const seedRow = ''; + const staticTbody = `${seedRow}`; + const inner = opts.theadFirst + ? `${thead}${dynamicTbody}${staticTbody}` + : `${dynamicTbody}${staticTbody}${thead}`; + doc.body.innerHTML = `
#名称
${inner}
`; + return doc; +} + +describe('bodyOf():data-edit 挂外层 时,解析到的就是那个
本身', () => { + it('无论 thead 在前在后,bodyOf 命中的都是
(唯一挂了 data-edit 的外层元素)', () => { + for (const theadFirst of [true, false]) { + const doc = table({ theadFirst, dataRows: 0 }); + const host = bodyOf(doc, 'd.o'); + expect(host?.tagName, `theadFirst=${theadFirst}`).toBe('TABLE'); + } + }); +}); + +describe('addRow(): 排在数据行前面 —— 克隆到表头(坏的那一形,零行时)', () => { + it('克隆出来的新行内容是表头(带
),零个 input[data-col] —— 按钮点了有反应,结果不可用', () => { + const doc = table({ theadFirst: true, dataRows: 0 }); + const before = doc.querySelectorAll('tr').length; + + const ok = addRow(doc, 'd.o'); + + expect(ok).toBe(true); // 找到了「样本」去克隆,只是克隆错了行 —— 不报错、不失败,静默产出垃圾 + expect(doc.querySelectorAll('tr').length).toBe(before + 1); + const fresh = doc.querySelector('.jc-row-new'); + expect(fresh).not.toBeNull(); + // 克隆出来的新行是 的直接子节点(addRow 的 host.appendChild 就是这么接的, + // 不是插回原来的 ——判断靠内容不靠落点,见头注①) + expect(fresh?.parentElement?.tagName).toBe('TABLE'); + expect(fresh?.querySelectorAll('th')).toHaveLength(2); // 内容是表头文字(#、名称) + expect(fresh?.querySelectorAll('input[data-col]')).toHaveLength(0); // 零个可填格子 + }); +}); + +describe('addRow(): 排在两个 之后 —— 克隆到数据行(好的那一形,零行时)', () => { + it('克隆出来的新行是种子行的内容:1 个具名 input[data-col],不含 ` 摆两行(`existing-1`/`existing-2`),`addRow` 该续克隆 + * 的是**最后**那一行。真实实现命中 `existing-2`;若选择器被换成 `tr:first-of-type`, + * 会命中 `existing-1`——这正是复审要求的变异要能让测试变红的落点。 + */ +describe('addRow():动态 tbody 有两条真实数据行(post-first-save 状态)—— 续克隆的必须是最后一条', () => { + it('克隆样本携带 row-2 的标记(不是 row-1、不是种子行、不是表头)', () => { + const doc = table({ theadFirst: false, dataRows: 2 }); + + const ok = addRow(doc, 'd.o'); + + expect(ok).toBe(true); + const fresh = doc.querySelector('.jc-row-new'); + expect(fresh?.querySelectorAll('th')).toHaveLength(0); + // 克隆样本原本挂着 data-marker="row-2"(cloneNode 会带过来,addRow 没有清它) + expect(fresh?.getAttribute('data-marker')).toBe('row-2'); + const input = fresh?.querySelector('input[data-col="name"]'); + expect(input).not.toBeNull(); + expect(input?.hasAttribute('placeholder')).toBe(false); // 种子行的 input 才带 placeholder + expect(input?.value).toBe(''); // 值已被 addRow 清空(不是抄 "existing-2" 过来) + }); +}); + +describe('addRow():真的一整张表连表头都没有一行 —— 找不到样本可克隆,如实返回 false', () => { + it('host 存在但一个 都没有(连 thead 也没有)时,addRow 不产出垃圾,直接说做不到', () => { + const doc = document.implementation.createHTMLDocument(''); + doc.body.innerHTML = '
', () => { + const doc = table({ theadFirst: false, dataRows: 0 }); + + const ok = addRow(doc, 'd.o'); + + expect(ok).toBe(true); + const fresh = doc.querySelector('.jc-row-new'); + expect(fresh).not.toBeNull(); + expect(fresh?.querySelectorAll('th')).toHaveLength(0); + const inputs = Array.from(fresh?.querySelectorAll('input[data-col]') ?? []); + expect(inputs.map((i) => i.getAttribute('data-col'))).toEqual(['name']); + // 新克隆的行值清空(种子行原样是空的,这里额外钉一遍「不是抄了旧值」) + expect(inputs[0]?.value).toBe(''); + }); +}); + +/** + * **这一组是让 `tr:last-of-type` 与 `tr:first-of-type` 真正分道扬镳的那一组** + * (见头注②)——动态 `
'; + expect(bodyOf(doc, 'd.o')?.tagName).toBe('TABLE'); + expect(addRow(doc, 'd.o')).toBe(false); + expect(doc.querySelectorAll('.jc-row-new')).toHaveLength(0); + }); +}); diff --git a/packages/ui-kit/src/ViewContainer.tsx b/packages/ui-kit/src/ViewContainer.tsx index e6db88d..6ba7b88 100644 --- a/packages/ui-kit/src/ViewContainer.tsx +++ b/packages/ui-kit/src/ViewContainer.tsx @@ -162,7 +162,7 @@ function replaceInPlace(doc: Document, html: string): void { } /** 一张可改的表的容器(tbody)—— 按 `data-edit` 地址找 */ -function bodyOf(doc: Document, address: string): HTMLElement | null { +export function bodyOf(doc: Document, address: string): HTMLElement | null { return doc.querySelector(`[data-edit="${CSS.escape(address)}"]`); } @@ -300,7 +300,7 @@ export function dropRow(row: HTMLElement): 'removed' | 'marked' | 'unmarked' { * 一张空表(一行都没有)加不了行 —— 没有可照的样子;那种情形本来也不该由人开头 * (行由活动写下,人改的是它写错的地方)。 */ -function addRow(doc: Document, address: string): boolean { +export function addRow(doc: Document, address: string): boolean { const host = bodyOf(doc, address); const last = host?.querySelector('tr:last-of-type'); if (host === null || last == null) return false;