Archived
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:
@@ -133,12 +133,18 @@ describe('台账声明', () => {
|
||||
});
|
||||
|
||||
/*
|
||||
* Task 7b(2026-08-29):`type_id` 声明是 `text` 不是 `integer` —— 台账是 JSONB,不存在
|
||||
* 物理列类型这回事,这条纯粹是逻辑类型;改 text 是因为它是本仓唯一「auto:true 又是
|
||||
* 整数型」的主键,integer 会踩两个坑(`resolveRowKey` 的续号步骤抢在 `autoKeys()` 前面
|
||||
* 把它按草稿行数续成 1、撞种子的 `type_id=1`;`autoKeys()` 补零的字符串 `'03'` 经引用列的
|
||||
* `coerceToColumn(_, 'integer')` 会被 `Number()` 削掉前导零,`refersTo`/查表按字符串比对
|
||||
* 全部失配)。查证过程见 Task 7b 报告。
|
||||
* Task 7b(2026-08-29):`type_id` 声明是 `text` 不是 `integer` —— 它是本仓唯一
|
||||
* 「auto:true 又是整数型」的主键,integer 会踩两个平台坑(仍未修,见 docs/debt.md):
|
||||
* ① `resolveRowKey` 的续号步骤只认 declared type 是不是 integer/number(`isSeq`),
|
||||
* 不看 `auto`,抢在 `autoKeys()` 前面把它按草稿行数续成 1、撞种子的 `type_id=1`;
|
||||
* ② `autoKeys()` 补零的字符串 `'03'` 经引用列的 `coerceToColumn(_, 'integer')` 会被
|
||||
* `Number()` 削掉前导零,`refersTo`/查表按字符串比对全部失配。
|
||||
* **改 text 不是因为「台账没有物理列类型」**——`master`/`record` 是真实物理列
|
||||
* (`ledger-store.ts` 的 `PG_TYPE` 映射,`integer → bigint`);只有任务级草稿层
|
||||
* `ledger.__draft` 才是 JSONB。安全的真正原因是 `schema-merge.ts` 的单调加宽 lattice
|
||||
* 把 `integer → text` 判定成合法加宽(不是类型冲突),`widen-column` 那支生成的是
|
||||
* `alter column … type text using col::text`——bigint 转 text 沿 PG 的转型没有失败路径。
|
||||
* 查证过程见 Task 7b 报告。
|
||||
*/
|
||||
it('类型字典:type_id 是自增主键 —— 人在件上新建类型时留空,由基座续号', () => {
|
||||
expect(colOf('parts.chip_type', 'type_id')).toMatchObject({ primaryKey: true, type: 'text', auto: true });
|
||||
@@ -532,6 +538,46 @@ describe('件① 评估输入补全', () => {
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **评审 Critical 回归修复的结构安全网**(2026-08-30):`data-edit` 两处写法
|
||||
* (上面那条)有个副作用——`ViewContainer.bodyOf()` 的 `document.querySelector`
|
||||
* 从此解析到外层 `<table>`,而 `addRow()` 接着在这个 host 里找 `tr:last-of-type`——
|
||||
* CSS `:last-of-type` 是按各自父元素判定的,`<thead>` 里孤零零的那个 `<tr>` 在它
|
||||
* 自己的父元素(`thead`)里必然「最后」,若 `<thead>` 排在数据行前面,它会被文档序
|
||||
* 优先命中:克隆出来的是表头文字,零个 `input[data-col]`,`parts.type_param`
|
||||
* 那张(改动前本来是好的)因此被做坏——一次真回归(评审用 jsdom 在 renderView() 真实
|
||||
* 输出上复现两次坐实)。
|
||||
*
|
||||
* 修法是把 `<thead>` 挪到两个 `<tbody>` 之后(CSS 的 `table-header-group` 排版规则:
|
||||
* 视觉上永远排最前,不看它在 DOM 里排第几——这条规则本来就是 HTML5 允许 `tfoot`
|
||||
* 写在 `thead` 之后、`tbody` 之前的原理)——`document.querySelector`/`:last-of-type`
|
||||
* 认的是文档序不是视觉序,`<thead>` 排在两个 `<tbody>` 后面,`tr:last-of-type` 的
|
||||
* 搜索就再也碰不到表头行。
|
||||
*
|
||||
* 走 DOM 选择器的那部分行为(渲染后 `addRow`/`bodyOf` 真的会不会命中表头)由
|
||||
* `apps/api/src/modules/interaction/__tests__/view-render-addrow-thead.test.ts`
|
||||
* 覆盖(activities 包按 ADR-004 只准 import @sdk/@contracts/@sfeel,够不着
|
||||
* `apps/api` 的 `renderView`,所以那份测试镜像了这份源码的结构,不是 import 它)——
|
||||
* 这里补的是**廉价的字符串安全网**,把这份源码与那份镜像测试的假设锁在一起:
|
||||
* 谁若把 `<thead>` 挪回两个 `<tbody>` 之前(哪怕只改一张表),这条测试立刻标红,
|
||||
* 提醒去核对 `view-render-addrow-thead.test.ts` 是否也需要同步。
|
||||
*/
|
||||
it('**回归安全网**:两张新表的 <thead> 都排在两个 <tbody> 之后(不是最前面)—— 与 view-render-addrow-thead.test.ts 的假设锁在一起', () => {
|
||||
for (const address of ['parts.chip_type', 'parts.type_param']) {
|
||||
const tableStart = html.indexOf(`<table data-edit="${address}">`);
|
||||
expect(tableStart, address).toBeGreaterThanOrEqual(0);
|
||||
const tableEnd = html.indexOf('</table>', tableStart);
|
||||
const tableHtml = html.slice(tableStart, tableEnd);
|
||||
const theadAt = tableHtml.indexOf('<thead>');
|
||||
const tbodyPositions = [...tableHtml.matchAll(/<tbody/g)].map((m) => m.index ?? -1);
|
||||
expect(theadAt, `${address}: 找不到 <thead>`).toBeGreaterThanOrEqual(0);
|
||||
expect(tbodyPositions.length, `${address}: 应有两个 <tbody>`).toBe(2);
|
||||
// <thead> 必须排在两个 <tbody> 的开标签**之后**(源码序;视觉呈现由 CSS 的
|
||||
// table-header-group 保证仍在最前,与源码序无关)
|
||||
for (const pos of tbodyPositions) expect(theadAt, address).toBeGreaterThan(pos);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* 2026-08-29 评审 Minor:brief 原话是「红带显隐用 data-class-of」,但 gate_reason 是
|
||||
* 中文自由文本 —— classToken 只留 [a-z0-9-],中文字符全被剥空,不论空串还是有内容的
|
||||
|
||||
@@ -54,7 +54,7 @@ export const manifest: ActivityManifest = {
|
||||
activityId: ACTIVITY_ID,
|
||||
name: 'ACT-WORK-05 · 差异评估',
|
||||
/* 重写不是改 —— 同版重装是 no-op(静默丢失,踩过) */
|
||||
packageVersion: '2.1.0',
|
||||
packageVersion: '2.1.1',
|
||||
category: '需求分析与方案匹配',
|
||||
sdkVersion: '0.1.0-m5',
|
||||
checksum: 'dev-checksum-part-substitution-assessment',
|
||||
@@ -234,12 +234,27 @@ export const manifest: ActivityManifest = {
|
||||
* `refersTo`/`data-by` 那几处按 `String(...)` 比对的查找会全部失配(`'03' !== '3'`),
|
||||
* 写入当场被 `refersTo` 拒、或下拉/查表显示不出人刚建的类型。
|
||||
* **两处病根都是「integer 类型的自增列」这个此前从未出现过的组合触发的**——本仓
|
||||
* 唯一先例 `contact_code` 一直是 `text`。改成 `text`(此列与下面 5 处同引用列一起改,
|
||||
* 保持同一条身份链路全程一种类型)对存储没有影响(台账是 JSONB,没有物理列类型),
|
||||
* 只是把逻辑类型改得诚实;S-FEEL 那几处只做等值/去重比较,不做数值运算,不受影响
|
||||
* (已逐处核对:distinct 计数 / lookup·take / refersTo 皆按字符串比,`>` 只用在
|
||||
* `distinct_chip_type_id` 这个计数列上,不用在值本身)。查证过程详见
|
||||
* Task 7b 报告(`.superpowers/sdd/2026-08-29-act-work-05-rewrite/task-7b-report.md`)。
|
||||
* 唯一先例 `contact_code` 一直是 `text`。**`resolveRowKey`/`coerceToColumn` 这两处
|
||||
* 平台代码本身仍带着这两个坑没修**(下一个建同样组合主键的人会重新踩上,已记
|
||||
* `docs/debt.md`)——这里只是在活动自己的声明层绕开,不是修复。
|
||||
*
|
||||
* 改成 `text` 为什么是安全的(此列与下面 5 处同引用列一起改,保持同一条身份链路
|
||||
* 全程一种类型):**`ledger.__draft`(任务级草稿层)才是 JSONB**
|
||||
* (`ledger-store.ts` 的 `DRAFT_TABLE`/`DRAFT_DDL`:`key`/`patch`/`baseline` 三列都是
|
||||
* `jsonb`)——但 `master`/`record` 这类**正式表是真实的物理列**(同文件 `PG_TYPE`
|
||||
* 映射,`integer → bigint`,`create table`/`add column`/`alter column ... type ...`
|
||||
* 都拼的是真 PG 类型)。改声明安全,靠的是 `schema-merge.ts` 的**单调加宽 lattice**
|
||||
* (`SCALAR_CHAIN.integer = ['integer', 'number', 'text']`,`widen()`
|
||||
* 判定 `integer → text` 是**合法加宽**、不是类型冲突)配合 `widen-column` 那支生成的
|
||||
* 显式转换(`alter table … alter column … type text using col::text`——整数转文本
|
||||
* 沿 PG 的 `bigint::text` 转型,没有失败路径)。这次重装(`activities:install`)会
|
||||
* 把 `ledger.parts__chip_type.type_id` 等六列从 `bigint` 真的 `ALTER … TYPE text`
|
||||
* 过去一遍。
|
||||
*
|
||||
* S-FEEL 那几处只做等值/去重比较,不做数值运算,不受影响(已逐处核对:distinct
|
||||
* 计数 / lookup·take / refersTo 皆按字符串比,`>` 只用在 `distinct_chip_type_id`
|
||||
* 这个计数列上,不用在值本身)。查证过程详见 Task 7b 报告
|
||||
* (`.superpowers/sdd/2026-08-29-act-work-05-rewrite/task-7b-report.md`)。
|
||||
*/
|
||||
col({ name: 'type_id', type: 'text', label: '类型编号', primaryKey: true, auto: true }),
|
||||
col({ name: 'type_name', type: 'text', label: '类型名称', required: true }),
|
||||
|
||||
@@ -350,14 +350,29 @@ const PART_TABLE = `<div class="jc-tbl-wrap"><table id="tbl1">
|
||||
* 与「新建那一行」两处的输入,宿主必须是两者共同的祖先,外层那份是留给它看的。
|
||||
* `.on` 落点用 `host.closest('table')`——外层就是 `<table>` 自身,`closest` 命中自己,
|
||||
* 落的还是同一张 `<table>`,与从前只在 `<tbody>` 上挂 `data-edit` 时完全一致,CSS 不必改。
|
||||
*
|
||||
* **`<thead>` 排在两个 `<tbody>` 之后 —— 不是随手换的顺序,是评审逮到的 Critical 回归修复**
|
||||
* (2026-08-30):`data-edit` 两处写法(上一段)有个副作用——`ViewContainer.bodyOf()`
|
||||
* 的 `document.querySelector` 从此解析到外层 `<table>`,`addRow()` 接着在这个 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>` 里——按钮点了有反应,结果完全不可用,且不报错。
|
||||
* `parts.type_param` 那张更要紧:它的加行功能在这次改动之前本来是好的(`data-edit`
|
||||
* 只挂在 `<tbody>` 上时,`querySelectorAll` 的搜索范围本就不含 `<thead>`)——外层
|
||||
* `data-edit` 一挂,这是一次真回归。
|
||||
* 挪到两个 `<tbody>` 之后就绕开了:CSS 表格排版规范里 `table-header-group`
|
||||
* (`<thead>` 的默认 display)**视觉上永远排最前,不看它在 DOM 里排第几**(这正是
|
||||
* HTML5 允许 `<tfoot>` 写在 `<thead>` 之后、`<tbody>` 之前的同一条排版原理)——呈现
|
||||
* 一字不变,而 `querySelector`/`:last-of-type` 认的是**文档序**,`<thead>` 排在最后,
|
||||
* `tr:last-of-type` 的搜索就再也碰不到表头行(种子行保证两个 `<tbody>` 不会同时空,
|
||||
* 表头因此永远轮不到)。DOM 层面的证据(真实 `renderView()` 输出 + 真实选择器路径,
|
||||
* 含变异验证:挪回原位置能让新测试真红)见
|
||||
* `apps/api/src/modules/interaction/__tests__/view-render-addrow-thead.test.ts`;
|
||||
* 这份源码本身的结构安全网见 `__tests__/activity.test.ts` 里那条「回归安全网」。
|
||||
*/
|
||||
const CHIP_TYPE_TABLE = `<div class="jc-tbl-wrap"><table data-edit="parts.chip_type">
|
||||
<thead><tr>
|
||||
<th style="width:38px">#</th>
|
||||
<th data-h="类型编号">编号</th>
|
||||
<th class="req" data-h="类型名称">类型名称</th>
|
||||
<th data-h="类型说明">类型说明</th>
|
||||
</tr></thead>
|
||||
<tbody data-rows="mine:parts.chip_type" data-edit="parts.chip_type"><tr>
|
||||
<td class="rn"><span class="jc-rn-num"></span></td>
|
||||
<td>${ro('type_id', 'mono')}</td>
|
||||
@@ -370,6 +385,12 @@ const CHIP_TYPE_TABLE = `<div class="jc-tbl-wrap"><table data-edit="parts.chip_t
|
||||
<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>`;
|
||||
|
||||
/**
|
||||
@@ -394,16 +415,13 @@ const CHIP_TYPE_TABLE = `<div class="jc-tbl-wrap"><table data-edit="parts.chip_t
|
||||
* 这张表列的顺序与语义不同(所属类型编号/参数名/参数说明/约束/参考单位,6 列)——
|
||||
* 位置 5、6 恰好被那两条老规则套反(约束套上「单位」的宽度、参考单位套上「约束」的宽度)。
|
||||
* 不挂 id 就不会继承这几条位置写死的规则,退回普通 `table`/`th`/`td` 基础样式。
|
||||
*
|
||||
* **`<thead>` 排在两个 `<tbody>` 之后,同 `CHIP_TYPE_TABLE` 那条大注释**(评审逮到的 Critical 回归修复,2026-08-30)——
|
||||
* 这张表比 `parts.chip_type` 更要紧:它的「加一行」在这次改动**之前本来是好的**(`data-edit`
|
||||
* 只挂 `<tbody>` 时,`querySelectorAll('tr:last-of-type')` 搜索范围本就不含 `<thead>`),
|
||||
* 外层 `data-edit` 一挂就撞上了同一个坑,是一次真回归。理由与做法一字不差,不重复贴。
|
||||
*/
|
||||
const TYPE_PARAM_TABLE = `<div class="jc-tbl-wrap"><table data-edit="parts.type_param">
|
||||
<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>
|
||||
<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" title="删除此行" data-edit-del="parts.type_param">×</button></td>
|
||||
<td>
|
||||
@@ -423,6 +441,14 @@ const TYPE_PARAM_TABLE = `<div class="jc-tbl-wrap"><table data-edit="parts.type_
|
||||
<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>`;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1458,3 +1458,44 @@ nudge:[{after:'PT2H',action:'nudge'}], retry:{times:2,backoff:'PT30S'}`)慢慢
|
||||
与 `checkWhen` 现有的「S-FEEL 对未绑定的名字求值为假,这一笔将永远被跳过」同一条纪律,只是
|
||||
`{in:X}` 的失败面目更隐蔽(空串会被拼接、被当合法值继续往下算,不像 S-FEEL 谓词那样直接短路)。
|
||||
`auto` 是 ADR-014 新落地的机制,这个洞会在下一个用到跨步共享投影的活动上重复咬人。
|
||||
|
||||
## `auto:true` 又是整数型的主键,平台两处独立机制都会踩(2026-08-30,Task 7b 评审 Critical 逼出来的发现)
|
||||
|
||||
`resolveRowKey`(`apps/api/src/app/routes/desk.ts:111-181`)第③段「续号」只判断
|
||||
declared type 是不是 `integer`/`number`(`isSeq`),**不看这一列是否 `auto:true`** ——
|
||||
它是为 `req_seq` 这类「非自增、按行数续的序号型主键」(I01 的 `req_seq`)设计的,却对**任何**
|
||||
整数型主键一视同仁。若某张表的主键**恰好**既是 `auto:true` 又是 `type:'integer'`,这一段会
|
||||
**抢在** `writeEditedRows` 的 `mintable`/`autoKeys()`(真正为 `auto:true` 设计的续号器,数
|
||||
`taskView` 正式版+草稿叠加、按声明的前缀+两位补零规则铸号)**前面**动手,拿「这一单自己的
|
||||
草稿行数+1」铸一个号——这个数不数**正式表**里已有的行,新建的第一行几乎必然撞上任何已存在的
|
||||
`type_id=1`(种子数据、或早前任务放行落地的行),**静默覆盖**那一行的其余字段。
|
||||
|
||||
`autoKeys()` 补零续出的号(如 `'03'`)还有第二个坑:**引用这一列的其它表**(外键列)若也声明
|
||||
`type:'integer'`,写入时会被 `coerceToColumn`(`apps/api/src/app/wiring/engines.ts:590-598`)
|
||||
按 `Number()` 解析,**削掉前导零**——`'03'` 存成整数 `3`,而被引用的原表仍是字符串 `'03'`,
|
||||
`refersTo` 校验(`engines.ts:1119-1143`)与 `data-by` 嵌套查表(`view-render.ts` 的
|
||||
`byParent`)全按 `String(...)` 比对,两边永远对不上:引用列的写入会被 `refersTo` 拒(「在字典里
|
||||
不存在」),或下拉/查表显示不出刚建的那一行。
|
||||
|
||||
**本仓迄今唯一一个 `auto:true` 又是 `integer` 的主键是 `part-substitution-assessment`(W05)的
|
||||
`parts.chip_type.type_id`**(`auto:true` 的既有先例 `contact_code`——E04/quote-intake 两处——
|
||||
从来都是 `type:'text'`,没人先撞上这个组合)。Task 7b 补「件① 新建芯片类型」时踩上了(种子数据
|
||||
`type_id: 1/2` 会被第一次新建的类型静默覆盖;引用列若也是 `integer` 会导致新建的类型写不进
|
||||
`parts.profile.chip_type_id` 等)。**这次只是把 `type_id` 自身与五处引用列(`type_param.type_id`
|
||||
/ `profile.chip_type_id` / `sub_eval.part.chip_type_id` / `sub_eval.case.chip_type_id` /
|
||||
`substitution.relation.chip_type_id`)在活动自己的声明层统一改成 `text` 绕开——`resolveRowKey`
|
||||
与 `coerceToColumn` 这两处平台代码本身一行没改,坑原样留着**。
|
||||
|
||||
**要治**(两处,各自独立就够,不必都做):
|
||||
- `resolveRowKey` 第③段续号加一条 `if (input.autos?.includes(k)) continue;`(与第②段
|
||||
`barred` 检查同一条精神:`auto:true` 的列有自己专用的续号器,不该被这段通用的
|
||||
「整数型主键续号」抢跑)。
|
||||
- 或者更保守地,给 `writeEditedRows`/`static-audit` 加一条装包期检查:某张表的主键**同时**
|
||||
`primaryKey:true`、`auto:true`、`type:'integer'|'number'` 时报一条 warning,提醒作者
|
||||
「这个组合会撞上 `resolveRowKey` 的续号步骤,改成 `text` 或联系平台修 `resolveRowKey`」——
|
||||
比事后重新发现这条坑更便宜。
|
||||
|
||||
**排查法留一条**:某张 `auto:true` 主键表的**种子/早前数据在没人手动改过的情况下自己变了
|
||||
名字/内容**,且改动发生在**该表第一次被本单新建一行**前后,先怀疑这条坑,查该主键是否
|
||||
`type:'integer'` 且没有其它非 auto 主键列(单主键、无前缀的场景下,`resolveRowKey` 续出的
|
||||
号从 `1` 起,最容易撞上种子数据里最早的几行)。
|
||||
|
||||
Reference in New Issue
Block a user