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/app/__tests__/view-table-edit.test.ts
T
xcsandClaude Opus 5 40daa6c9f7 fix(w05): 评审回来的一条 Critical、一条 Important、两条 Minor
Critical:S1 数据工单的 gateLedgers 带着 parts.chip_type,而这张件没有任何手段
写它(选类型走 data-options 下拉现读,不是编辑表)—— mine:parts.chip_type 永远
是空数组,desk.ts 的 emptyGate 永远判「这一单还一行都没有」,确认钮永久按不下去,
三张真被编辑的表填得再全也一样。删掉 parts.chip_type,只留真被这张件写过的三张;
注释写明这是永久决定,不是等 Task 7b 补了新建类型的编辑表就该还原(主路是从下拉
挑已有类型,那时这张表天然零行,「零行=没干完」这条闸的前提对它压根不成立)。
新增 apps/api 侧回归探针(调用生产 emptyGate,不抄实现):证明旧配置确实会永久
卡死、新配置在同一份数据下真的放行。

Important:BIND_COMMON 的序号计数器规则只认 .tbl-wrap,而三张表的外壳是
.jc-tbl-wrap(继承自已删除的旧 P08_MASTER_CREATE 同一个坑)—— 两个 class 对不上,
「#」列在只读态与编辑态下逐行空白。补上 .jc-tbl-wrap,钉一条结构测试。

Minor:①block③ 的注释「只两栏可改」没说全 —— internal_pn/param_name 也走 cell()
(主键格必挂 data-col 是既定约定,行的钥匙),补全这句话;②data-class-of="gate_reason"
在中文自由文本上永远算不出 v-token,是源码里躺着的一个永远不起作用的属性——删掉,
显隐只留 :has(:empty) 一处,brief 给的那条断言同步改成断真实机制。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 19:11:03 +08:00

638 lines
32 KiB
TypeScript

