fix(w05): Task 7b 评审 Critical —— data-edit 挂外层 table 后「加一行」克隆到表头,不是数据行

外层 data-edit 是为了让 collect() 同时收到静态种子行与动态行,副作用是 ViewContainer.bodyOf()
的单个 querySelector 从此解析到 <table> 本身,addRow() 接着在这个 host 里找 tr:last-of-type ——
CSS :last-of-type 按各自父元素判定,<thead> 里孤零零的那个 <tr> 在它自己的父元素里必然「最后」,
若 <thead> 排在数据行前面就会被文档序优先命中:克隆出来的是四个 <th> 表头文字、零个
input[data-col],还被 appendChild 到 <table> 底下、不在任何 <tbody> 里。parts.type_param 那张
更要紧——它的加行功能在这次改动之前本来是好的(data-edit 只挂 tbody 时,querySelectorAll 的
搜索范围本就不含 thead),外层 data-edit 一挂就成了真回归。

修法(不改 ViewContainer.tsx/view-render.ts):把两张表的 <thead> 挪到两个 <tbody> 之后。CSS 的
table-header-group 排版规则视觉上永远排最前、不看它在 DOM 里排第几(与 HTML5 允许 tfoot 写在
thead 之后、tbody 之前同一条原理)——呈现不变,而 querySelector/:last-of-type 认的是文档序,
thead 排最后,选择器就再也碰不到表头行。

