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/packages/ui-kit/__tests__/view-container-collect.test.ts
xcsandClaude Fable 5 ffa1ef1076 件上按 ×:刚加的行直接拿掉,原有的行挂记号 —— 且记号要看得见(W05 1.4.2)
用户:「编辑点击再加一行后,点击旁边的叉没有反应」。两层病:
- 件:E04 那套 jc-row-gone / jc-row-new 的样式挂在它自己的外壳类下,W05 按目标件重做后
  表是 table.on,一条都没跟过来 —— × 按了类是切了、屏上没动静。补四条。
- ui-kit:刚「加一行」加出来的行按 × 也只是挂记号 —— 它从没存在过,挂记号再保存送出去的是
  一条「撤掉一行草稿里没有的行」,服务端只能回认不出。抽成 dropRow:jc-row-new 的直接移除,
  原有的行挂记号、再按摘掉;jsdom 钉两条。
浏览器在用户在跑那一单上实证:加行 1→2,× 后 1;原有行 × 一次挂记号、两次摘掉;取消回 1。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh
2026-08-27 13:42:35 +08:00

225 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* **手写控件嵌套多一层,`collect()` 按行归组不能错行**(2026-08-21 修死)。
*
* 自动孪生的格子是 `<td><input data-col></td>`,往上两层正好是 `<tr>`;
* 手写的三选一(A2 替代等级、A4 决策卡这类)套了一层分组容器
* (`<td><div class="jc-radiogrp"><label class="jc-rop"><input type=radio></label></div></td>`,
* input 到 `<tr>` 隔了 4 层)——`cell.parentElement?.parentElement` 这个定深两层的老算法
* 只能爬到那层 `<div class="jc-radiogrp">`,爬不到真正的行。E04 的执行回执表就是这样
* 栽的:一次改 3 行的完成状态,保存却只留下 1 行(其余两行的单选组各自拆成了
* 不带主键的伪行,服务端按"随便找到的第一行"兜底,写来写去只剩一行生效)。
*
* 修法是 `rowOf`:从格子往上走到 host(`data-edit` 容器)的**直接子元素**为止,
* 不管中间嵌了几层都能对上真正的那一行。这里直接用真实 DOM(jsdom,不是手搭的假对象)
* 复刻这个嵌套形状,断言 `collect()` 认出的是 3 行而不是被拆散的碎片。
*
* 完成状态这一列本身已经从三选一 pill 改成了原生 `<select>`(2026-08-21,见
* `report.ts`),不再嵌那层 `.jc-radiogrp` div——但 `rowOf` 的修法是通用的,
* 不管嵌几层都对;这里的 `.jc-radiogrp` 用例代表仍在用手写三选一的其余活动
* (A2/A4 那几张卡),证明这条路对它们同样成立。
*/
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { collect, dropRow, fileRefOf, unanswered } from '../src/ViewContainer.tsx';
/** 三行,每行一个手写三选一(照 A2/A4 现存的 `.jc-radiogrp` 结构,input 距 <tr> 四层) */
function radioTable(): Document {
const doc = document.implementation.createHTMLDocument('');
// name 按行缀 __rN(照 view-render.ts 的 uniqueNames())—— 不缀的话 jsdom 与真浏览器
// 一样,把同名 radio 当一组处理,勾选第 2 行会顺手把第 1 行刚勾的那个悄悄取消掉
const row = (code: string, i: number): string => `
<tr>
<td><span class="jc-vcell mono" data-slot="contact_code"></span><input class="jc-cell mono" data-col="contact_code" value="${code}"/></td>
<td class="req">
<div class="jc-radiogrp">
<label class="jc-rop"><input type="radio" name="cs__r${i}" class="jc-cell" data-col="completion_status" value="done"><span>完成</span></label>
<label class="jc-rop"><input type="radio" name="cs__r${i}" class="jc-cell" data-col="completion_status" value="partial"><span>部分完成</span></label>
<label class="jc-rop"><input type="radio" name="cs__r${i}" class="jc-cell" data-col="completion_status" value="skipped"><span>未执行</span></label>
</div>
</td>
</tr>`;
doc.body.innerHTML = `<table><tbody data-edit="crm_coverage.receipt">
${row('CU-0142-01', 0)}${row('CU-0087-02', 1)}${row('CU-0087-01', 2)}
</tbody></table>`;
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,直接是 <td> 的子节点 */
function selectTable(): Document {
const doc = document.implementation.createHTMLDocument('');
const row = (code: string): string => `
<tr>
<td><span class="jc-vcell mono" data-slot="contact_code"></span><input class="jc-cell mono" data-col="contact_code" value="${code}"/></td>
<td class="req">
<select class="jc-cell" data-col="completion_status" required>
<option value=""></option>
<option value="done">完成</option>
<option value="partial">部分完成</option>
<option value="skipped">未执行</option>
</select>
<span class="jc-tag"></span>
</td>
</tr>`;
doc.body.innerHTML = `<table><tbody data-edit="crm_coverage.receipt">
${row('CU-0142-01')}${row('CU-0087-02')}${row('CU-0087-01')}
</tbody></table>`;
return doc;
}
describe('collect() 与 unanswered():下拉换掉三选一之后,同样的多行独立编辑要成立', () => {
it('三行各选一个完成状态,三行都收得到、互不串行', () => {
const doc = selectTable();
const selects = Array.from(doc.querySelectorAll<HTMLSelectElement>('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<HTMLSelectElement>('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<HTMLSelectElement>('select[data-col="completion_status"]')!;
select.value = 'done';
expect(unanswered(select)).toBe(false);
});
it('普通文本输入永远算已答(没有"没答"这一态)', () => {
const doc = selectTable();
const input = doc.querySelector<HTMLInputElement>('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 => `
<tr>
<td><span class="jc-vcell mono" data-slot="contact_code"></span><input class="jc-cell mono" data-col="contact_code" value="${code}"/></td>
<td class="req"><textarea class="jc-cell" data-col="result_summary" placeholder="谈了什么"></textarea></td>
</tr>`;
doc.body.innerHTML = `<table><tbody data-edit="crm_coverage.receipt">
${row('CU-0142-01')}${row('CU-0087-02')}
</tbody></table>`;
return doc;
}
describe('collect():textarea 与文本输入同源同待 —— E04 回执的结果摘要照目标件是多行文本', () => {
it('人在 textarea 里写的话收得到,主键对得上行', () => {
const doc = textareaTable();
const areas = Array.from(doc.querySelectorAll<HTMLTextAreaElement>('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<HTMLTextAreaElement>('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();
});
});
/**
* 按 × 的两种下场(2026-08-27 用户在 W05 上:「编辑点击再加一行后,点击旁边的叉没有反应」)。
* 类是切了、件里没那条样式是件的事;这里钉的是行为:刚加的行按 × 直接没了,原有的行挂记号、再按摘掉。
*/
describe('dropRow:刚加的行直接拿掉,原有的行挂记号', () => {
const table = () => {
const doc = document.implementation.createHTMLDocument('');
doc.body.innerHTML = '<table><tbody data-edit="a.b"><tr id="old"><td>x</td></tr><tr id="fresh" class="jc-row-new"><td></td></tr></tbody></table>';
return doc;
};
it('jc-row-new 的行 → 从表里移除(它从没存在过,挂记号再保存只会送一条认不出的撤行)', () => {
const doc = table();
const fresh = doc.getElementById('fresh')!;
expect(dropRow(fresh)).toBe('removed');
expect(doc.getElementById('fresh')).toBeNull();
expect(doc.querySelectorAll('tbody tr')).toHaveLength(1);
});
it('原有的行 → 第一次挂 jc-row-gone,第二次摘掉;行本身一直在(人看得见、有得后悔)', () => {
const doc = table();
const old = doc.getElementById('old')!;
expect(dropRow(old)).toBe('marked');
expect(old.classList.contains('jc-row-gone')).toBe(true);
expect(dropRow(old)).toBe('unmarked');
expect(old.classList.contains('jc-row-gone')).toBe(false);
expect(doc.getElementById('old')).not.toBeNull();
});
});