/**
* **件上就地改** —— 人手改的那几格,是不是落得下去、按不按得下提交。
*
* 两件事在这儿测,都是用户实测逼出来的(2026-08-02,同一单):
*
* ① **按地址收,不按「当下那张卡的第几块」收**。
* 载荷从前是 `{workOrderId, values:{'table:0': …}}`:列名由那张工单的 `blocks[0]`
* 给出,于是「这份件能不能改」被绑在了「此刻开着的那张卡长什么样」上。
* 实测:一单在放行门连拒五拍熔断,引擎发的是一张**没有 blocks 的熔断卡**;
* 屏上摆着的正是受理记录那份件,闸在、编辑进得去、保存按得下 ——
* 前端找不到匹配的块,**一格都没往下送**。而人要补的恰恰是把这一单
* 卡在那儿的那个必填格。
*
* ② **必填还空着就按不下提交**。同一单里,受理需求那张卡上一条料缺年用量(必填),
* 人照样交了回来;这一单于是一路走到放行门被 L1 拦住,模型连拒五拍熔断。
* 同一件事在提交那一刻拦下便宜得多 —— 那时缺的是哪一格就摆在他眼前。
*
* 测的是生产那两段本身(`writesOf` / `gateOf`),不在测里抄一份实现:
* 抄一份的下场踩过 —— 生产改了、测没跟着改,测全绿而线上照旧。
*/
import { describe, expect, it } from 'vitest';
import { emptyGate, findKnownRow, gateAddressesOf, gateOf, parseRowKey, resolveRowKey, rowChanged, writesOf } from '../routes/desk.ts';
import { createOnlyViolation, decisionReadings, missingReadings } from '../wiring/engines.ts';
/** A1 受理记录的声明(裁到这一段用得着的几列):PN 与年用量必填 */
const RECORD = {
domain: 'quote_intake',
object: 'record',
fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true },
{ name: 'pn', type: 'text', primaryKey: true, label: '型号 PN', required: true },
{ name: 'eau', type: 'number', primaryKey: false, label: '年用量 EAU', required: true },
{ name: 'brand', type: 'text', primaryKey: false, label: '制造商', expect: true },
],
};
const VERDICT = {
domain: 'quote_intake',
object: 'verdict',
fields: [{ name: 'rfq_ref', type: 'text', primaryKey: true }],
};
describe('件上按保存:按地址收', () => {
it('**地址在声明里就写** —— 当下开着的是哪张卡、带没带块,一概不问', () => {
const got = writesOf(
[RECORD, VERDICT],
{ 'quote_intake.record': [{ pn: 'NE555P', eau: '30000' }] },
);
expect(got).toHaveLength(1);
expect(got[0]?.declared.object).toBe('record');
expect(got[0]?.lines).toEqual([{ pn: 'NE555P', eau: '30000' }]);
});
it('一次收好几张表 —— 需求表与分类表在同一份件上,各写各的', () => {
const got = writesOf([RECORD, VERDICT], {
'quote_intake.record': [{ pn: 'A', eau: '1' }],
'quote_intake.verdict': [{ rfq_ref: 'R1' }],
});
expect(got.map((x) => x.declared.object)).toEqual(['record', 'verdict']);
});
it('**活动没声明过的地址不写** —— 载荷从浏览器来,门得在服务端', () => {
expect(writesOf([RECORD], { 'customer.profile': [{ name: '改我' }] })).toEqual([]);
});
it('地址不成形的不写 —— 「域.对象」两段,多一段少一段都不认', () => {
expect(writesOf([RECORD], { record: [{ pn: 'A' }] })).toEqual([]);
expect(writesOf([RECORD], { 'a.b.c': [{ pn: 'A' }] })).toEqual([]);
});
});
/**
* **人在件上加的那一行,主键得凑得齐** —— 凑不齐就被静默丢掉,而屏上那一行还在
* (界面说加上了,台账里没有 —— 最坏的一类:它不报错)。
*
* 三类主键各有各的凑法(2026-08-23,审 I01 需求清单时逮到的):
* · **件上没摆的那一列**(如一单一个的案号):本单现有行全表一致 → 就是它;
* · **件上摆了的**:人填了就用他填的;
* · **序号型(整数主键)**:件上摆着,可「加一行」那一下所有格子都被清空
* (`ViewContainer.addRow` 如此)——各行序号本就互不相同,「全表一致」永远取不出,
* 于是整行被丢。序号的语义就是「本轮内递增」,该由基座续:现有最大值 + 1。
* I01 的需求清单主键正是 `round_ref + req_seq`,不补这一手,人加的需求就消失。
*/
describe('件上加行:主键凑得齐才落得下', () => {
const KEYS = ['round_ref', 'req_seq'];
const TYPES = { round_ref: 'text', req_seq: 'integer' };
const DRAFTS = [
{ key: { round_ref: 'CTB-1', req_seq: 1 } },
{ key: { round_ref: 'CTB-2', req_seq: 2 } },
];
const SAME_ROUND = [
{ key: { round_ref: 'CTB-1', req_seq: 1 } },
{ key: { round_ref: 'CTB-1', req_seq: 2 } },
];
it('老行:件上摆了序号,案号从全表一致里补', () => {
expect(resolveRowKey({ keys: KEYS, cells: { req_seq: '2' }, drafts: SAME_ROUND, types: TYPES })).toEqual({
round_ref: 'CTB-1',
req_seq: '2',
});
});
it('**空着的自增列不许从别的行借号** —— 加的新联系人不再覆盖该客户已有的草稿行(2026-08-24)', () => {
// 人加一行:customer_code 填了、contact_code(auto)在件上但空着;该客户已有一行草稿联系人
const contactDrafts = [{ key: { customer_code: 'CU-E2E-ADD', contact_code: 'CU-E2E-ADD-01' } }];
const out = resolveRowKey({
keys: ['customer_code', 'contact_code'],
cells: { customer_code: 'CU-E2E-ADD', contact_code: '', name: '测试CTO王工' },
drafts: contactDrafts,
types: { customer_code: 'text', contact_code: 'text' },
autos: ['contact_code'],
});
// 不许把 -01 借来当自己的钥匙(那是覆盖别人);认不出 → 交给 mint 那条路续号
expect(out).toBeUndefined();
// 编码真填了的(编辑既有行)照旧认得出
expect(
resolveRowKey({
keys: ['customer_code', 'contact_code'],
cells: { customer_code: 'CU-E2E-ADD', contact_code: 'CU-E2E-ADD-01', name: '改名' },
drafts: contactDrafts,
types: { customer_code: 'text', contact_code: 'text' },
autos: ['contact_code'],
}),
).toEqual({ customer_code: 'CU-E2E-ADD', contact_code: 'CU-E2E-ADD-01' });
});
it('**新增行:序号空着 → 基座续号(现有最大 + 1)**,不再静默丢掉', () => {
expect(resolveRowKey({ keys: KEYS, cells: { content_type: '案例集' }, drafts: SAME_ROUND, types: TYPES })).toEqual({
round_ref: 'CTB-1',
req_seq: '3',
});
});
it('空表上加第一行:序号从 1 起 —— 但案号取不出就还是不猜', () => {
expect(resolveRowKey({ keys: KEYS, cells: { content_type: '公司介绍' }, drafts: [], types: TYPES })).toBeUndefined();
expect(
resolveRowKey({ keys: KEYS, cells: { round_ref: 'CTB-9', content_type: '公司介绍' }, drafts: [], types: TYPES }),
).toEqual({ round_ref: 'CTB-9', req_seq: '1' });
});
/*
* **只有一行时,那一行的序号不是「全表一致的常量」**(2026-08-23 浏览器实测逮到:
* 人在只有一行的清单上加一行,新行拿到 req_seq=1,**当场把原来那一行覆盖掉** ——
* 屏上两行、库里一行,而且内容还成了新的那份)。序号逐行不同是它的定义,
* 一致只是行数太少的假象。
*/
it('**表里只有一行:加的新行续成 2,不覆盖原来那一行**', () => {
const one = [{ key: { round_ref: 'CTB-1', req_seq: 1 } }];
expect(resolveRowKey({ keys: KEYS, cells: { content_type: '销售工具包' }, drafts: one, types: TYPES })).toEqual({
round_ref: 'CTB-1',
req_seq: '2',
});
});
it('老行照旧认得出来 —— 件上摆了序号(主键列必挂 data-col,它是行的钥匙)', () => {
const one = [{ key: { round_ref: 'CTB-1', req_seq: 1 } }];
expect(resolveRowKey({ keys: KEYS, cells: { req_seq: '1', scene: '展会' }, drafts: one, types: TYPES })).toEqual({
round_ref: 'CTB-1',
req_seq: '1',
});
});
it('案号本身取不出(一单跨了两个号)→ 不猜,如实跳过', () => {
expect(resolveRowKey({ keys: KEYS, cells: { content_type: '案例集' }, drafts: DRAFTS, types: TYPES })).toBeUndefined();
});
it('**文本主键不续号** —— 序号能续是因为它本就是「第几条」,PN 这种续不出来', () => {
const textKeys = ['rfq_ref', 'pn'];
const textTypes = { rfq_ref: 'text', pn: 'text' };
const drafts = [{ key: { rfq_ref: 'R1', pn: 'A' } }, { key: { rfq_ref: 'R1', pn: 'B' } }];
expect(resolveRowKey({ keys: textKeys, cells: { eau: '100' }, drafts, types: textTypes })).toBeUndefined();
});
});
describe('提交闸:必填没填就交不了', () => {
const gate = (rows: Array<Record<string, unknown>>) =>
gateOf(missingReadings({ 'mine:quote_intake.record': rows }, [RECORD]), [RECORD]);
it('一格不缺 → 交得了', () => {
expect(gate([{ rfq_ref: 'R1', pn: 'NE555P', eau: 30000 }]).ready).toBe(true);
});
it('**缺必填 → 交不了,并说清差在哪一行哪一格**', () => {
const got = gate([
{ rfq_ref: 'R1', pn: 'NE555P', eau: 30000 },
{ rfq_ref: 'R1', pn: 'DAG2400L321504XZB01-R0', eau: null },
]);
expect(got.ready).toBe(false);
expect(got.why).toContain('1 条必填');
expect(got.blanks).toEqual(['R1/DAG2400L321504XZB01-R0 缺年用量 EAU']);
});
it('**选填缺不拦单** —— 那一问不能省,但它不是交不了的理由', () => {
expect(gate([{ rfq_ref: 'R1', pn: 'NE555P', eau: 30000, brand: '' }]).ready).toBe(true);
});
it('空串与 null 同等看待 —— 模型用 `""` 表示「原文没给」', () => {
expect(gate([{ rfq_ref: 'R1', pn: 'NE555P', eau: '' }]).ready).toBe(false);
});
it('**与放行门的 L1 同一个数** —— 两处各算各的,就会出现「卡上说能交、放行门说不行」', () => {
const rows = [{ rfq_ref: 'R1', pn: 'A', eau: 1 }, { rfq_ref: 'R1', pn: 'B', eau: null }];
const readings = missingReadings({ 'mine:quote_intake.record': rows }, [RECORD]);
// L1 判据读的就是这个键:`quote_intake_record_missing = 0`
expect(readings['quote_intake_record_missing']).toBe(1);
expect(gateOf(readings, [RECORD]).ready).toBe(false);
});
});
/*
* **行级必填 `requiredWhen`:必填钉在该轮到的那一行**(2026-08-02 用户:
* 「工作区的 HTML 必填项必须填写了才能提交啊,不填写能算任务完成了吗?」)。
*
* 逐颗按序派单(A2 的 sel_stage 队列)撞上表级 required 的死结:定案三格整列必填,
* 第一颗的卡会被第二颗(还没轮到、件上不可见)的空格拦死;整列不标,闸又没牙,
* 空手就能交卷(用户实测点名)。requiredWhen 只在行上指定列等于指定值时才算必填。
*/
describe('行级必填:requiredWhen 只数该轮到的那一行', () => {
const LIST = {
domain: 'quote_alternative',
object: 'list',
fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true },
{ name: 'pn', type: 'text', primaryKey: true },
{ name: 'alt_pn', type: 'text', primaryKey: false, label: '替代料 PN', requiredWhen: { column: 'sel_stage', value: 'NOW' } },
{ name: 'alt_grade', type: 'text', primaryKey: false, label: '替代等级', requiredWhen: { column: 'sel_stage', value: 'NOW' } },
],
};
const read = (rows: Array<Record<string, unknown>>) =>
missingReadings({ 'mine:quote_alternative.list': rows }, [LIST]);
it('**排队(WAIT)的颗不拦当前的卡** —— 只有 NOW 行的空格算缺', () => {
const got = read([
{ rfq_ref: 'R1', pn: 'A', sel_stage: 'NOW', alt_pn: '', alt_grade: '' },
{ rfq_ref: 'R1', pn: 'B', sel_stage: 'WAIT', alt_pn: '', alt_grade: '' },
]);
expect(got['quote_alternative_list_missing']).toBe(1);
expect(got['quote_alternative_list_missing_keys']).toBe('R1/A 缺替代料 PN、替代等级');
});
it('NOW 行定案齐了 → 缺 0,闸开;办结(DONE)的行不再数', () => {
const got = read([
{ rfq_ref: 'R1', pn: 'A', sel_stage: 'DONE', alt_pn: 'X', alt_grade: 'P2P' },
{ rfq_ref: 'R1', pn: 'B', sel_stage: 'NOW', alt_pn: 'Y', alt_grade: 'FUNC' },
]);
expect(got['quote_alternative_list_missing']).toBe(0);
});
it('**未果撤行之后 NOW 行不存在 → 缺 0** —— 撤行也是一个答案', () => {
expect(read([{ rfq_ref: 'R1', pn: 'B', sel_stage: 'WAIT', alt_pn: '', alt_grade: '' }])['quote_alternative_list_missing']).toBe(0);
});
it('不带 requiredWhen 的 required 照旧整列必填 —— 老声明一个字不用改', () => {
const plain = {
domain: 'd',
object: 'o',
fields: [
{ name: 'k', type: 'text', primaryKey: true },
{ name: 'v', type: 'text', primaryKey: false, label: '值', required: true },
],
};
expect(missingReadings({ 'mine:d.o': [{ k: '1', v: '' }] }, [plain])['d_o_missing']).toBe(1);
});
});
/*
* **执行卡零输入之后,闸看哪几张表**(2026-08-02 用户定:填写全去工作区)。
*
* 卡面不再绑 blocks,闸若只认卡上的表就整个失效 —— 缺着必填照样交得回来,
* 一路走到放行门才被 L1 拦住,那正是当初立这道闸要治的病。
* 回落断了页面上看不出来(按钮照常绿着),所以钉在这儿。
*/
describe('闸看哪几张表:卡上绑的优先,没绑回落到必交付物声明', () => {
const table = (ledger: string) => ({ t: 'table' as const, ledger, cols: [], rows: [], fields: [] });
it('卡上绑了表 → 看绑的那几张,交付物声明不掺和', () => {
expect(
gateAddressesOf([table('quote_intake.record')], [{ ledgers: ['a.b', 'c.d'] }]),
).toEqual(['quote_intake.record']);
});
it('**卡上没绑表(零输入执行卡)→ 回落到必交付物的 ledgers 全表**', () => {
expect(
gateAddressesOf(undefined, [{ ledger: 'quote_intake.record', ledgers: ['quote_intake.record', 'quote_intake.verdict'] }]),
).toEqual(['quote_intake.record', 'quote_intake.verdict']);
});
it('**卡自己声明了 gateLedgers → 看它,不回落到交付物**(2026-08-27 W05 实测:数据工单要补的是 parts.* 主数据,不在必交付物名单里,闸盯着一堆无关的表、一格不缺,空手交卷一路放行)', () => {
expect(
gateAddressesOf(undefined, [{ ledgers: ['sub_eval.input_gap', 'substitution.relation'] }], ['parts.profile', 'parts.parameter']),
).toEqual(['parts.profile', 'parts.parameter']);
});
it('**卡自报的表一行都没有 = 没完成**(2026-08-27 W05 e2e:模型压拍没写骨架就发了数据工单,件上空空如也,而「缺必填」只数已有行 → 零行零缺、闸照开)', () => {
const watched = [
{ domain: 'parts', object: 'profile', label: '芯片主数据' },
{ domain: 'parts', object: 'parameter', label: '标准化参数集' },
];
const declared = ['parts.profile', 'parts.parameter'];
expect(emptyGate({}, watched, declared)?.ready).toBe(false);
expect(emptyGate({}, watched, declared)?.why).toContain('芯片主数据、标准化参数集');
// 两张都有行 → 不归这一段管,交给「缺不缺必填」那一段
expect(emptyGate({ 'mine:parts.profile': [{}], 'mine:parts.parameter': [{}] }, watched, declared)).toBeUndefined();
// 一张有一张没有 → 只点没有的那一张
expect(emptyGate({ 'mine:parts.profile': [{}] }, watched, declared)?.why).toContain('标准化参数集');
});
it('卡没自报(回落到必交付物那条老路)→ 零行不拦人:那条路上零行是常态', () => {
expect(emptyGate({}, [{ domain: 'a', object: 'b' }], [])).toBeUndefined();
});
/*
* **W05 `input-supplement` 回归网**(2026-08-29 评审 Critical:确认钮永久按不下去)。
*
* 那张数据工单卡的 `gateLedgers` 一度带着 `parts.chip_type`,而件上没有任何
* `data-edit="parts.chip_type"`(选类型走的是 `data-options` 下拉现读,不写这张表)——
* `mine:parts.chip_type` 因而永远是空数组,`emptyGate` 永远判「这一单还一行都没有」,
* 人把另外三张表填得再全,提交钮也按不下去。这条钉死两件事:
* ①**复现旧配置确实会永久卡死**(不是猜的);②**修完(去掉 chip_type)之后闸真的开得了**——
* 同一份 `mine`(chip_type 全程零行,profile/type_param/parameter 都已填好)在新
* `gateLedgers` 下必须放行。ledger 的 domain/object/label 抄自
* `activities/part-substitution-assessment/src/manifest.ts` 的声明原文。
*/
it('W05 input-supplement:gateLedgers 若带 parts.chip_type 会永久卡死(它没有编辑手段可写)——去掉之后,三张真被编辑的表填齐即放行', () => {
const watched = [
{ domain: 'parts', object: 'chip_type', label: '芯片类型字典' },
{ domain: 'parts', object: 'type_param', label: '芯片类型核心参数字段' },
{ domain: 'parts', object: 'profile', label: '芯片主数据(P-08)' },
{ domain: 'parts', object: 'parameter', label: '标准化参数集(P-08 子表)' },
];
// parts.chip_type 从未被这张件写过 —— 无论闸怎么配,它在 mine 里恒是空数组
const filledExceptChipType = {
'mine:parts.type_param': [{ type_id: '1', param_name: '输入电压范围' }],
'mine:parts.profile': [{ internal_pn: 'LM2596S-5.0' }, { internal_pn: 'XL2596S-5.0' }],
'mine:parts.parameter': [{ internal_pn: 'LM2596S-5.0', param_name: '输入电压范围' }],
};
// 旧配置(已删除,这里复现只为证明它确实会卡死):chip_type 也在闸的名单里
const buggyDeclared = ['parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter'];
const stuck = emptyGate(filledExceptChipType, watched, buggyDeclared);
expect(stuck?.ready).toBe(false);
expect(stuck?.why).toContain('芯片类型字典');
expect(stuck?.why).toContain('这张卡就是来建它的');
// 修完(case-model.ts 现在的真实值):chip_type 不列入闸的名单
const fixedDeclared = ['parts.type_param', 'parts.profile', 'parts.parameter'];
expect(emptyGate(filledExceptChipType, watched, fixedDeclared)).toBeUndefined();
});
it('优先级:绑的表 > 卡自报 > 交付物;空串与空白不算数', () => {
expect(gateAddressesOf([table('quote_intake.record')], [{ ledgers: ['a.b'] }], ['parts.profile'])).toEqual(['quote_intake.record']);
expect(gateAddressesOf(undefined, [{ ledgers: ['a.b'] }], ['', ' '])).toEqual(['a.b']);
expect(gateAddressesOf(undefined, [{ ledgers: ['a.b'] }], [' parts.profile '])).toEqual(['parts.profile']);
});
it('老清单只给 ledger 单张 → 退它那一张;两边都空才真的没有依据', () => {
expect(gateAddressesOf([], [{ ledger: 'quote_sourcing.quote_list' }])).toEqual(['quote_sourcing.quote_list']);
expect(gateAddressesOf([], [{}])).toEqual([]);
expect(gateAddressesOf(undefined, [])).toEqual([]);
});
it('非表块(文本段)不算绑表 —— 熔断卡带 basis 不带表,照样回落', () => {
expect(
gateAddressesOf(
[{ t: 'p', text: '拒绝理由' } as never],
[{ ledgers: ['quote_alternative.list', 'quote_alternative.verdict'] }],
),
).toEqual(['quote_alternative.list', 'quote_alternative.verdict']);
});
});
describe('只写真改了的那几行', () => {
const row = { rfq_ref: 'R1', pn: 'NE555P', eau: 30000, brand: 'TI' };
it('**一个字没改就不算一次修订** —— 件上按保存,整张表都回传上来,它不知道人动了哪一格', () => {
expect(rowChanged(row, { eau: 30000, brand: 'TI' })).toBe(false);
});
it('改了一格就算 —— 只按值比,不按有没有回传比', () => {
expect(rowChanged(row, { eau: 45000, brand: 'TI' })).toBe(true);
});
it('**数字与它的字符串写法相等** —— 台账里躺的是 30000,人手填回来的是 "30000"', () => {
expect(rowChanged(row, { eau: '30000' })).toBe(false);
});
it('空与没有相等 —— 空格子回传成 `""`,而台账里那一列压根没写过', () => {
expect(rowChanged(row, { remark: '' })).toBe(false);
expect(rowChanged({ ...row, remark: null }, { remark: '' })).toBe(false);
});
it('台账里还没有这一行 → 是新增,当然算改', () => {
expect(rowChanged(undefined, { eau: 30000 })).toBe(true);
});
});
/**
* **增删行**(2026-08-02 用户:「编辑列表应该还能增删行」)。
*
* 手改本来就是**兜 AI 的错**的那一手,而错不止「这一格填错了」:
* 「这一行根本不该在」(客户点名本轮跳过的料被写进了有效清单)与「漏了一行」
* 是同一类错 —— 只给改格子,前两种就没有出口,那一单会一路走到放行门被判据拦住,
* 而人明明一眼就看出问题在哪(实测那一单 13 次拒回、234k token 才熔断)。
*/
describe('增删行:删要说出来,不靠「没回传就是删了」', () => {
it('**删走 `drops`,与改走 `tables` 分开** —— 两份都按地址过同一道声明门', () => {
const drops = writesOf([RECORD], { 'quote_intake.record': [{ pn: 'NE555P', rfq_ref: 'R1' }] });
expect(drops).toHaveLength(1);
expect(drops[0]?.lines).toEqual([{ pn: 'NE555P', rfq_ref: 'R1' }]);
});
it('删的地址也得在声明里 —— 载荷从浏览器来,撤行比改格子更该有门', () => {
expect(writesOf([RECORD], { 'customer.profile': [{ name: '撤我' }] })).toEqual([]);
});
it('**回传里没提到的行不算删** —— 一次不完整的回传不该变成一次删表', () => {
// 三行的表只回传一行:那是「这次只说了这一行」,不是「另外两行撤掉」
const got = writesOf([RECORD], { 'quote_intake.record': [{ pn: 'A' }] });
expect(got[0]?.lines).toHaveLength(1); // 删与不删由 drops 说了算,tables 不承载删的语义
});
it('新增行照样过归型与必填 —— 加一行只填了 PN,闸照旧拦着', () => {
const readings = missingReadings(
{ 'mine:quote_intake.record': [{ rfq_ref: 'R1', pn: '老料', eau: 1 }, { rfq_ref: 'R1', pn: '人加的新料' }] },
[RECORD],
);
const got = gateOf(readings, [RECORD]);
expect(got.ready).toBe(false);
expect(got.blanks).toEqual(['R1/人加的新料 缺年用量 EAU']);
});
});
/**
* **自增主键由基座生成**(2026-08-02 用户:「联系人ID自增啊,不用填」)。
*
* 联系人编码不承载业务含义,只是「这家客户下的第几位」。让人填是白占一格
* 他答不上来的表单(他哪知道已经有几位);让模型编更糟 —— 编重了就是覆盖别人那一行,
* 而主数据覆盖错了后面每一单都跟着错。
*/
describe('自增主键:前缀-两位序号', () => {
/** 与 `routes/desk.ts` 的 `autoKeys` 同一口径 —— 改一处这里要跟着改 */
const gen = (others: string[], given: Record<string, string>, count: number, names: string[]) => {
const prefix = others.map((k) => given[k] ?? '').filter((v) => v !== '').join('-');
const out: Record<string, string> = {};
for (const [i, n] of names.entries()) {
const seq = String(count + 1 + i).padStart(2, '0');
out[n] = prefix === '' ? seq : `${prefix}-${seq}`;
}
return out;
};
it('**照种子里那个写法**:第一位联系人是 `客户编码-01`', () => {
expect(gen(['customer_code'], { customer_code: 'CU-9001' }, 0, ['contact_code'])).toEqual({
contact_code: 'CU-9001-01',
});
});
it('这家客户已经有两位 → 新的是 -03,不跟谁撞', () => {
expect(gen(['customer_code'], { customer_code: 'CU-0142' }, 2, ['contact_code'])['contact_code']).toBe('CU-0142-03');
});
it('同一单里连加两位 → 各自拿到 -01 / -02(后一位不覆盖前一位)', () => {
expect(Object.values(gen(['customer_code'], { customer_code: 'CU-1' }, 0, ['a', 'b']))).toEqual([
'CU-1-01',
'CU-1-02',
]);
});
it('没有其余主键(它自己就是唯一那把键)→ 退回纯序号', () => {
expect(gen([], {}, 4, ['seq'])).toEqual({ seq: '05' });
});
});
describe('主键摆不全时认现有行:一格都没认出来的碎片不许命中随便第一行', () => {
const drafts = [
{ key: { round_ref: 'R1', contact_code: 'CU-0142-01' } },
{ key: { round_ref: 'R1', contact_code: 'CU-0087-02' } },
{ key: { round_ref: 'R1', contact_code: 'CU-0087-01' } },
];
const keys = ['round_ref', 'contact_code'];
it('至少一格对得上 → 命中那一行(正常路径)', () => {
expect(findKnownRow(drafts, keys, { contact_code: 'CU-0087-02' })?.key).toEqual({
round_ref: 'R1',
contact_code: 'CU-0087-02',
});
});
it('**一格主键都没给的碎片**(如渲染多嵌一层导致 collect() 拆出的空键分组)→ 不命中第一行,回 undefined', () => {
expect(findKnownRow(drafts, keys, {})).toBeUndefined();
});
it('给了但一个都对不上 → 同样不命中(不是「随便一行」,是「哪行都不是」)', () => {
expect(findKnownRow(drafts, keys, { contact_code: 'CU-9999-01' })).toBeUndefined();
});
});
/**
* **点开一份资料文件时,那一行是怎么认出来的**(2026-08-23)。
*
* 件上传来的 `key` 是一串字符,而"哪一行"是主键说了算。这一段的要害是
* **只认声明里的主键列** —— 不然一个精心构造的 key 就能把别的行当成"这一份资料"取走。
*/
describe('parseRowKey —— 件上传来的主键认哪一行', () => {
it('单列主键:直接给值', () => {
expect(parseRowKey('CT-2026-0001', ['content_id'])).toEqual({ content_id: 'CT-2026-0001' });
});
it('多列主键:列=值 以逗号连,顺序无所谓', () => {
expect(parseRowKey('req_seq=2,round_ref=BR-1', ['round_ref', 'req_seq'])).toEqual({
round_ref: 'BR-1',
req_seq: '2',
});
});
it('**声明里没有的列名一律不认** —— 别让一串 key 把别的行取走', () => {
expect(parseRowKey('content_id=A,secret=1', ['content_id'])).toBeUndefined();
});
it('主键给不全:不认(宁可打不开,也不打开错的那一份)', () => {
expect(parseRowKey('round_ref=BR-1', ['round_ref', 'req_seq'])).toBeUndefined();
expect(parseRowKey('round_ref=BR-1,req_seq=', ['round_ref', 'req_seq'])).toBeUndefined();
});
it('多列主键却只给了个裸值:不认(猜不出它是哪一列)', () => {
expect(parseRowKey('BR-1', ['round_ref', 'req_seq'])).toBeUndefined();
});
it('空串 / 没有主键列:不认', () => {
expect(parseRowKey('', ['content_id'])).toBeUndefined();
expect(parseRowKey('X', [])).toBeUndefined();
});
});
/**
* **保存说成功,就得真的都存下了**(2026-08-24 用户实测:
* 「用编辑新增了一个客户,推进到下一步直接就把我手动添加的这一行遗漏掉了」)。
*
* 主键那几格空着就凑不出钥匙 —— 从前基座一句 `continue` 悄悄跳过,接口照回 `{ok:true}`:
* 人看到「保存成功」,东西却不在,而提交闸也拦不住(一行根本没写进台账,谈何缺列)。
* 现在没落下的行要点名说出来,且**只点他填得了的那几列**。
*/
describe('落不下的行要点名 —— 不许悄悄丢', () => {
const keys = ['round_ref', 'customer_code'];
it('主键那一格空着 → 认不出这一行(resolveRowKey 交白卷)', () => {
expect(
resolveRowKey({
keys,
cells: { customer_code: '', customer_name: '忘了填编号的客户' },
drafts: [{ key: { round_ref: 'COV-1', customer_code: 'CU-0142' } }],
types: { round_ref: 'text', customer_code: 'text' },
}),
).toBeUndefined();
});
it('主键填了 → 案号由「全表一致」补齐,行认得出来', () => {
expect(
resolveRowKey({
keys,
cells: { customer_code: 'CU-9527', customer_name: '人工加的客户' },
drafts: [{ key: { round_ref: 'COV-1', customer_code: 'CU-0142' } }],
types: { round_ref: 'text', customer_code: 'text' },
}),
).toEqual({ customer_code: 'CU-9527', round_ref: 'COV-1' });
});
it('**表里只有一行时也不许「继承」老行的编码** —— 那会当场把老行覆盖掉', () => {
// 件上摆着 customer_code、人却空着:一行的表在「全表一致」眼里像个常量,
// 从前新行会拿到 CU-0142(屏上两行、库里一行,而且内容还是新的那份)
expect(
resolveRowKey({
keys,
cells: { customer_code: '', customer_name: '新客户' },
drafts: [{ key: { round_ref: 'COV-1', customer_code: 'CU-0142' } }],
types: { round_ref: 'text', customer_code: 'text' },
}),
).toBeUndefined();
});
it('件上没摆的那把钥匙(案号)照旧由基座按全表一致补 —— 那是他碰不到的列', () => {
expect(
resolveRowKey({
keys,
cells: { customer_code: 'CU-9527' },
drafts: [{ key: { round_ref: 'COV-1', customer_code: 'CU-0142' } }],
types: { round_ref: 'text', customer_code: 'text' },
}),
).toEqual({ customer_code: 'CU-9527', round_ref: 'COV-1' });
});
});
/**
* 只建不改的表(契约 `createOnly`)—— 2026-08-27 W05:数据工单里模型多铺了一行不该建的料号,
* 件上多出一行要人填的记录、红带点名它缺品牌。这一项守「在库的料被重建」这一形。
*/
describe('createOnly:键已在正式表 → 拒并指路', () => {
const target = { label: '芯片主数据(P-08)', keys: ['internal_pn'] };
const formal = [{ internal_pn: 'LM2596S-ADJ', brand_name: 'TI' }, { internal_pn: 'LM2596S-5.0' }];
it('在库的料号写下来 → 一句带指路的拒回(点名 drop)', () => {
const why = createOnlyViolation(target, { internal_pn: 'LM2596S-ADJ' }, formal);
expect(why).toContain('已经有「internal_pn=LM2596S-ADJ」');
expect(why).toContain('drop');
});
it('本单经数据工单确认已入库的那一行 → 拒回换一句话:建好了别再写,不点名 drop(撤了读面少一行)', () => {
const why = createOnlyViolation(target, { internal_pn: 'LM2596S-ADJ' }, formal, [{ internal_pn: 'LM2596S-ADJ' }]);
expect(why).toContain('已入库');
expect(why).not.toContain('drop');
// 另一颗在库的料号与本单无关,照旧是原来那句
expect(createOnlyViolation(target, { internal_pn: 'LM2596S-5.0' }, formal, [{ internal_pn: 'LM2596S-ADJ' }])).toContain('drop');
});
it('库里没有的料号 → 放行;复合主键按全部键列比', () => {
expect(createOnlyViolation(target, { internal_pn: 'XL2596S-5.0' }, formal)).toBeUndefined();
const sub = { label: '标准化参数集', keys: ['internal_pn', 'param_name'] };
const rows = [{ internal_pn: 'LM2596S-ADJ', param_name: '输出电压' }];
expect(createOnlyViolation(sub, { internal_pn: 'LM2596S-ADJ', param_name: '输出电压' }, rows)).toBeDefined();
expect(createOnlyViolation(sub, { internal_pn: 'LM2596S-ADJ', param_name: '使能阈值' }, rows)).toBeUndefined();
expect(createOnlyViolation(sub, { internal_pn: 'XL2596S-5.0', param_name: '输出电压' }, rows)).toBeUndefined();
});
});
/** 决策卡的回答是留痕里的事实(2026-08-27:审核哨兵不再读模型自写的旗) */
describe('decisionReadings:按 card-reply 数', () => {
it('最后一次选的值 + 按值计数;custom: 一律归 custom', () => {
const trail = [
{ kind: 'card-sent', detail: {} },
{ kind: 'card-reply', detail: { value: 'custom:重做一次' } },
{ kind: 'card-reply', detail: { value: 'custom:再改一下' } },
{ kind: 'card-reply', detail: { value: 'approve' } },
];
expect(decisionReadings(trail)).toEqual({ decision_last: 'approve', decision_approve_total: 1, decision_reject_total: 0, decision_custom_total: 2 });
});
it('一张都没答 → last 为空串、各计数铺 0(哨兵里 decision_last = "approve" 恒假,门关着)', () => {
expect(decisionReadings([])).toEqual({ decision_last: '', decision_approve_total: 0, decision_reject_total: 0, decision_custom_total: 0 });
});
});