This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/packages/ui-kit/__tests__/view-container-addrow.test.ts
T
xcsandClaude Opus 5 832c662fdb fix(w05): Task 7b 评审 fix round 2 —— 6 条 DOM 测试是空的,bodyOf/addRow 加 export 后补真测试
复审用变异逮到:把 ViewContainer.tsx:305 的 tr:last-of-type 换成明摆着错的 tr:first-of-type
重跑 view-render-addrow-thead.test.ts 新增的 6 条,6/6 全绿零反应。根因:那 6 条只 import 了
一份手抄的选择器复刻(findAddRowTarget),与真实实现之间没有任何绑定,ViewContainer.tsx 换掉
选择器它不会跟着变 —— 全仓当时没有任何测试真的执行过 bodyOf/addRow,对这两个函数的未来回归
零防护。

「不能 export/import」这条前提是错的:packages-are-leaves 只禁 packages→apps,没有任何规则
禁 apps→packages;bodyOf/addRow 只是没加 export——加 export 是零行为改动。本轮第三次踩同一条
(Task 4 读数函数、Task 6 let 镜像,现在是选择器):禁令属实,但抄多了,能 import 的都该 import。

①ViewContainer.tsx:165/303 给 bodyOf/addRow 加 export,函数体一字未改。
②新增 packages/ui-kit/__tests__/view-container-addrow.test.ts(5 条),照 view-container-
collect.test.ts 的写法直接 import 真实函数,合成最小 fixture(不碰任何活动 HTML)。写的时候
自己踩过两处:addRow 的 appendChild 把新行接到 <table> 下面不是插回原 tbody/thead(断言要查
内容不能查落点);tr:last-of-type 与 tr:first-of-type 在单行行组里永远等价(补一组两行的
动态 tbody + data-marker 才让两个选择器真正分道扬镳)。变异验证:选择器换错后 1/5 真红
(row-2 变 row-1),复原验绿。

③两处测试的头注写清各守各的:新测试守机制(addRow/bodyOf 面对通用形状的真实行为),原 6 条
守 W05 这两张表此刻的具体 renderView() 输出形状是对的(选择器仍是镜像,但选择器本身的正确性
已交给新测试)——头注同时改正了此前「不能 import 是 depcruise 拦着」的错误说法,真实原因是
ViewContainer.tsx 含 JSX、需要 tsconfig.web.json 的 jsx 选项,apps/api 只受 tsconfig.node.json
管,类型系统这一关过不去。

pnpm gates 退出码 0(2422 passed | 34 skipped);E04/I01 包内测试 92/92、55/55 全绿,零回归。
报告同批刷新了上一轮贴错版本的失败输出证据(用当前最终代码重新跑过变异)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-30 01:00:58 +08:00

145 lines
8.9 KiB
TypeScript

