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/apps/api/src/modules/interaction/__tests__/view-render.test.ts
T
xcsandClaude Fable 5 51914292dc feat(w05): 数据工单第四轮九连改 —— 笔↔软盘卡角编辑、只选不建、浅蓝题头、镜像修数字
2026-08-31 用户一次点齐九件,逐条落:
① 两行编辑闸退役 → 卡右上角笔(开当前这一面的编辑)↔ 软盘(保存)+ 叉(放弃);
  按钮就是平台的 data-edit-open/save/cancel 触发器,快照/取消机制原样,按面显隐全在样式表。
② 保存一瞬间下拉变数字 → ui-kit mirrorTwins:下拉镜像**选项文案**不镜 value,并把
  selected 属性拨到人选的那一项(守卫 CSS 与取消回显都认它)。2 测。
③ 参数输入框占位提示 → 渲染器 twinInputs 认 data-ph 落成 placeholder(平台小扩面,1 测);
  值格「规格书上的值 · 查不到留空」,说明格「补充说明 · 选填」。
④ 参数说明空着不再摆「建议」→ 什么都不摆(连兜底破折号一起收)。
⑤ 区序对调:芯片档案在前,比对基准在后。
⑥ **新建芯片类型整条路移除**(只能选已有类型):抽屉与两张编辑表(种子行/Critical-1
  修法/thead 回归网)退役,S1 配方 ②③ 自投影删除(八笔→六笔),gateLedgers 收窄到
  profile+parameter,writes 同步,gate_reason 与指导书改口「只选不建」;样例改名
  「候选缺库·建档定型」;e2e 案例 0 改走选既有 type_1(type_id=90 那套绕坑法随路退役)。
  退役面的七条钉测按仓例 it.skip 留墓志铭(钉的是已移除的路径,不是放松断言)。
⑦ 题头回浅蓝渐变(原目标件那条 120°),深色夜景只留验收两件。
⑧ 对话区执行卡不再摆「参考信息」板块(basis 数据照旧随卡走,详情/归档仍有)。
⑨ 比对基准降噪:字段收成丸(名+硬软徽+单位),说明藏进悬浮气泡指上即看;
  类型说明一行截断、悬浮展开。

测:ui-kit 33(mirror 2 新)+ 渲染器 61(ph 1 新)+ 包内 104(38 steps 重排)+ 2525 全仓
四门绿;2.10.0 装库 SQL 六探针全中(hd-light/软盘/data-ph/零表格/无 chip_type 编辑面/
gateLedgers 两张);:8787 已重启载入 data-ph 渲染器。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 16:41:14 +08:00

559 lines
28 KiB
TypeScript