补 6 条真 DOM 测试(apps/api/.../view-render-addrow-thead.test.ts):真实 renderView() 输出
在 jsdom 里挂起来,逐行复刻 bodyOf()/addRow() 的真实选择器链(它们没 export,双向 depcruise
规则也拦着直接 import),断言克隆样本落在 tbody 里、含 N 个具名 input[data-col],不是表头。
apps/api 只受 tsconfig.node.json 管(零 DOM lib),packages/ui-kit 又不许反向 import apps/*
(packages-are-leaves)——用 TS 标准的 per-file `/// <reference lib="dom" />` 只给这一份文件
挂 DOM 类型,不改 tsconfig 本身。activity.test.ts 另补一条字符串级结构安全网(thead 确实排在
两个 tbody 之后),把源码与镜像测试的假设锁在一起。变异验证两段输出(拿掉修复→3/6 真红,
含 report.ts 真实源码单独验证一次红/绿→改回来全绿)贴在报告里。

Important 两条:①两个平台坑(resolveRowKey 续号步骤不认 auto、coerceToColumn 削前导零)
记入 docs/debt.md,写清仍未修、只是被本活动的声明层绕开;②manifest.ts/activity.test.ts 里
「台账是 JSONB 不存在物理列类型」这句错的论据删掉——master/record 是真实物理列(bigint),
只有草稿层是 JSONB,安全的真正原因是 schema-merge.ts 的单调加宽 lattice 把 integer→text
判定成合法加宽,widen-column 生成的 ALTER TYPE text USING col::text 沿 PG 转型没有失败路径。

pnpm gates 退出码 0(2417 passed | 34 skipped);E04/I01 包内测试 92/92、55/55 全绿。
packageVersion 2.1.0 → 2.1.1。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
xcs
2026-08-30 00:23:47 +08:00
co-authored by Claude Opus 5
parent d9bcce9516
commit 57870329e6
5 changed files with 384 additions and 27 deletions
@@ -0,0 +1,229 @@
/**
* **「加一行」克隆到的必须是数据行,不是表头行**(2026-08-30,评审逮到的 Critical 回归)。
*
* 背景(ACT-WORK-05 差异评估 Task 7b):`parts.chip_type` / `parts.type_param` 两张表
* 需要在**零草稿行**时也能「加一行」(`activities/part-substitution-assessment/src/views/
* report.ts` 的 `CHIP_TYPE_TABLE`/`TYPE_PARAM_TABLE`)。为了让 `ViewContainer.collect()`
* 同时收到「已有的动态行(`data-rows`)」与「一条不受行数所限的静态种子行」两处的输入,
* `data-edit="域.对象"` 挂在了外层 `<table>` 上(而不是只挂在内层 `data-rows` 的
* `<tbody>` 上)。
*
* 副作用:`ViewContainer.bodyOf()`(`packages/ui-kit/src/ViewContainer.tsx:164-167`,
* 单个 `document.querySelector`,按文档序返回第一个匹配)从此解析到**外层 `<table>`**;
* `addRow()`(`:303-323`)接着在这个 host 里找 `tr:last-of-type` —— CSS `:last-of-type`
* 是**相对各自父元素**判定的,`<thead>` 里那个孤零零的 `<tr>` 在它自己的父元素(`thead`)
* 里必然是「最后一个」,若 `<thead>` 在文档序上排在数据行之前,它就会被
* `querySelectorAll('tr:last-of-type')` 第一个命中 —— 克隆出来的是四个 `<th>` 表头文字、
* **零个** `input[data-col]`,还被 `appendChild` 到 `<table>` 底下、不在任何 `<tbody>` 里。
* 按钮点了有反应,结果完全不可用,且不报错。
*
* 修法(`report.ts`,不改 `ViewContainer.tsx`/`view-render.ts` 这两个共享面):把 `<thead>`
* 挪到**两个 `<tbody>` 之后**、`</table>` 之前。CSS 表格布局规范里
* `table-header-group`(`<thead>` 的默认 display)**始终排在最前面渲染,不看它在 DOM 里
* 排第几**(这是 `tfoot` 允许写在 `thead` 后面、`tbody` 前面这条 HTML5 规则背后同一条
* 视觉排版原理)——视觉呈现不变,而 `document.querySelector`/`querySelectorAll` 认的是
* **文档序**,不是视觉序:`<thead>` 排在两个 `<tbody>` 后面,`tr:last-of-type` 的搜索
* 就再也碰不到表头行(表头行在文档序上比所有数据行/种子行都晚,只有当两个 `<tbody>`
* 都彻底没有 `<tr>` 时才轮得到它——而静态种子行保证了这种情况不会发生)。
*
* 这份测试**不能** `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` 那两行选择器(标了对应的源码行号)
* —— 测的是选择器路径本身的行为,不是字符串包含。
*/
// @vitest-environment jsdom
/*
* apps/api/** 只受 tsconfig.node.json 管(lib: ES2023,无 DOM——它是 Node 后端,本不该有
* DOM 全局)。这份测试需要真实 DOM(document/HTMLElement)去挂载 renderView() 的输出、
* 复刻 bodyOf()/addRow() 的选择器链——挪去 packages/ui-kit(有 DOM lib)又会撞
* packages-are-leaves(packages/* 不许反向 import apps/*),renderView 只活在 apps/api
* 里。两难之下用 TS 标准机制解:per-file 的三斜线 lib 引用只给这一份文件额外挂上 DOM 的
* 环境类型,不改 tsconfig.node.json 本身(该配置对 apps/api 其余所有文件原样零 DOM)。
*/
/// <reference lib="dom" />
import { describe, expect, it } from 'vitest';
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 上跑。
*/
function findAddRowTarget(doc: Document, address: string): HTMLElement | null {
// 镜像 bodyOf():单个 querySelector,按文档序返回第一个匹配
const host = doc.querySelector<HTMLElement>(`[data-edit="${address}"]`);
if (host === null) return null;
// 镜像 addRow() 的取「克隆样本」那一步:host 内找 tr:last-of-type
return host.querySelector<HTMLElement>('tr:last-of-type');
}
/**
* 与 `report.ts` 的 `CHIP_TYPE_TABLE` 逐字节对齐的镜像(4 列:# / 编号 / 类型名称 / 类型说明;
* `type_id` 只读不挂 `data-col`——自增列不问人;种子行两格 `data-col`:type_name / description)。
* `thead` 排在两个 `tbody` **之后**——这是本测试要钉死的结构。
*/
const CHIP_TYPE_FRAGMENT = `<div class="jc-tbl-wrap"><table data-edit="parts.chip_type">
<tbody data-rows="mine:parts.chip_type" data-edit="parts.chip_type"><tr>
<td class="rn"><span class="jc-rn-num"></span></td>
<td><span class="jc-vcell ro mono" data-slot="type_id" data-blank></span></td>
<td><span class="jc-vcell" data-slot="type_name" data-col="type_name" data-blank></span></td>
<td><span class="jc-vcell" data-slot="description" data-col="description" data-blank></span></td>
</tr></tbody>
<tbody><tr class="jc-row-static">
<td class="rn"><span class="jc-rn-num"></span></td>
<td><span class="jc-vcell ro">系统生成</span></td>
<td><input class="jc-cell" data-col="type_name" placeholder="新类型名称"></td>
<td><input class="jc-cell" data-col="description" placeholder="这个类型是什么(供选型参考)"></td>
</tr></tbody>
<thead><tr>
<th style="width:38px">#</th>
<th data-h="类型编号">编号</th>
<th class="req" data-h="类型名称">类型名称</th>
<th data-h="类型说明">类型说明</th>
</tr></thead>
</table></div>`;
/**
* 与 `report.ts` 的 `TYPE_PARAM_TABLE` 逐字节对齐的镜像(6 列;`type_id` 是下拉
* `data-options`,不是裸文本 —— Task 7b 的另一条修改)。同样把 `thead` 挪到两个 `tbody` 之后。
*/
const TYPE_PARAM_FRAGMENT = `<div class="jc-tbl-wrap"><table data-edit="parts.type_param">
<tbody data-rows="mine:parts.type_param" data-edit="parts.type_param"><tr>
<td class="rn"><span class="jc-rn-num"></span><button class="jc-rowdel" type="button" data-edit-del="parts.type_param">×</button></td>
<td>
<span class="jc-vcell"><span data-rows="parts.chip_type" data-by="type_id=type_id"><i data-slot="type_name" data-blank></i></span></span>
<select class="jc-cell" data-col="type_id" required data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select>
</td>
<td><span class="jc-vcell" data-slot="param_name" data-col="param_name" data-blank></span></td>
<td><span class="jc-vcell" data-slot="param_desc" data-col="param_desc" data-blank></span></td>
<td><select class="jc-cell" data-col="constraint_type"><option value=""></option><option value="hard">硬约束</option><option value="soft">软约束</option></select></td>
<td><span class="jc-vcell" data-slot="param_unit" data-col="param_unit" data-blank></span></td>
</tr></tbody>
<tbody><tr class="jc-row-static">
<td class="rn"><span class="jc-rn-num"></span></td>
<td><select class="jc-cell" data-col="type_id" required data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select></td>
<td><input class="jc-cell" data-col="param_name" placeholder="参数名"></td>
<td><input class="jc-cell" data-col="param_desc" placeholder="这个参数为什么要紧"></td>
<td><select class="jc-cell" data-col="constraint_type"><option value=""></option><option value="hard">硬约束</option><option value="soft">软约束</option></select></td>
<td><input class="jc-cell" data-col="param_unit" placeholder="单位(选填)"></td>
</tr></tbody>
<thead><tr>
<th style="width:38px">#</th>
<th class="req" data-h="所属类型">所属类型</th>
<th class="req" data-h="参数名">参数名</th>
<th class="req" data-h="参数说明">参数说明</th>
<th class="req" data-h="约束类型">约束(硬/软)</th>
<th data-h="参考单位">参考单位</th>
</tr></thead>
</table></div>`;
describe('parts.chip_type(零行时):「加一行」必须落在种子行上,不是表头', () => {
it('renderView() 真实输出,零草稿行 —— 动态 tbody 空,种子行还在', () => {
const html = renderView(CHIP_TYPE_FRAGMENT, { 'mine:parts.chip_type': [] });
const doc = document.implementation.createHTMLDocument('');
doc.body.innerHTML = html;
// renderRows() 消费掉 data-rows 属性(只留 data-edit),动态 tbody 按位置认(文档序第一个 <tbody>)
const dynamicTbody = doc.querySelectorAll('tbody')[0];
expect(dynamicTbody?.querySelectorAll('tr').length).toBe(0);
expect(doc.querySelectorAll('tr.jc-row-static')).toHaveLength(1);
});
it('走一遍 bodyOf()/addRow() 的真实选择器路径:克隆样本落在 tbody 里,不是 thead', () => {
const html = renderView(CHIP_TYPE_FRAGMENT, { 'mine:parts.chip_type': [] });
const doc = document.implementation.createHTMLDocument('');
doc.body.innerHTML = html;
const target = findAddRowTarget(doc, 'parts.chip_type');
expect(target).not.toBeNull();
// 命中的必须是 <tbody> 的直接子行 —— 不是 <thead> 那一行
expect(target?.parentElement?.tagName).toBe('TBODY');
expect(target?.closest('thead')).toBeNull();
// 且它就是那条种子行(class 对得上)
expect(target?.classList.contains('jc-row-static')).toBe(true);
// 含 2 个 input[data-col](type_name / description),不是 0 个(表头行会是 0)
const inputs = Array.from(target?.querySelectorAll('input[data-col]') ?? []);
expect(inputs.map((i) => i.getAttribute('data-col')).sort()).toEqual(['description', 'type_name']);
});
});
describe('parts.chip_type(已有 1 行时):「加一行」克隆的是那条真数据行', () => {
it('走一遍选择器路径:命中动态 tbody 的最后一条真实数据行,不是种子行也不是表头', () => {
const html = renderView(CHIP_TYPE_FRAGMENT, {
'mine:parts.chip_type': [{ type_id: '1', type_name: 'DC-DC 降压稳压器', description: '开关式降压转换器' }],
});
const doc = document.implementation.createHTMLDocument('');
doc.body.innerHTML = html;
const target = findAddRowTarget(doc, 'parts.chip_type');
expect(target).not.toBeNull();
// renderRows() 消费掉 data-rows 属性 —— 认「不是种子行、且是文档序第一个 tbody(动态那个)的行」
expect(target?.parentElement).toBe(doc.querySelectorAll('tbody')[0]);
expect(target?.classList.contains('jc-row-static')).toBe(false);
expect(target?.closest('thead')).toBeNull();
// 这一行真的带着那条料的值(证明不是表头、不是种子行)
expect(target?.textContent).toContain('DC-DC 降压稳压器');
});
});
describe('parts.type_param(零行时):同款结构,同样要验', () => {
it('renderView() 真实输出,零草稿行 —— 动态 tbody 空,种子行还在', () => {
const html = renderView(TYPE_PARAM_FRAGMENT, { 'mine:parts.type_param': [] });
const doc = document.implementation.createHTMLDocument('');
doc.body.innerHTML = html;
// renderRows() 消费掉 data-rows 属性(只留 data-edit),动态 tbody 按位置认(文档序第一个 <tbody>)
const dynamicTbody = doc.querySelectorAll('tbody')[0];
expect(dynamicTbody?.querySelectorAll('tr').length).toBe(0);
expect(doc.querySelectorAll('tr.jc-row-static')).toHaveLength(1);
});
it('走一遍选择器路径:克隆样本落在 tbody 里、6 个可填格子都在,不是表头(表头会是 0 个)', () => {
const html = renderView(TYPE_PARAM_FRAGMENT, { 'mine:parts.type_param': [] });
const doc = document.implementation.createHTMLDocument('');
doc.body.innerHTML = html;
const target = findAddRowTarget(doc, 'parts.type_param');
expect(target).not.toBeNull();
expect(target?.parentElement?.tagName).toBe('TBODY');
expect(target?.closest('thead')).toBeNull();
expect(target?.classList.contains('jc-row-static')).toBe(true);
const cols = Array.from(target?.querySelectorAll('input[data-col], select[data-col]') ?? [])
.map((i) => i.getAttribute('data-col'))
.sort();
expect(cols).toEqual(['constraint_type', 'param_desc', 'param_name', 'param_unit', 'type_id']);
});
});
/**
* **这是 Task 7b 修复之前的真实回归**(评审逮到:「parts.type_param 那张更要紧:它的
* 加行功能在你改动之前是好的」)——`data-edit` 只挂在内层 `<tbody data-rows>` 上(不挂
* 外层 `<table>`),`thead` 排在最前面(改回归修复前的原始顺序)。这条测试**不是**用来
* 证明当前代码正确——它证明的是「如果 `data-edit` 回到只挂 `<table>` 且 `thead` 排在最前」
* 这个错误形状,选择器确实会命中表头(0 个 `input[data-col]`)。留着当变异验证的可读记录:
* 不必真的改 `report.ts` 再改回来,这条测试本身就是「变了会怎样」的固化证据。
*/
describe('回归记录:data-edit 挂外层 table 且 thead 排最前 —— 这个错误形状会命中表头', () => {
it('(此形状已不出现在 report.ts 里,这里独立复现,证明它确实是错的)', () => {
const brokenFragment = `<table data-edit="parts.chip_type">
<thead><tr><th>#</th><th>编号</th><th>类型名称</th><th>类型说明</th></tr></thead>
<tbody data-rows="mine:parts.chip_type" data-edit="parts.chip_type"></tbody>
<tbody><tr class="jc-row-static"><td class="rn"></td><td><span class="jc-vcell ro">系统生成</span></td><td><input class="jc-cell" data-col="type_name"></td><td><input class="jc-cell" data-col="description"></td></tr></tbody>
</table>`;
const html = renderView(brokenFragment, { 'mine:parts.chip_type': [] });
const doc = document.implementation.createHTMLDocument('');
doc.body.innerHTML = html;
const target = findAddRowTarget(doc, 'parts.chip_type');
expect(target).not.toBeNull();
// 命中的是表头那一行:落在 thead 里,零个 input[data-col]
expect(target?.closest('thead')).not.toBeNull();
expect(target?.querySelectorAll('input[data-col]')).toHaveLength(0);
});
});