/**
* **`addRow()`/`bodyOf()` 对 `<thead>` 位置敏感 —— 这条测试守的是通用机制,不是某个活动的
* 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="域.对象"` 挂在**外层 `<table>`**(不是只挂在
* `<tbody data-rows>` 上)时,`bodyOf()` 的单个 `document.querySelector` 解析到的是这个
* `<table>` 本身;`addRow()` 接着在这个 host 里找 `tr:last-of-type`。CSS `:last-of-type`
* **按各自父元素**判定,`<thead>` 里孤零零的那个 `<tr>` 在它自己的父元素(`thead`)里必然
* 「最后」——若 `<thead>` 排在数据行**前面**(常规写法),`querySelectorAll` 会**优先命中它**
* (文档序在前):克隆出来的是表头文字、零个 `input[data-col]`,不是一行能填的空白。
* 把 `<thead>` 挪到数据行**之后**就避开了(CSS `table-header-group` 视觉上仍永远排最前,
* 呈现不变,`querySelector` 认的是文档序不是视觉序)。
*
* `bodyOf`/`addRow` 原本没有 `export`(零行为改动,只是加宽可见性)——两函数本就是纯粹按
* `Document`/字符串地址操作,不依赖任何活动的具体内容,合成的最小 fixture 就测得出这条
* 通用机制,不必碰任何真活动的 HTML(那条路会撞 `apps-not-into-activities`)。
*
* **重要一点(写这份测试时自己踩过两次,记下来免得下一个人重踩)**:
* ① `addRow()` 的 `host.appendChild(fresh)` 把克隆出来的新行直接挂在 `host`
* (即那张 `<table>`)下面,不是插回原来那个 `<tbody>` 或 `<thead>`——所以判断
* 「克隆对不对」不能查新行的**落点**(它永远是 `<table>` 的直接子节点,这本身不是
* bug,HTML5 允许 `<tr>` 直接挂在 `<table>` 下,浏览器按匿名行组渲染),只能查新行的
* **内容**(有没有 `<th>`、`input[data-col]` 携带的是哪一行的值)。
* ② `tr:last-of-type`(真实实现)与 `tr:first-of-type`(变异后)在**只有一行**的
* 行组里**永远等价**(只有一个元素,天然同时是「第一个」与「最后一个」)——早期版本
* 每个 `<tbody>` 都只放一行,拿 `tr:first-of-type` 去跑照样全绿,等于白测。要让两个
* 选择器分道扬镳,动态 `<tbody>` 必须**至少两行**,且两行要带得出「究竟克隆了哪一行」
* 的证据(`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"` 只挂外层 `<table>`(W05 那两张表的真实挂法,
* 为了让 `collect()` 同时收到静态种子行与动态行);`<thead>` 位置可控,动态 `<tbody>`
* 里的数据行数可控(每行带 `data-marker` 标出「我是第几行」,用来证明克隆的到底是哪一行)。
*/
function table(opts: { theadFirst: boolean; dataRows: number }): Document {
const doc = document.implementation.createHTMLDocument('');
const thead = '<thead><tr><th>#</th><th data-h="名称">名称</th></tr></thead>';
const dataRowsHtml = Array.from({ length: opts.dataRows }, (_, i) => i + 1)
.map((n) => `<tr data-marker="row-${n}"><td class="rn"></td><td><input class="jc-cell" data-col="name" value="existing-${n}"></td></tr>`)
.join('');
const dynamicTbody = `<tbody data-rows data-edit="d.o">${dataRowsHtml}</tbody>`;
const seedRow = '<tr class="jc-row-static"><td class="rn"></td><td><input class="jc-cell" data-col="name" placeholder="新的"></td></tr>';
const staticTbody = `<tbody>${seedRow}</tbody>`;
const inner = opts.theadFirst
? `${thead}${dynamicTbody}${staticTbody}`
: `${dynamicTbody}${staticTbody}${thead}`;
doc.body.innerHTML = `<div class="jc-tbl-wrap"><table data-edit="d.o">${inner}</table></div>`;
return doc;
}
describe('bodyOf():data-edit 挂外层 <table> 时,解析到的就是那个 <table> 本身', () => {
it('无论 thead 在前在后,bodyOf 命中的都是 <table>(唯一挂了 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():<thead> 排在数据行前面 —— 克隆到表头(坏的那一形,零行时)', () => {
it('克隆出来的新行内容是表头(带 <th>),零个 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();
// 克隆出来的新行是 <table> 的直接子节点(addRow 的 host.appendChild 就是这么接的,
// 不是插回原来的 <thead>——判断靠内容不靠落点,见头注①)
expect(fresh?.parentElement?.tagName).toBe('TABLE');
expect(fresh?.querySelectorAll('th')).toHaveLength(2); // 内容是表头文字(#、名称)
expect(fresh?.querySelectorAll('input[data-col]')).toHaveLength(0); // 零个可填格子
});
});
describe('addRow():<thead> 排在两个 <tbody> 之后 —— 克隆到数据行(好的那一形,零行时)', () => {
it('克隆出来的新行是种子行的内容:1 个具名 input[data-col],不含 <th>', () => {
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<HTMLInputElement>('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` 真正分道扬镳的那一组**
* (见头注②)——动态 `<tbody>` 摆两行(`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<HTMLInputElement>('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 存在但一个 <tr> 都没有(连 thead 也没有)时,addRow 不产出垃圾,直接说做不到', () => {
const doc = document.implementation.createHTMLDocument('');
doc.body.innerHTML = '<table data-edit="d.o"></table>';
expect(bodyOf(doc, 'd.o')?.tagName).toBe('TABLE');
expect(addRow(doc, 'd.o')).toBe(false);
expect(doc.querySelectorAll('.jc-row-new')).toHaveLength(0);
});
});