From 57870329e61020ad84bfd8b1d583f09426729f21 Mon Sep 17 00:00:00 2001 From: xcs Date: Sun, 30 Aug 2026 00:23:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(w05):=20Task=207b=20=E8=AF=84=E5=AE=A1=20Cr?= =?UTF-8?q?itical=20=E2=80=94=E2=80=94=20data-edit=20=E6=8C=82=E5=A4=96?= =?UTF-8?q?=E5=B1=82=20table=20=E5=90=8E=E3=80=8C=E5=8A=A0=E4=B8=80?= =?UTF-8?q?=E8=A1=8C=E3=80=8D=E5=85=8B=E9=9A=86=E5=88=B0=E8=A1=A8=E5=A4=B4?= =?UTF-8?q?,=E4=B8=8D=E6=98=AF=E6=95=B0=E6=8D=AE=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 外层 data-edit 是为了让 collect() 同时收到静态种子行与动态行,副作用是 ViewContainer.bodyOf() 的单个 querySelector 从此解析到 本身,addRow() 接着在这个 host 里找 tr:last-of-type —— CSS :last-of-type 按各自父元素判定, 里孤零零的那个 在它自己的父元素里必然「最后」, 若 排在数据行前面就会被文档序优先命中:克隆出来的是四个 '); + const tbodyPositions = [...tableHtml.matchAll(/ m.index ?? -1); + expect(theadAt, `${address}: 找不到 `).toBeGreaterThanOrEqual(0); + expect(tbodyPositions.length, `${address}: 应有两个 `).toBe(2); + // 必须排在两个 的开标签**之后**(源码序;视觉呈现由 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-],中文字符全被剥空,不论空串还是有内容的 diff --git a/activities/part-substitution-assessment/src/manifest.ts b/activities/part-substitution-assessment/src/manifest.ts index e06cd16..79f6320 100644 --- a/activities/part-substitution-assessment/src/manifest.ts +++ b/activities/part-substitution-assessment/src/manifest.ts @@ -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 }), diff --git a/activities/part-substitution-assessment/src/views/report.ts b/activities/part-substitution-assessment/src/views/report.ts index 9392b64..99f2d21 100644 --- a/activities/part-substitution-assessment/src/views/report.ts +++ b/activities/part-substitution-assessment/src/views/report.ts @@ -350,14 +350,29 @@ const PART_TABLE = `
表头文字、零个 input[data-col],还被 appendChild 到 底下、不在任何 里。parts.type_param 那张 更要紧——它的加行功能在这次改动之前本来是好的(data-edit 只挂 tbody 时,querySelectorAll 的 搜索范围本就不含 thead),外层 data-edit 一挂就成了真回归。 修法(不改 ViewContainer.tsx/view-render.ts):把两张表的 挪到两个 之后。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 `/// ` 只给这一份文件 挂 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) Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet --- .../__tests__/activity.test.ts | 58 ++++- .../src/manifest.ts | 29 ++- .../src/views/report.ts | 54 +++-- .../view-render-addrow-thead.test.ts | 229 ++++++++++++++++++ docs/debt.md | 41 ++++ 5 files changed, 384 insertions(+), 27 deletions(-) create mode 100644 apps/api/src/modules/interaction/__tests__/view-render-addrow-thead.test.ts diff --git a/activities/part-substitution-assessment/__tests__/activity.test.ts b/activities/part-substitution-assessment/__tests__/activity.test.ts index b64510c..03fed29 100644 --- a/activities/part-substitution-assessment/__tests__/activity.test.ts +++ b/activities/part-substitution-assessment/__tests__/activity.test.ts @@ -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` + * 从此解析到外层 `
`,而 `addRow()` 接着在这个 host 里找 `tr:last-of-type`—— + * CSS `:last-of-type` 是按各自父元素判定的,`` 里孤零零的那个 `` 在它 + * 自己的父元素(`thead`)里必然「最后」,若 `` 排在数据行前面,它会被文档序 + * 优先命中:克隆出来的是表头文字,零个 `input[data-col]`,`parts.type_param` + * 那张(改动前本来是好的)因此被做坏——一次真回归(评审用 jsdom 在 renderView() 真实 + * 输出上复现两次坐实)。 + * + * 修法是把 `` 挪到两个 `` 之后(CSS 的 `table-header-group` 排版规则: + * 视觉上永远排最前,不看它在 DOM 里排第几——这条规则本来就是 HTML5 允许 `tfoot` + * 写在 `thead` 之后、`tbody` 之前的原理)——`document.querySelector`/`:last-of-type` + * 认的是文档序不是视觉序,`` 排在两个 `` 后面,`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 它)—— + * 这里补的是**廉价的字符串安全网**,把这份源码与那份镜像测试的假设锁在一起: + * 谁若把 `` 挪回两个 `` 之前(哪怕只改一张表),这条测试立刻标红, + * 提醒去核对 `view-render-addrow-thead.test.ts` 是否也需要同步。 + */ + it('**回归安全网**:两张新表的 都排在两个 之后(不是最前面)—— 与 view-render-addrow-thead.test.ts 的假设锁在一起', () => { + for (const address of ['parts.chip_type', 'parts.type_param']) { + const tableStart = html.indexOf(`
`); + expect(tableStart, address).toBeGreaterThanOrEqual(0); + const tableEnd = html.indexOf('
', tableStart); + const tableHtml = html.slice(tableStart, tableEnd); + const theadAt = tableHtml.indexOf('
* 与「新建那一行」两处的输入,宿主必须是两者共同的祖先,外层那份是留给它看的。 * `.on` 落点用 `host.closest('table')`——外层就是 `
` 自身,`closest` 命中自己, * 落的还是同一张 `
`,与从前只在 `` 上挂 `data-edit` 时完全一致,CSS 不必改。 + * + * **`` 排在两个 `` 之后 —— 不是随手换的顺序,是评审逮到的 Critical 回归修复** + * (2026-08-30):`data-edit` 两处写法(上一段)有个副作用——`ViewContainer.bodyOf()` + * 的 `document.querySelector` 从此解析到外层 `
`,`addRow()` 接着在这个 host 里找 + * `tr:last-of-type`。CSS `:last-of-type` 是**按各自父元素**判定的,`` 里孤零零的 + * 那个 `` 在它自己的父元素(`thead`)里必然是「最后一个」——若 `` 排在数据行 + * 前面,`querySelectorAll('tr:last-of-type')` 会**优先**命中它(文档序在前):点「+加一行」 + * 克隆出来的是四个 `
` 表头文字、零个 `input[data-col]`,还被 `appendChild` 到 + * `` 底下、不在任何 `` 里——按钮点了有反应,结果完全不可用,且不报错。 + * `parts.type_param` 那张更要紧:它的加行功能在这次改动之前本来是好的(`data-edit` + * 只挂在 `` 上时,`querySelectorAll` 的搜索范围本就不含 ``)——外层 + * `data-edit` 一挂,这是一次真回归。 + * 挪到两个 `` 之后就绕开了:CSS 表格排版规范里 `table-header-group` + * (`` 的默认 display)**视觉上永远排最前,不看它在 DOM 里排第几**(这正是 + * HTML5 允许 `` 写在 `` 之后、`` 之前的同一条排版原理)——呈现 + * 一字不变,而 `querySelector`/`:last-of-type` 认的是**文档序**,`` 排在最后, + * `tr:last-of-type` 的搜索就再也碰不到表头行(种子行保证两个 `` 不会同时空, + * 表头因此永远轮不到)。DOM 层面的证据(真实 `renderView()` 输出 + 真实选择器路径, + * 含变异验证:挪回原位置能让新测试真红)见 + * `apps/api/src/modules/interaction/__tests__/view-render-addrow-thead.test.ts`; + * 这份源码本身的结构安全网见 `__tests__/activity.test.ts` 里那条「回归安全网」。 */ const CHIP_TYPE_TABLE = `
- - - - - - @@ -370,6 +385,12 @@ const CHIP_TYPE_TABLE = `
#编号类型名称类型说明
${ro('type_id', 'mono')}
+ + + + + +
#编号类型名称类型说明
`; /** @@ -394,16 +415,13 @@ const CHIP_TYPE_TABLE = `
- - - - - - - - ) + 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(); + // 命中的必须是 的直接子行 —— 不是 那一行 + 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 按位置认(文档序第一个 ) + 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` 只挂在内层 `` 上(不挂 + * 外层 `
#所属类型参数名参数说明约束(硬/软)参考单位
@@ -423,6 +441,14 @@ const TYPE_PARAM_TABLE = `
+ + + + + + + +
#所属类型参数名参数说明约束(硬/软)参考单位
`; /** 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 new file mode 100644 index 0000000..3ec348b --- /dev/null +++ b/apps/api/src/modules/interaction/__tests__/view-render-addrow-thead.test.ts @@ -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="域.对象"` 挂在了外层 `` 上(而不是只挂在内层 `data-rows` 的 + * `` 上)。 + * + * 副作用:`ViewContainer.bodyOf()`(`packages/ui-kit/src/ViewContainer.tsx:164-167`, + * 单个 `document.querySelector`,按文档序返回第一个匹配)从此解析到**外层 `
`**; + * `addRow()`(`:303-323`)接着在这个 host 里找 `tr:last-of-type` —— CSS `:last-of-type` + * 是**相对各自父元素**判定的,`` 里那个孤零零的 `` 在它自己的父元素(`thead`) + * 里必然是「最后一个」,若 `` 在文档序上排在数据行之前,它就会被 + * `querySelectorAll('tr:last-of-type')` 第一个命中 —— 克隆出来的是四个 `` 的默认 display)**始终排在最前面渲染,不看它在 DOM 里 + * 排第几**(这是 `tfoot` 允许写在 `thead` 后面、`tbody` 前面这条 HTML5 规则背后同一条 + * 视觉排版原理)——视觉呈现不变,而 `document.querySelector`/`querySelectorAll` 认的是 + * **文档序**,不是视觉序:`` 排在两个 `` 后面,`tr:last-of-type` 的搜索 + * 就再也碰不到表头行(表头行在文档序上比所有数据行/种子行都晚,只有当两个 `` + * 都彻底没有 `` 时才轮得到它——而静态种子行保证了这种情况不会发生)。 + * + * 这份测试**不能** `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)。 + */ +/// +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(`[data-edit="${address}"]`); + if (host === null) return null; + // 镜像 addRow() 的取「克隆样本」那一步:host 内找 tr:last-of-type + return host.querySelector('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 = `
` 表头文字、 + * **零个** `input[data-col]`,还被 `appendChild` 到 `` 底下、不在任何 `` 里。 + * 按钮点了有反应,结果完全不可用,且不报错。 + * + * 修法(`report.ts`,不改 `ViewContainer.tsx`/`view-render.ts` 这两个共享面):把 `` + * 挪到**两个 `` 之后**、`
` 之前。CSS 表格布局规范里 + * `table-header-group`(`
+ + + + + + + + + + + + + + + + + + +
系统生成
#编号类型名称类型说明
`; + +/** + * 与 `report.ts` 的 `TYPE_PARAM_TABLE` 逐字节对齐的镜像(6 列;`type_id` 是下拉 + * `data-options`,不是裸文本 —— Task 7b 的另一条修改)。同样把 `thead` 挪到两个 `tbody` 之后。 + */ +const TYPE_PARAM_FRAGMENT = `
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
#所属类型参数名参数说明约束(硬/软)参考单位
`; + +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 按位置认(文档序第一个
`),`thead` 排在最前面(改回归修复前的原始顺序)。这条测试**不是**用来 + * 证明当前代码正确——它证明的是「如果 `data-edit` 回到只挂 `
` 且 `thead` 排在最前」 + * 这个错误形状,选择器确实会命中表头(0 个 `input[data-col]`)。留着当变异验证的可读记录: + * 不必真的改 `report.ts` 再改回来,这条测试本身就是「变了会怎样」的固化证据。 + */ +describe('回归记录:data-edit 挂外层 table 且 thead 排最前 —— 这个错误形状会命中表头', () => { + it('(此形状已不出现在 report.ts 里,这里独立复现,证明它确实是错的)', () => { + const brokenFragment = `
+ + + +
#编号类型名称类型说明
系统生成
`; + 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); + }); +}); diff --git a/docs/debt.md b/docs/debt.md index d9b7bfe..263069b 100644 --- a/docs/debt.md +++ b/docs/debt.md @@ -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` 起,最容易撞上种子数据里最早的几行)。