/** * **手写控件嵌套多一层,`collect()` 按行归组不能错行**(2026-08-21 修死)。 * * 自动孪生的格子是 ``,往上两层正好是 ``; * 手写的三选一(A2 替代等级、A4 决策卡这类)套了一层分组容器 * (`
`, * input 到 `` 隔了 4 层)——`cell.parentElement?.parentElement` 这个定深两层的老算法 * 只能爬到那层 `
`,爬不到真正的行。E04 的执行回执表就是这样 * 栽的:一次改 3 行的完成状态,保存却只留下 1 行(其余两行的单选组各自拆成了 * 不带主键的伪行,服务端按"随便找到的第一行"兜底,写来写去只剩一行生效)。 * * 修法是 `rowOf`:从格子往上走到 host(`data-edit` 容器)的**直接子元素**为止, * 不管中间嵌了几层都能对上真正的那一行。这里直接用真实 DOM(jsdom,不是手搭的假对象) * 复刻这个嵌套形状,断言 `collect()` 认出的是 3 行而不是被拆散的碎片。 * * 完成状态这一列本身已经从三选一 pill 改成了原生 `
`; doc.body.innerHTML = ` ${row('CU-0142-01', 0)}${row('CU-0087-02', 1)}${row('CU-0087-01', 2)}
`; return doc; } describe('collect():三选一嵌了一层分组容器,三行各自的完成状态不许串行/丢主键', () => { it('三行都点了不同的选项,一次 collect 认出三行,主键各自带对(老算法在这里会把三行拆成更多、且各自缺主键)', () => { const doc = radioTable(); const rows = Array.from(doc.querySelectorAll('tr')); (rows[0]!.querySelector('input[value="done"]') as HTMLInputElement).checked = true; (rows[1]!.querySelector('input[value="partial"]') as HTMLInputElement).checked = true; (rows[2]!.querySelector('input[value="skipped"]') as HTMLInputElement).checked = true; const got = collect(doc, 'crm_coverage.receipt'); expect(got).toHaveLength(3); const byCode = new Map(got.map((r) => [r['contact_code'], r])); expect(byCode.get('CU-0142-01')?.['completion_status']).toBe('done'); expect(byCode.get('CU-0087-02')?.['completion_status']).toBe('partial'); expect(byCode.get('CU-0087-01')?.['completion_status']).toBe('skipped'); }); it('只点了 1 行的单选,另外两行没点(没答)—— 保存只交出那一行的新值,其余两行原值不动', () => { const doc = radioTable(); const rows = Array.from(doc.querySelectorAll('tr')); (rows[1]!.querySelector('input[value="done"]') as HTMLInputElement).checked = true; const got = collect(doc, 'crm_coverage.receipt'); const byCode = new Map(got.map((r) => [r['contact_code'], r])); expect(byCode.get('CU-0087-02')?.['completion_status']).toBe('done'); expect(byCode.get('CU-0142-01')).not.toHaveProperty('completion_status'); expect(byCode.get('CU-0087-01')).not.toHaveProperty('completion_status'); }); }); /** 照 report.ts 现在的 RECEIPT_TABLE:完成状态是原生 select,直接是 的子节点 */ function selectTable(): Document { const doc = document.implementation.createHTMLDocument(''); const row = (code: string): string => ` `; doc.body.innerHTML = ` ${row('CU-0142-01')}${row('CU-0087-02')}${row('CU-0087-01')}
`; return doc; } describe('collect() 与 unanswered():下拉换掉三选一之后,同样的多行独立编辑要成立', () => { it('三行各选一个完成状态,三行都收得到、互不串行', () => { const doc = selectTable(); const selects = Array.from(doc.querySelectorAll('select[data-col="completion_status"]')); selects[0]!.value = 'done'; selects[1]!.value = 'partial'; selects[2]!.value = 'skipped'; const got = collect(doc, 'crm_coverage.receipt'); const byCode = new Map(got.map((r) => [r['contact_code'], r])); expect(byCode.get('CU-0142-01')?.['completion_status']).toBe('done'); expect(byCode.get('CU-0087-02')?.['completion_status']).toBe('partial'); expect(byCode.get('CU-0087-01')?.['completion_status']).toBe('skipped'); }); it('下拉停在空占位 → unanswered() 为真,collect() 跳过这一列(没答=不改,不是清空)', () => { const doc = selectTable(); const select = doc.querySelector('select[data-col="completion_status"]')!; expect(unanswered(select)).toBe(true); const got = collect(doc, 'crm_coverage.receipt'); expect(got.find((r) => r['contact_code'] === 'CU-0142-01')).not.toHaveProperty('completion_status'); }); it('下拉选了具体值 → unanswered() 为假', () => { const doc = selectTable(); const select = doc.querySelector('select[data-col="completion_status"]')!; select.value = 'done'; expect(unanswered(select)).toBe(false); }); it('普通文本输入永远算已答(没有"没答"这一态)', () => { const doc = selectTable(); const input = doc.querySelector('input[data-col="contact_code"]')!; expect(unanswered(input)).toBe(false); }); }); /** 照 E04 目标件的执行回执:结果摘要 / 客户反馈是 textarea(一句话装不下) */ function textareaTable(): Document { const doc = document.implementation.createHTMLDocument(''); const row = (code: string): string => ` `; doc.body.innerHTML = ` ${row('CU-0142-01')}${row('CU-0087-02')}
`; return doc; } describe('collect():textarea 与文本输入同源同待 —— E04 回执的结果摘要照目标件是多行文本', () => { it('人在 textarea 里写的话收得到,主键对得上行', () => { const doc = textareaTable(); const areas = Array.from(doc.querySelectorAll('textarea[data-col="result_summary"]')); areas[0]!.value = '见到了产品 VP,达成样品测试意向'; areas[1]!.value = '技术交流完成,对方提了认证问题'; const got = collect(doc, 'crm_coverage.receipt'); const byCode = new Map(got.map((r) => [r['contact_code'], r])); expect(byCode.get('CU-0142-01')?.['result_summary']).toBe('见到了产品 VP,达成样品测试意向'); expect(byCode.get('CU-0087-02')?.['result_summary']).toBe('技术交流完成,对方提了认证问题'); }); it('textarea 与文本输入同款:永远算已答(空着收空串,语义同 input)', () => { const doc = textareaTable(); const area = doc.querySelector('textarea[data-col="result_summary"]')!; expect(unanswered(area)).toBe(false); }); }); /** * **件上的资料名点开哪一份** —— `data-file` 的解析(2026-08-23)。 * 认不出就不认:点不动比发一个取不回来的请求好(界面不撒谎)。 */ describe('fileRefOf —— data-file 解析', () => { it('域.对象/主键值 → 地址与主键各归各位', () => { expect(fileRefOf('content.asset/CT-2026-0001')).toEqual({ address: 'content.asset', key: 'CT-2026-0001' }); }); it('主键值里带斜杠照样归主键 —— 只认第一个斜杠', () => { expect(fileRefOf('content.asset/round=R/1')).toEqual({ address: 'content.asset', key: 'round=R/1' }); }); it('多列主键写 列=值,值 —— 原样交给服务端认', () => { expect(fileRefOf('c.a/round_ref=BR-1,req_seq=2')).toEqual({ address: 'c.a', key: 'round_ref=BR-1,req_seq=2' }); }); it('缺一半 / 形状不对 / 空的:一律不认', () => { expect(fileRefOf(null)).toBeUndefined(); expect(fileRefOf('')).toBeUndefined(); expect(fileRefOf('content.asset')).toBeUndefined(); expect(fileRefOf('content.asset/')).toBeUndefined(); expect(fileRefOf('/CT-1')).toBeUndefined(); expect(fileRefOf('不是地址/CT-1')).toBeUndefined(); }); });