/** 视图模版填数 —— 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 = ''; const out = renderView(html, { x: '' }); // 标签没能成型:尖括号全被转义,留下的是惰性文本 expect(out).not.toContain('<img src=x onerror=alert(1)>'); }); it('净化门查的是**作者写的模版**,不是填完数据的成品', () => { // 模版本身必须无脚本、无事件处理器、无外链 expect(templateIsSafe('')).toBe(true); expect(templateIsSafe('')).toBe(false); // 成品里出现「onerror=」这串字面文本是转义后的惰性文本,不该再拿模版规则去卡它 —— // 否则任何正文提到事件处理器的业务数据都会被误伤。安全由「模版过门 + 沙箱容器」两道保证。 expect(renderView('', { x: 'onclick=1' })).toBe('onclick=1'); }); it('未命中的槽位填「—」,不把占位文字漏到成品上', () => { expect(renderView('占位', {})).toBe('—'); }); it('填充消费槽位 —— 交出去的是干净 HTML,不带模板属性', () => { expect(renderView('', { x: 1 })).toBe('1'); }); }); describe('行模版 —— 逐条清单不写死条数', () => { const html = '
'; it('按数组逐项复制,行内槽位相对该行取值', () => { const out = renderView(html, { records: [ { pn: 'A1', eau: 100 }, { pn: 'B2', eau: 200 }, ], }); expect(out).toContain('A1'); expect(out).toContain('B2'); expect(out).toContain('200'); }); it('空数组渲染成空容器,不留一行占位', () => { const out = renderView(html, { records: [] }); expect(out).toBe('
'); }); it('行先展开再填标量槽 —— 顺序反了行内会被外层作用域填掉', () => { const out = renderView('', { rs: [{ v: '行内' }], v: '外层', }); expect(out).toContain('
  • 行内
  • '); expect(out).toContain('外层'); }); }); describe('转义', () => { it('四个字符各归其位', () => { expect(escapeHtml('&')).toBe('<a href="x">&</a>'); }); }); 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('', scope); expect(out).toContain('LM358DR'); }); }); /** * `data-blank` —— 「这一格客户没给」与「这一格填了个破折号」是两件事。 * * 报告里的必填 / 选填胶囊、缺必填的整行标底,全靠这一格**真的空**才生效 * (`td:empty::after` / `tr:has(td.req:empty)`)。填了「—」就永远标不出来。 */ describe('data-blank:空值真的留空', () => { it('空串 / null / 缺席一律留空,属性随槽位一并消费', () => { const html = ''; expect(renderView(html, { eau: '' })).toBe(''); expect(renderView(html, { eau: null })).toBe(''); expect(renderView(html, {})).toBe(''); }); it('有值照填 —— 0 与 false 都是值,不算空', () => { const html = ''; expect(renderView(html, { v: 30_000 })).toBe('30000'); expect(renderView(html, { v: 0 })).toBe('0'); expect(renderView(html, { v: false })).toBe('否'); }); it('不带 data-blank 的照旧填「—」—— 老模版行为不动', () => { expect(renderView('', { x: '' })).toBe('—'); }); it('逐行展开时每行各判各的空', () => { const out = renderView('', { rs: [{ eau: 30_000 }, { eau: '' }], }); expect(out).toBe('30000'); }); }); /** * `data-class-of` —— **按值上色**,模版仍然不许算。 * * 「A 绿 / B 橙 / C 紫」这类分档表现,从前只能三个值一个色 —— 那等于假装读懂了值。 * 把值投影成 class 仍然只是取值:没有表达式、没有分支,判断留在样式表里。 * 属性位是唯一一处让值进属性的地方,所以消毒必须硬。 */ describe('data-class-of:按值上色', () => { it('值消毒成 v-<值> 追加进 class,属性随之消费', () => { const html = ''; expect(renderView(html, { type_tag: 'A' })).toBe('A'); expect(renderView(html, { type_tag: 'C' })).toBe('C'); }); it('没有 class 属性就新建一个', () => { expect(renderView('', { k: 'B' })).toBe('B'); }); it('布尔与数字都投影得出来 —— 是/否两态也要能分色', () => { expect(renderView('', { f: true })).toBe('是'); expect(renderView('', { n: 0 })).toBe('0'); }); it('**值只放行 ASCII 字母数字与横杠**:引号、尖括号、空格全剔掉,注不进属性', () => { const out = renderView('', { k: '" onclick=alert(1) ' }); // class 里只剩白名单字符;文本位照旧转义 —— 两处都注不进去 expect(out).toBe('" onclick=alert(1) <b>'); expect(out).not.toContain('onclick=alert(1)"'); }); it('一个合规字符都没有(中文 / 空值)就不给 class —— 宁可没颜色,不给错钩子', () => { expect(renderView('', { k: '老客户' })).toBe('老客户'); expect(renderView('', {})).toBe('—'); }); it('逐行展开时每行按自己的值上色', () => { const out = renderView('', { rs: [{ t: 'A' }, { t: 'C' }], }); expect(out).toBe('AC'); }); /* * **上色不以填字为条件** —— 这一组是补的账。 * * 从前只认「带 data-slot 的元素」,于是分布条那种纯色块 * (``,一格一条料、格里不写字)整条静默失色: * 属性还留在成品上,一个 class 都没长出来。屏上是一排灰格子 —— * 而灰格子看着像「还没算出来」,不像「没上色」,查了两遍才认出来。 * 上面每一条老用例都同时写了 data-slot,所以谁都没拦住它。 */ it('**只上色不填字**:没有 data-slot 也照样长出 class', () => { expect(renderView('', { t: 'B' })).toBe(''); expect(renderView('', { t: 'C' })).toBe(''); }); it('只上色的格子逐行展开,每格按自己那一行的值', () => { const out = renderView('
    ', { rs: [{ t: 'A' }, { t: 'B' }, { t: 'C' }], }); expect(out).toBe('
    '); }); it('值取不到就把属性摘掉了事 —— 不留半成品属性到成品上', () => { expect(renderView('', {})).toBe(''); expect(renderView('', { k: '老客户' })).toBe(''); }); it('壳上的 data-class-of 按外层作用域上色,行内的仍按各自那一行', () => { const out = renderView('
    ', { hit: 'r1', rs: [{ t: 'A' }, { t: 'C' }], }); expect(out).toBe('
    '); }); }); /** * `data-edit` + `data-col` —— **就地改**:可改的格子渲两份,只读那份与输入孪生件。 * * 为什么两份都渲、而不是渲成输入:同一份件既要在待处理页给人改, * 又要在工作成果里定稿归档 —— 两处是同一份 HTML。渲两套等于存两份、也就会分叉。 * 显示哪一份由样式表按根节点上的 `editing` 决定,填数这一侧仍然只做取值。 */ describe('data-edit / data-col:可改格子的输入孪生件', () => { const ROWS = ''; it('容器标了 data-edit,格子就多一个带值的 input', () => { const out = renderView(ROWS, { rs: [{ pn: 'AO6604' }] }); expect(out).toContain('AO6604'); expect(out).toContain(''); }); it('没标 data-edit 的表一个 input 都不长 —— 归档件就是件,不是表单', () => { const out = renderView(ROWS.replace(' data-edit="d.o"', ''), { rs: [{ pn: 'AO6604' }] }); expect(out).not.toContain(' { 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="" onfocus=alert(1) x=""'); 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(' { const SELECT_ROW = ''; it('当下的值标 selected —— 人一进编辑态就看得见 AI 填了什么', () => { const out = renderView(SELECT_ROW, { rs: [{ depth: 'standard' }] }); expect(out).toContain(''); }); it('值匹配到具体那个 option,不是随便一个', () => { const out = renderView(SELECT_ROW, { rs: [{ depth: 'light' }] }); expect(out).toContain(''); expect(out).toContain(''); expect(out).toContain(''); }); 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(''); }); }); /** * 手写的多行文本孪生件 —— E04 执行回执的「结果摘要 / 客户反馈」照目标件是 textarea * (一句话装不下「见到的关键人、谈了什么、达成什么」)。与下拉同款: * 渲染时把当下的值填进正文,人一进编辑态看得见 AI/上一轮填了什么。 */ describe('手写的多行文本孪生件:当下的值进正文', () => { const TEXTAREA_ROW = '' + '' + ''; it('当下的值进正文 —— 人一进编辑态就看得见已填的内容', () => { const out = renderView(TEXTAREA_ROW, { rs: [{ result_summary: '见到了产品 VP,达成样品测试意向' }] }); expect(out).toContain('data-col="result_summary" placeholder="谈了什么">见到了产品 VP,达成样品测试意向'); }); it('没有值 —— 正文真空着,placeholder 才显得出来', () => { const out = renderView(TEXTAREA_ROW, { rs: [{}] }); expect(out).toContain('placeholder="谈了什么">'); }); it('值要转义 —— 正文里混进闭合标签就是逃逸', () => { const out = renderView(TEXTAREA_ROW, { rs: [{ result_summary: '' }] }); expect(out).not.toContain('