/** * **`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); }); });