/** 视图模版填数 —— domain 测试零 mock(AM 07:模版是内容不是代码) */
import { describe, expect, it } from 'vitest';
import { displayValue, escapeHtml, renderView, resolveSlot } from '../internal/domain/view-render.ts';
import { templateIsSafe } from '../internal/domain/control-handover.ts';
describe('取值与展示', () => {
it('点号下行 + 数组下标 —— 与步声明的写法一致', () => {
const scope = { records: [{ pn: 'LM358DR' }], n: 3 };
expect(resolveSlot(scope, 'records.0.pn')).toBe('LM358DR');
expect(resolveSlot(scope, 'n')).toBe(3);
expect(resolveSlot(scope, 'nope.deep')).toBeUndefined();
});
it('空值显示为「—」,免得画面留空洞', () => {
expect(displayValue(undefined)).toBe('—');
expect(displayValue('')).toBe('—');
expect(displayValue(null)).toBe('—');
});
it('数组显示条数,布尔显示是/否,小数收敛', () => {
expect(displayValue([1, 2, 3])).toBe('3');
expect(displayValue(true)).toBe('是');
expect(displayValue(0.123456789)).toBe('0.1235');
});
});
describe('填数只做取值与转义 —— 模版不得成为代码', () => {
it('填入的值被转义,注不进标签', () => {
const html = '<b data-slot="x"></b>';
const out = renderView(html, { x: '<img src=x onerror=alert(1)>' });
// 标签没能成型:尖括号全被转义,留下的是惰性文本
expect(out).not.toContain('<img');
expect(out).toBe('<b>&lt;img src=x onerror=alert(1)&gt;</b>');
});
it('净化门查的是**作者写的模版**,不是填完数据的成品', () => {
// 模版本身必须无脚本、无事件处理器、无外链
expect(templateIsSafe('<b data-slot="x"></b>')).toBe(true);
expect(templateIsSafe('<b onclick="x()"></b>')).toBe(false);
// 成品里出现「onerror=」这串字面文本是转义后的惰性文本,不该再拿模版规则去卡它 ——
// 否则任何正文提到事件处理器的业务数据都会被误伤。安全由「模版过门 + 沙箱容器」两道保证。
expect(renderView('<b data-slot="x"></b>', { x: 'onclick=1' })).toBe('<b>onclick=1</b>');
});
it('未命中的槽位填「—」,不把占位文字漏到成品上', () => {
expect(renderView('<b data-slot="missing">占位</b>', {})).toBe('<b>—</b>');
});
it('填充消费槽位 —— 交出去的是干净 HTML,不带模板属性', () => {
expect(renderView('<b class="k" data-slot="x"></b>', { x: 1 })).toBe('<b class="k">1</b>');
});
});
describe('行模版 —— 逐条清单不写死条数', () => {
const html = '<table data-rows="records"><tr><td data-slot="pn"></td><td data-slot="eau"></td></tr></table>';
it('按数组逐项复制,行内槽位相对该行取值', () => {
const out = renderView(html, {
records: [
{ pn: 'A1', eau: 100 },
{ pn: 'B2', eau: 200 },
],
});
expect(out).toContain('<td>A1</td>');
expect(out).toContain('<td>B2</td>');
expect(out).toContain('<td>200</td>');
});
it('空数组渲染成空容器,不留一行占位', () => {
const out = renderView(html, { records: [] });
expect(out).toBe('<table></table>');
});
it('行先展开再填标量槽 —— 顺序反了行内会被外层作用域填掉', () => {
const out = renderView('<ul data-rows="rs"><li data-slot="v"></li></ul><b data-slot="v"></b>', {
rs: [{ v: '行内' }],
v: '外层',
});
expect(out).toContain('<li>行内</li>');
expect(out).toContain('<b>外层</b>');
});
});
describe('转义', () => {
it('四个字符各归其位', () => {
expect(escapeHtml('<a href="x">&</a>')).toBe('&lt;a href=&quot;x&quot;&gt;&amp;&lt;/a&gt;');
});
});
describe('整键优先 —— 台账地址本身带点号', () => {
const scope = {
'quote_intake.record': [{ pn: 'LM358DR', eau: 12000 }],
quote_intake: { record: [{ pn: '不该取到这个' }] },
a: { b: 'nested' },
};
it('字面整键存在时取它,不拆成两级', () => {
const rows = resolveSlot(scope, 'quote_intake.record');
expect(Array.isArray(rows) && (rows[0] as { pn: string }).pn).toBe('LM358DR');
});
it('没有整键才按点号下行', () => {
expect(resolveSlot(scope, 'a.b')).toBe('nested');
});
it('行模版用地址键取表', () => {
const out = renderView('<tbody data-rows="quote_intake.record"><tr><td data-slot="pn"></td></tr></tbody>', scope);
expect(out).toContain('<td>LM358DR</td>');
});
});
/**
* `data-blank` —— 「这一格客户没给」与「这一格填了个破折号」是两件事。
*
* 报告里的必填 / 选填胶囊、缺必填的整行标底,全靠这一格**真的空**才生效
* (`td:empty::after` / `tr:has(td.req:empty)`)。填了「—」就永远标不出来。
*/
describe('data-blank:空值真的留空', () => {
it('空串 / null / 缺席一律留空,属性随槽位一并消费', () => {
const html = '<td class="req" data-blank data-slot="eau"></td>';
expect(renderView(html, { eau: '' })).toBe('<td class="req"></td>');
expect(renderView(html, { eau: null })).toBe('<td class="req"></td>');
expect(renderView(html, {})).toBe('<td class="req"></td>');
});
it('有值照填 —— 0 与 false 都是值,不算空', () => {
const html = '<td class="req" data-blank data-slot="v"></td>';
expect(renderView(html, { v: 30_000 })).toBe('<td class="req">30000</td>');
expect(renderView(html, { v: 0 })).toBe('<td class="req">0</td>');
expect(renderView(html, { v: false })).toBe('<td class="req">否</td>');
});
it('不带 data-blank 的照旧填「—」—— 老模版行为不动', () => {
expect(renderView('<b data-slot="x"></b>', { x: '' })).toBe('<b>—</b>');
});
it('逐行展开时每行各判各的空', () => {
const out = renderView('<tbody data-rows="rs"><tr><td class="req" data-blank data-slot="eau"></td></tr></tbody>', {
rs: [{ eau: 30_000 }, { eau: '' }],
});
expect(out).toBe('<tbody><tr><td class="req">30000</td></tr><tr><td class="req"></td></tr></tbody>');
});
});
/**
* `data-class-of` —— **按值上色**,模版仍然不许算。
*
* 「A 绿 / B 橙 / C 紫」这类分档表现,从前只能三个值一个色 —— 那等于假装读懂了值。
* 把值投影成 class 仍然只是取值:没有表达式、没有分支,判断留在样式表里。
* 属性位是唯一一处让值进属性的地方,所以消毒必须硬。
*/
describe('data-class-of:按值上色', () => {
it('值消毒成 v-<值> 追加进 class,属性随之消费', () => {
const html = '<td class="pill" data-class-of="type_tag" data-slot="type_tag"></td>';
expect(renderView(html, { type_tag: 'A' })).toBe('<td class="pill v-a">A</td>');
expect(renderView(html, { type_tag: 'C' })).toBe('<td class="pill v-c">C</td>');
});
it('没有 class 属性就新建一个', () => {
expect(renderView('<i data-class-of="k" data-slot="k"></i>', { k: 'B' })).toBe('<i class="v-b">B</i>');
});
it('布尔与数字都投影得出来 —— 是/否两态也要能分色', () => {
expect(renderView('<b data-class-of="f" data-slot="f"></b>', { f: true })).toBe('<b class="v-true">是</b>');
expect(renderView('<b data-class-of="n" data-slot="n"></b>', { n: 0 })).toBe('<b class="v-0">0</b>');
});
it('**值只放行 ASCII 字母数字与横杠**:引号、尖括号、空格全剔掉,注不进属性', () => {
const out = renderView('<b class="x" data-class-of="k" data-slot="k"></b>', { k: '" onclick=alert(1) <b>' });
// class 里只剩白名单字符;文本位照旧转义 —— 两处都注不进去
expect(out).toBe('<b class="x v-onclickalert1b">&quot; onclick=alert(1) &lt;b&gt;</b>');
expect(out).not.toContain('onclick=alert(1)"');
});
it('一个合规字符都没有(中文 / 空值)就不给 class —— 宁可没颜色,不给错钩子', () => {
expect(renderView('<b class="x" data-class-of="k" data-slot="k"></b>', { k: '老客户' })).toBe('<b class="x">老客户</b>');
expect(renderView('<b class="x" data-class-of="k" data-slot="k"></b>', {})).toBe('<b class="x">—</b>');
});
it('逐行展开时每行按自己的值上色', () => {
const out = renderView('<tbody data-rows="rs"><tr><td data-class-of="t" data-slot="t"></td></tr></tbody>', {
rs: [{ t: 'A' }, { t: 'C' }],
});
expect(out).toBe('<tbody><tr><td class="v-a">A</td></tr><tr><td class="v-c">C</td></tr></tbody>');
});
/*
* **上色不以填字为条件** —— 这一组是补的账。
*
* 从前只认「带 data-slot 的元素」,于是分布条那种纯色块
* (`<i data-class-of="type_tag"></i>`,一格一条料、格里不写字)整条静默失色:
* 属性还留在成品上,一个 class 都没长出来。屏上是一排灰格子 ——
* 而灰格子看着像「还没算出来」,不像「没上色」,查了两遍才认出来。
* 上面每一条老用例都同时写了 data-slot,所以谁都没拦住它。
*/
it('**只上色不填字**:没有 data-slot 也照样长出 class', () => {
expect(renderView('<i data-class-of="t"></i>', { t: 'B' })).toBe('<i class="v-b"></i>');
expect(renderView('<i class="seg" data-class-of="t"></i>', { t: 'C' })).toBe('<i class="seg v-c"></i>');
});
it('只上色的格子逐行展开,每格按自己那一行的值', () => {
const out = renderView('<div class="bar" data-rows="rs"><i data-class-of="t"></i></div>', {
rs: [{ t: 'A' }, { t: 'B' }, { t: 'C' }],
});
expect(out).toBe('<div class="bar"><i class="v-a"></i><i class="v-b"></i><i class="v-c"></i></div>');
});
it('值取不到就把属性摘掉了事 —— 不留半成品属性到成品上', () => {
expect(renderView('<i class="seg" data-class-of="nope"></i>', {})).toBe('<i class="seg"></i>');
expect(renderView('<i data-class-of="k"></i>', { k: '老客户' })).toBe('<i></i>');
});
it('壳上的 data-class-of 按外层作用域上色,行内的仍按各自那一行', () => {
const out = renderView('<div data-class-of="hit" data-rows="rs"><i data-class-of="t"></i></div>', {
hit: 'r1',
rs: [{ t: 'A' }, { t: 'C' }],
});
expect(out).toBe('<div class="v-r1"><i class="v-a"></i><i class="v-c"></i></div>');
});
});
/**
* `data-edit` + `data-col` —— **就地改**:可改的格子渲两份,只读那份与输入孪生件。
*
* 为什么两份都渲、而不是渲成输入:同一份件既要在待处理页给人改,
* 又要在工作成果里定稿归档 —— 两处是同一份 HTML。渲两套等于存两份、也就会分叉。
* 显示哪一份由样式表按根节点上的 `editing` 决定,填数这一侧仍然只做取值。
*/
describe('data-edit / data-col:可改格子的输入孪生件', () => {
const ROWS = '<tbody data-rows="rs" data-edit="d.o"><tr><td><span class="jc-vcell mono" data-slot="pn" data-col="pn"></span></td></tr></tbody>';
it('容器标了 data-edit,格子就多一个带值的 input', () => {
const out = renderView(ROWS, { rs: [{ pn: 'AO6604' }] });
expect(out).toContain('<span class="jc-vcell mono">AO6604</span>');
expect(out).toContain('<input class="jc-cell mono" data-col="pn" data-row="0" value="AO6604"/>');
});
it('没标 data-edit 的表一个 input 都不长 —— 归档件就是件,不是表单', () => {
const out = renderView(ROWS.replace(' data-edit="d.o"', ''), { rs: [{ pn: 'AO6604' }] });
expect(out).not.toContain('<input');
});
it('行号钉在每一行上 —— 写回时按它对行', () => {
const out = renderView(ROWS, { rs: [{ pn: 'A' }, { pn: 'B' }, { pn: 'C' }] });
expect(out.match(/data-row="\d"/g)).toEqual(['data-row="0"', 'data-row="1"', 'data-row="2"']);
});
it('空值的 input 真空着 —— 空的输入框显示自己的 placeholder,那正是「这一格还没有」', () => {
const out = renderView(ROWS, { rs: [{ pn: '' }] });
expect(out).toContain('value=""');
});
it('**值进属性也要转义** —— 属性位插值只此一处,引号进去就是逃逸', () => {
const out = renderView(ROWS, { rs: [{ pn: '" onfocus=alert(1) x="' }] });
expect(out).toContain('value="&quot; onfocus=alert(1) x=&quot;"');
expect(out).not.toMatch(/value="" onfocus/);
});
it('排版类跟着走(mono / num),别的类不带过去 —— 同一列两态字体要一致', () => {
const num = ROWS.replace('jc-vcell mono', 'jc-vcell num req');
expect(renderView(num, { rs: [{ pn: '30000' }] })).toContain('<input class="jc-cell num"');
});
});
describe('手写的下拉孪生件:当下的值要标 selected', () => {
const SELECT_ROW =
'<tbody data-rows="rs" data-edit="d.o"><tr><td><select class="jc-cell" data-col="depth">' +
'<option value=""></option><option value="deep">深度</option><option value="standard">标准</option>' +
'<option value="light">轻度</option></select></td></tr></tbody>';
it('当下的值标 selected —— 人一进编辑态就看得见 AI 填了什么', () => {
const out = renderView(SELECT_ROW, { rs: [{ depth: 'standard' }] });
expect(out).toContain('<option value="standard" selected>标准</option>');
});
it('值匹配到具体那个 option,不是随便一个', () => {
const out = renderView(SELECT_ROW, { rs: [{ depth: 'light' }] });
expect(out).toContain('<option value="light" selected>轻度</option>');
expect(out).toContain('<option value="deep">深度</option>');
expect(out).toContain('<option value="standard">标准</option>');
});
it('没有值 —— 照旧留白,不选任何一项(没答=不改的规矩不能被这条改掉)', () => {
const out = renderView(SELECT_ROW, { rs: [{}] });
expect(out).not.toContain('selected');
});
it('值不在选项里 —— 原样留白,不硬凑一个', () => {
const out = renderView(SELECT_ROW, { rs: [{ depth: 'unknown-code' }] });
expect(out).not.toContain('selected');
});
it('没标 data-edit 的表(归档件)一样标好 selected —— 静态归档也该如实呈现当下的值', () => {
const out = renderView(SELECT_ROW.replace(' data-edit="d.o"', ''), { rs: [{ depth: 'deep' }] });
expect(out).toContain('<option value="deep" selected>深度</option>');
});
});
/**
* 手写的多行文本孪生件 —— E04 执行回执的「结果摘要 / 客户反馈」照目标件是 textarea
* (一句话装不下「见到的关键人、谈了什么、达成什么」)。与下拉同款:
* 渲染时把当下的值填进正文,人一进编辑态看得见 AI/上一轮填了什么。
*/
describe('手写的多行文本孪生件:当下的值进正文', () => {
const TEXTAREA_ROW =
'<tbody data-rows="rs" data-edit="d.o"><tr><td>' +
'<textarea class="jc-cell" data-col="result_summary" placeholder="谈了什么"></textarea>' +
'</td></tr></tbody>';
it('当下的值进正文 —— 人一进编辑态就看得见已填的内容', () => {
const out = renderView(TEXTAREA_ROW, { rs: [{ result_summary: '见到了产品 VP,达成样品测试意向' }] });
expect(out).toContain('data-col="result_summary" placeholder="谈了什么">见到了产品 VP,达成样品测试意向</textarea>');
});
it('没有值 —— 正文真空着,placeholder 才显得出来', () => {
const out = renderView(TEXTAREA_ROW, { rs: [{}] });
expect(out).toContain('placeholder="谈了什么"></textarea>');
});
it('值要转义 —— 正文里混进闭合标签就是逃逸', () => {
const out = renderView(TEXTAREA_ROW, { rs: [{ result_summary: '</textarea><script>alert(1)</script>' }] });
expect(out).not.toContain('<script>');
expect(out).toContain('&lt;/textarea&gt;');
});
});
describe('渲染两遍不叠加 —— 交出去的是干净 HTML', () => {
it('第二遍渲染不会再长一个 input(属性已被消费)', () => {
const once = renderView('<tbody data-rows="rs" data-edit="d.o"><tr><td><span data-slot="pn" data-col="pn"></span></td></tr></tbody>', {
rs: [{ pn: 'X' }],
});
expect((once.match(/<input/g) ?? []).length).toBe(1);
expect(renderView(once, {})).toBe(once);
});
it('模版里仍然没有脚本 —— 输入件是纯 HTML,没有事件处理器', () => {
const out = renderView('<tbody data-rows="rs" data-edit="d.o"><tr><td><span data-slot="pn" data-col="pn"></span></td></tr></tbody>', {
rs: [{ pn: 'X' }],
});
expect(out).not.toMatch(/<script|\son[a-z]+=/i);
});
});
describe('data-file-of:这一格点开是一份资料文件', () => {
const TPL =
'<tbody data-rows="mine:content.asset"><tr>' +
'<td><span data-file-of="content_id" data-slot="title"></span></td>' +
'</tr></tbody>';
it('换成认得出行的 data-file="地址/主键值",原属性被消费掉', () => {
const out = renderView(TPL, {
'mine:content.asset': [{ content_id: 'CT-2026-0001', title: '供货能力说明' }],
});
expect(out).toContain('data-file="content.asset/CT-2026-0001"');
expect(out).not.toContain('data-file-of');
expect(out).toContain('供货能力说明');
});
it('**地址去掉 `mine:` 前缀** —— 那是取值的路径,不是台账地址', () => {
const out = renderView(TPL, { 'mine:content.asset': [{ content_id: 'A', title: 'T' }] });
expect(out).toContain('data-file="content.asset/A"');
});
it('那一列还空着:整个属性去掉 —— 没有文件的行不该长得像能点开', () => {
const out = renderView(TPL, { 'mine:content.asset': [{ title: '还没编号' }] });
expect(out).not.toContain('data-file');
expect(out).toContain('还没编号');
});
it('主键值里的引号被转义 —— 一行数据撑不开属性', () => {
const out = renderView(TPL, { 'mine:content.asset': [{ content_id: 'A" onclick="x', title: 'T' }] });
// 引号成了 &quot;,那一串整个还在 data-file 的值里面 —— 没有第二个属性冒出来
expect(out).toContain('data-file="content.asset/A&quot; onclick=&quot;x"');
expect(out).not.toContain('onclick="x"');
});
});
/*
* 嵌套行模版 + `data-by` 父键过滤(2026-08-27,W05 差异评估结论审核「一个候选一张表」逼出来的):
* 外层按候选逐个复制,里层只列该候选名下的明细 —— 从前渲染器只认一层,里层的 data-rows 原样漏到成品里。
*/
describe('嵌套行模版与 data-by —— 一个候选一张表', () => {
// 外层容器用 <article>:行模版是 <section>,里面不会再出现 <article>(行容器的标签名不得在行模版内部再出现)
const html =
'<article data-rows="rel"><section><h2 data-slot="pn"></h2>' +
'<tbody data-rows="items" data-by="rel_id"><tr><td data-slot="name"></td><td data-slot="pn"></td></tr></tbody>' +
'</section></article>';
const scope = {
rel: [
{ rel_id: 'R1', pn: 'XL' },
{ rel_id: 'R2', pn: 'AP' },
],
items: [
{ rel_id: 'R1', name: '输出电压' },
{ rel_id: 'R2', name: '使能逻辑' },
{ rel_id: 'R1', name: '工作温度' },
],
};
it('里层只留与外层同键的行;父行的槽位仍填父值;data-by 属性随之消费', () => {
const out = renderView(html, scope);
const [first = '', second = ''] = out.split('</section>');
expect(out.split('<section>').length - 1).toBe(2); // 一个候选一个 section
expect(first).toContain('<h2>XL</h2>');
expect(first).toContain('<td>输出电压</td>');
expect(first).toContain('<td>工作温度</td>');
expect(first).not.toContain('使能逻辑');
expect(second).toContain('<h2>AP</h2>');
expect(second).toContain('<td>使能逻辑</td>');
expect(second).not.toContain('输出电压');
expect(out).not.toContain('data-by');
expect(out).not.toContain('data-rows');
});
it('里层槽位与父行同名时取里层那一行(先展开里层,再填父行)', () => {
const out = renderView(html, { ...scope, items: [{ rel_id: 'R1', name: 'n', pn: '子' }] });
expect(out).toContain('<h2>XL</h2>');
expect(out).toContain('<td>子</td>');
});
it('data-by="子列=父列":两边列名不同也对得上 —— 当查表用(整表里挑这一颗料的那一行)', () => {
const out = renderView(
'<ul data-rows="rel"><li><b data-slot="pn"></b><span data-rows="profile" data-by="internal_pn=pn"><i data-slot="brand"></i></span></li></ul>',
{ rel: [{ pn: 'XL' }, { pn: 'AP' }], profile: [{ internal_pn: 'AP', brand: 'Diodes' }, { internal_pn: 'XL', brand: 'XLSEMI' }] },
);
expect(out).toContain('<b>XL</b><span><i>XLSEMI</i></span>');
expect(out).toContain('<b>AP</b><span><i>Diodes</i></span>');
});
it('父值缺席一行不留;没有 data-by 的里层照旧全复制', () => {
const none = renderView(html, { ...scope, rel: [{ rel_id: '', pn: 'X' }] });
expect(none).toContain('<h2>X</h2>');
expect(none).not.toContain('<td>');
const all = renderView(html.replace(' data-by="rel_id"', ''), scope);
expect(all.split('使能逻辑').length - 1).toBe(2);
});
/*
* **已知问题,尚未修 —— 2026-08-30(ACT-WORK-05 task-12,缺陷①,浏览器真人走查逮到)**。
* 现场报的是「候选料一行的芯片类型只读态显示着一个值,而那一格实际是空的,编辑态与 SQL
* 双重核实过是空串」。原本怀疑是 `byParent` 对空父键的处理退化成显示第一行 —— 查下来这
* 个猜测是错的:上面「父值缺席一行不留」那条已经钉死了"这一行自己把父键显式写成空字符串"
* 这个场景(`rel_id: ''`),`byParent` 的 `parent === ''` 守卫恒定正确,不是这次的病根。
*
* 真正能复现的是**另一种缺席**:这一行压根**没有**这个字段(不是空字符串,是键都不存在)——
* 常见于必填但还没填的草稿行(如 ACT-WORK-05 的 input-supplement 件:候选料还没定型号时,
* `sub_eval.part` 那一行不会带 `chip_type_id` 这个键)。`renderRows` 递归处理嵌套
* `data-rows` 时用 `{ ...scope, ...one }` 合并外层与本行 —— `one` 缺席的键会**悄悄落回
* 外层 scope 里同名的字段**。而外层 scope 常常真的有一个同名字段:`engines.ts` 的
* `taskData.view()` 会把「各对象最新一行的字段名」铺到顶层(`task.data`),
* `part-substitution-assessment` 的 `case-model.ts` 就因为撞过这同一个坑,自己的 `auto`
* 配方特地避免用 `{in:'chip_type_id'}`(该文件 134 行注释:「这个名字在三张表上都有,
* 顶层同名以后写的赢」)—— 但那条护栏只保护了 case-model 自己的 S-FEEL 求值,没有覆盖到
* 这里的 `data-by` 嵌套查表,于是同一类撞名在 `view-render.ts` 这一侧原样重演。
*
* 用 `it.fails`:这条断言此刻确实会失败(已用最小复现核对过,不是这条测试自己写错)——
* 先把复现钉进套件,不在这一轮改 `view-render.ts`(共享面,E04/I01 也用 `data-by`,
* 改动需要先报给设计者裁定修法);等修完翻正、去掉 `.fails`,它就是永久回归测试。
*/
it.fails('父键在本行里压根缺席(不是空字符串)时,不该被外层作用域里恰好同名的字段顶替', () => {
const withNestedLookup =
'<tbody data-rows="parts"><tr><td data-slot="pn"></td>' +
'<span data-rows="dict" data-by="id=type_id"><i data-slot="name"></i></span>' +
'</tr></tbody>';
const out = renderView(withNestedLookup, {
type_id: '5', // 页面顶层恰好有个同名字段(如 sub_eval.case.chip_type_id 铺到顶层的产物)
parts: [{ pn: 'XL2596S-5.0' /* 这一行没有 type_id 这个键 —— 该型号还没定型 */ }],
dict: [{ id: '5', name: 'DC-DC 降压稳压器' }],
});
expect(out).not.toContain('DC-DC 降压稳压器');
});
});
describe('data-options:下拉的选项从台账现读', () => {
const scope = {
'parts.chip_type': [
{ type_id: 1, type_name: 'DC-DC 降压稳压器' },
{ type_id: 2, type_name: 'LDO 线性稳压器' },
],
chip_type_id: 2,
};
it('把选项铺进去,并留一个空选项(「还没选」要说得出来)', () => {
const html = '<select data-col="chip_type_id" data-slot="chip_type_id" data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select>';
const out = renderView(html, scope);
expect(out).toContain('<option value=""></option>');
expect(out).toContain('<option value="1">DC-DC 降压稳压器</option>');
expect(out).toContain('<option value="2" selected>LDO 线性稳压器</option>');
});
it('选项文字要转义 —— 台账里的值是人填的', () => {
const out = renderView(
'<select data-col="c" data-slot="c" data-options="t.t" data-option-value="v" data-option-label="l"></select>',
{ 't.t': [{ v: 'x', l: '<b>坏</b>' }], c: '' },
);
expect(out).toContain('&lt;b&gt;坏&lt;/b&gt;');
expect(out).not.toContain('<b>坏</b>');
});
it('地址取不到 → 只留那个空选项,不塞假数据(界面不撒谎)', () => {
const out = renderView('<select data-col="c" data-options="nope.nope" data-option-value="v" data-option-label="l"></select>', {});
expect(out).toContain('<option value=""></option>');
expect(out).not.toContain('undefined');
});
it('行内的下拉:每一行都铺同一份选项,选中的按各行自己的值', () => {
const html =
'<tbody data-rows="mine:a.b" data-edit="a.b">' +
'<tr><select data-col="chip_type_id" data-slot="chip_type_id" data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select></tr>' +
'</tbody>';
const out = renderView(html, { ...scope, 'mine:a.b': [{ chip_type_id: 1 }, { chip_type_id: 2 }] });
expect(out.match(/<option value="1">DC-DC 降压稳压器<\/option>/g)).toHaveLength(1);
expect(out.match(/<option value="1" selected>DC-DC 降压稳压器<\/option>/g)).toHaveLength(1);
});
/*
* 评审逮到的渗透:「块里有没有 selected 这个词」防不住重复处理 ——
* 一行被正确处理过、且合法地落在「没有选中项」上(值不在选项里 / 这一格还没填),
* 跟「压根没被处理过」在字面上是同一个样子:都没有 selected 这个词。
* `renderView` 收尾那一遍拿的是外层作用域,这里的外层 `chip_type_id: 2` 恰好命中
* 选项列表里的 LDO —— 若防重失灵,它会被错误地渗进这一行,而这一行自己的数据
* 说的明明是「没有选中」。
*/
const rowHtml =
'<tbody data-rows="mine:a.b" data-edit="a.b">' +
'<tr><select data-col="chip_type_id" data-slot="chip_type_id" data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select></tr>' +
'</tbody>';
it('行内下拉:该行的值不在选项里 —— 不许被外层同名的值渗进来标 selected', () => {
const out = renderView(rowHtml, { ...scope, 'mine:a.b': [{ chip_type_id: 99 }] });
expect(out).not.toContain('selected');
});
it('行内下拉:新加的空行(这一列还没填)—— 同样不许被外层同名的值渗进来', () => {
const out = renderView(rowHtml, { ...scope, 'mine:a.b': [{}] });
expect(out).not.toContain('selected');
});
it('行外的独立下拉仍按顶层作用域正确标好 selected(防重不能把这条也防没了)', () => {
const html = '<select data-col="chip_type_id" data-slot="chip_type_id" data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select>';
const out = renderView(html, scope);
expect(out).toContain('<option value="2" selected>LDO 线性稳压器</option>');
});
});
describe('孪生件占位提示:data-ph 落成 input 的 placeholder(2026-08-31,W05 参数瓷贴)', () => {
it('带 data-ph 的可改格,孪生 input 有 placeholder;不带的没有', () => {
const html =
'<div data-rows="mine:parts.parameter" data-edit="parts.parameter"><p>' +
'<span class="jc-vcell" data-slot="param_value" data-col="param_value" data-ph="规格书上的值,查不到留空" data-blank></span>' +
'<span class="jc-vcell" data-slot="param_note" data-col="param_note" data-blank></span>' +
'</p></div>';
const out = renderView(html, { 'mine:parts.parameter': [{ param_value: '', param_note: '' }] });
expect(out).toContain('placeholder="规格书上的值,查不到留空"');
const notePart = out.slice(out.indexOf('data-col="param_note"'));
expect(notePart.slice(0, notePart.indexOf('/>'))).not.toContain('placeholder=');
});
});