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 允许 `` 直接挂在 `` 下,浏览器按匿名行组渲染),只能查新行的
+ * **内容**(有没有 `| `、`input[data-col]` 携带的是哪一行的值)。
+ * ② `tr:last-of-type`(真实实现)与 `tr:first-of-type`(变异后)在**只有一行**的
+ * 行组里**永远等价**(只有一个元素,天然同时是「第一个」与「最后一个」)——早期版本
+ * 每个 ` | ` 都只放一行,拿 `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"` 只挂外层 ``(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 = ``;
+ 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],不含 ', () => {
+ 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` 真正分道扬镳的那一组**
+ * (见头注②)——动态 `` 摆两行(`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 = '';
+ 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;
| |