Archived
用户:「编辑点击再加一行后,点击旁边的叉没有反应」。两层病: - 件: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
225 lines
12 KiB
TypeScript
225 lines
12 KiB
TypeScript
/**
|
||
* **手写控件嵌套多一层,`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();
|
||
});
|
||
});
|