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 Fable 5 c4219a7b7d feat(w05+platform): 确认闸条件档 gateWhen —— 类型不一致不许确认;量高改可缩;卡间互换动画
2026-08-31 用户三连:

① 「提示两颗料不是同一个芯片类型…就不应该能写入并继续下一步」——
  缺格闸只数必填空不空,管不了跨行一致性。平台加**确认闸条件档**:配方 card 声明
  gateWhen(S-FEEL,只读基座读数)+ gateWhenSay,受理回传前在 submitGate 现算
  (rows/missing/tally/distinct 全套读数按声明表现读),为假 400、拒回用发卡人那句话;
  装包静态审与动作级 when 同一套审;评估不出的条件不成墙(坏表达式装包就拦)。
  W05 钉 parts_profile_distinct_chip_type_id <= 1 —— 钉 profile 本表(草稿现读零滞后;
  钉 sub_eval.part 那份要等配方 tick,人存完就点确认会被误拦),profile.chip_type_id
  加 distinct 声明。e2e 案例 2 的空手探针从此吃的就是这道闸。

② 「页面底部没东西还能向下滑一截」—— ViewContainer 量高从
  max(documentElement.scrollHeight, body) 改 body 优先:documentElement 以 iframe 自身
  高度为下限,内容缩回后它缩不回去,量高**只涨不缩**(参数编辑态把卡背拉长过一次,
  底部从此留一截空白滚动)。body(margin 0)才是内容自己的高。

③ 「两张卡片中间做出可替换的动画」—— 单点单向信号换成**对向双信号点**(左右互穿,
  走在卡后、只在缝里现身)+ 中央 ⇄ 徽轻呼吸 —— 这界面是判「可否替换」的,动画说的
  就是这件事。reduced-motion 全静。

测:契约 gateWhen 解析 + runner 透传(变异红)+ conditionGateOf 3 例(含坏表达式不成墙)
+ steps/activity 锚;2531 全仓四门绿;2.12.0 装库核过(gateWhen 入库/swapfx);
:8787 已重建重启。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 17:43:08 +08:00

709 lines
36 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();
});
});
/**
* **Critical 1(2026-08-30 全分支终审逐行核实的链条)**:一张表**唯一**的主键
* 同时是 `auto:true` 又是 `text` 类型(W05 的 `parts.chip_type.type_id` —— 与
* `contact_code` 那种「auto 但有前缀主键陪衬」的形状不同,这里没有别的主键列可以
* 「凑出半把钥匙」)。这个组合把两条各自成立的保护同时绕开了:
* ① `barred` 认的是 `k in cells`(这一列在回传里露没露过面),不是「值是否非空」——
* 视图若干脆不给这一列挂 `data-col`,`collect()` 连这一列的空字符串都拿不到,
* `barred` 因此永远不会把它算进去;
* ② `isSeq` 认的是 declared type 是不是 `integer`/`number` —— 这一列声明成了 `text`
* (为了绕开另一条已记 debt 的坑,见 `docs/debt.md`),「序号型主键不走全表一致」
* 那条 2026-08-23 的保护对它不成立。
* 两条一起缺位,`resolveRowKey` 落到「全表一致」:表里此刻**恰好一行**(活动侧的
* 自投影机制保证了这一点)时,新建的那一行会被误判成「就是那唯一一行」,把它的名字
* /说明**静默覆盖**到既有那一行头上。
*
* 这两条用例用**真实、未改动的 `resolveRowKey`**逐字重放两种 `cells` 形状 ——
* 「视图漏挂 data-col」(旧版 `part-substitution-assessment/src/views/report.ts`
* 的形状)与「视图给了一个禁用、恒空的 data-col」(修复后的形状),证明因果链条本身:
* `resolveRowKey` 这个函数一个字没改,活动侧只是换了一种能让平台既有保护生效的
* 写法。平台侧的根治(`resolveRowKey` 该把声明了 `auto` 的列直接排除在「全表一致」
* 之外,不必等 `k in cells` 这道旁门)仍记在 `docs/debt.md`,下一个建「单一主键又是
* auto 列」的活动仍会踩上第一条用例这个坑。
*/
describe('Critical 1:唯一主键又是 auto 列(无前缀陪衬)——视图漏挂 data-col 会让新建行被误判成改既有行', () => {
const KEYS = ['type_id'];
const TYPES = { type_id: 'text' };
// 活动侧的自投影机制保证此刻 parts.chip_type 恰好一行 —— 种子里的既有类型
const ONE_EXISTING = [{ key: { type_id: '1' } }];
it('**病灶重放**:视图漏挂 data-col → type_id 压根不在 cells 里 → barred 落空 → 「全表一致」把新类型误判成既有那一行的号', () => {
const out = resolveRowKey({
keys: KEYS,
cells: { type_name: '新类型', description: '这是什么' }, // 没有 type_id 这个键 —— 与旧版 ro('type_id') 的产出一致
drafts: ONE_EXISTING,
types: TYPES,
autos: ['type_id'],
});
// 这正是 Critical 1 的病灶:落下的键是既有那一行的号,`writeEditedRows` 接下来会当成「改行」处理,
// 把新类型的 type_name/description 写进 type_id='1' 那一行,静默覆盖种子字典。
expect(out).toEqual({ type_id: '1' });
});
it('**修法重放**:视图挂了 data-col、值恒空(禁用输入格)→ type_id 在 cells 里但是空串 → barred 命中,交回 undefined 让 mintable/autoKeys 续新号', () => {
const out = resolveRowKey({
keys: KEYS,
cells: { type_id: '', type_name: '新类型', description: '这是什么' }, // 与修复后 <input data-col="type_id" disabled> 的产出一致
drafts: ONE_EXISTING,
types: TYPES,
autos: ['type_id'],
});
expect(out).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 });
});
});
describe('conditionGateOf:确认闸的条件档(2026-08-31,「类型不一致还能确认」逼出)', () => {
it('条件为假 → 不受理,拒回用发卡人那句话;为真 / 没声明 → 不拦', async () => {
const { conditionGateOf } = await import('../routes/desk.ts');
const readings = { parts_profile_distinct_chip_type_id: 2 };
const blocked = conditionGateOf('parts_profile_distinct_chip_type_id <= 1', '两颗料不是同一个芯片类型 —— 先改对再确认。', readings);
expect(blocked?.ready).toBe(false);
expect(blocked?.why).toContain('不是同一个芯片类型');
expect(conditionGateOf('parts_profile_distinct_chip_type_id <= 1', '话', { parts_profile_distinct_chip_type_id: 1 })).toBeUndefined();
expect(conditionGateOf(undefined, undefined, readings)).toBeUndefined();
});
it('表达式炸了不成墙(装包静态审管坏表达式;运行时评估不出的条件不该锁死一单)', async () => {
const { conditionGateOf } = await import('../routes/desk.ts');
expect(conditionGateOf('((((', '话', {})).toBeUndefined();
});
});