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/web/src/features/gov-task/__tests__/launch-link.test.ts
T
xcsandClaude Fable 5 e47ce92702 fix(w05): 两颗料不能是同一颗 —— 起单这一刻立墙,因为路只在这里
用户:「创建活动的启动参数里面不应该能选到同样的两个芯片」。
查下来这不只是没意义,是**把单子做死**:sub_eval.part 的主键是 eval_ref+internal_pn
(role 不在主键里),S1 配方铸的两行撞成同一个主键、后一笔覆盖前一笔,两行收成一行 ——
sub_eval_part_exists_status_found = 2 永不成立,S1 收不了步,而件上那两行人加不出来
(行源 mine:,没有「+加一行」),唯一出路是终止重开。与「两格留空」是同一条链。

平台加互斥声明 x-distinct(契约 distinctGroup)——与 x-link 正相反:那个是引导,这个是墙。
· 前端:别的格占着的候选这一格不列(自己填着的那个永远留着,否则 select 当场变空白),
  并说清是谁占了哪个;走「新增」自己写一个同样的 → 小红字 + 启动按不下去;
· 服务端 validateLaunchParams 独立拦(脚本 / API 绕不过),400 点名后填的那格;
· 「算不算同一个」= 去空白 + 不分大小写(launchDistinctKey,前后端同一把尺)——
  只挡精确相等的话 lm2596s-5.0 照样进去,混一颗垃圾主数据出来。
W05 两格挂 x-distinct: 'part',2.14.0 已装库核过。

测:服务端 6 条 + 前端 8 条(含联动与互斥合起来的真形状)+ 包内 1 条,三处变异验证过;
四道门禁绿(2548 测)。

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

190 lines
11 KiB
TypeScript

/**
* 启动参数里格与格之间的两件事(2026-08-31 用户定,W05 的原料号 / 候选料号两格两样都有):
* · **按分组联动**(按芯片类型互相收窄)—— 引导,三条口径见下;
* · **互斥**(不能选到同一颗料)—— 墙,末尾那一个 describe。
*
* 联动的三条口径由用户当场定死:
* ①**组内第一个填上值的那格是锚**,锚自己永远摆全量;锚粘着不让位 —— 把它清空,收窄整组解除
* (不把锚顺手交给另一格,否则「清空锚再改主意」当场被另一格锁住);整组都空了才重新认锚。
* ②未标类型的老料**列在末尾单独一组**,不是不列。
* ③锚改了类型之后,另一格里失配的值**清空**(人自己新增的值不动 —— 它压根不在候选里)。
*/
import { describe, expect, it } from 'vitest';
import type { LaunchParam } from '@contracts';
import { comboFace, distinctIssues, nextAnchors, staleLinked } from '../internal/launch-link.ts';
const OPTIONS = [
{ value: 'LM2596S-5.0', label: 'LM2596S-5.0', group: '1', groupLabel: 'DC-DC 降压稳压器' },
{ value: 'XL2596S-5.0', label: 'XL2596S-5.0', group: '1', groupLabel: 'DC-DC 降压稳压器' },
{ value: 'AMS1117-3.3', label: 'AMS1117-3.3', group: '2', groupLabel: 'LDO 线性稳压器' },
{ value: 'OLD-001', label: 'OLD-001', group: '' },
];
const combo = (key: string, label: string): LaunchParam => ({ key, label, type: 'combo', required: true, options: OPTIONS, linkGroup: 'chip-type' });
const PARAMS: LaunchParam[] = [combo('originalInternalPn', '原内部料号'), combo('candidateInternalPn', '候选内部料号')];
/** 不联动的一格:同一张表上还有别的 combo,不该被卷进来 */
const LOOSE: LaunchParam = { key: 'customerCode', label: '客户', type: 'combo', options: [{ value: 'CU-0142', label: 'Itelma' }] };
const face = (key: string, values: Record<string, string>, anchors: Record<string, string> = {}) =>
comboFace(PARAMS.find((p) => p.key === key)!, [...PARAMS, LOOSE], values, anchors);
describe('nextAnchors —— 谁是锚', () => {
it('组内第一个填上值的那格入座;另一格随后填上也不改朝换代', () => {
const first = nextAnchors(PARAMS, { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: '' }, {});
expect(first).toEqual({ 'chip-type': 'originalInternalPn' });
expect(nextAnchors(PARAMS, { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'XL2596S-5.0' }, first)).toEqual(first);
});
it('先填候选那格,锚就是它(反过来一样)', () => {
expect(nextAnchors(PARAMS, { originalInternalPn: '', candidateInternalPn: 'XL2596S-5.0' }, {})).toEqual({ 'chip-type': 'candidateInternalPn' });
});
it('**锚粘着**:把锚清空,收窄整组解除 —— 不把锚顺手交给还填着值的另一格', () => {
const anchors = { 'chip-type': 'originalInternalPn' };
const after = nextAnchors(PARAMS, { originalInternalPn: '', candidateInternalPn: 'XL2596S-5.0' }, anchors);
expect(after).toEqual(anchors);
// 锚空着 = 没有可收窄的依据:另一格摆全量(四条,分两类 + 未标类型)
expect(face('candidateInternalPn', { originalInternalPn: '', candidateInternalPn: 'XL2596S-5.0' }, after).narrow).toBeUndefined();
});
it('整组都空了才重新认锚 —— 这次谁先填谁当', () => {
const anchors = { 'chip-type': 'originalInternalPn' };
const cleared = nextAnchors(PARAMS, { originalInternalPn: '', candidateInternalPn: '' }, anchors);
expect(cleared).toEqual({});
expect(nextAnchors(PARAMS, { originalInternalPn: '', candidateInternalPn: 'AMS1117-3.3' }, cleared)).toEqual({ 'chip-type': 'candidateInternalPn' });
});
});
describe('comboFace —— 这一格摆哪些候选', () => {
it('没有锚:全量,按类型分组,**未标类型殿后**', () => {
const f = face('originalInternalPn', {});
expect(f.narrow).toBeUndefined();
expect(f.groups.map((g) => [g.label, g.options.map((o) => o.value)])).toEqual([
['DC-DC 降压稳压器', ['LM2596S-5.0', 'XL2596S-5.0']],
['LDO 线性稳压器', ['AMS1117-3.3']],
['未标类型', ['OLD-001']],
]);
});
it('锚自己永远摆全量 —— 人想换类型,回去改锚就行', () => {
const anchors = { 'chip-type': 'originalInternalPn' };
const f = face('originalInternalPn', { originalInternalPn: 'LM2596S-5.0' }, anchors);
expect(f.narrow).toBeUndefined();
expect(f.groups.flatMap((g) => g.options.map((o) => o.value))).toEqual(['LM2596S-5.0', 'XL2596S-5.0', 'AMS1117-3.3', 'OLD-001']);
});
it('另一格按锚的类型收窄:同类型在前、未标类型殿后;点名按谁的什么类型收的', () => {
const f = face('candidateInternalPn', { originalInternalPn: 'LM2596S-5.0' }, { 'chip-type': 'originalInternalPn' });
expect(f.narrow).toEqual({ by: '原内部料号', label: 'DC-DC 降压稳压器' });
expect(f.groups.map((g) => [g.label, g.options.map((o) => o.value)])).toEqual([
['DC-DC 降压稳压器', ['LM2596S-5.0', 'XL2596S-5.0']],
['未标类型', ['OLD-001']],
]);
});
it('**锚本身没有类型**(未标类型的老料)→ 另一格摆全部(用户原话)', () => {
const f = face('candidateInternalPn', { originalInternalPn: 'OLD-001' }, { 'chip-type': 'originalInternalPn' });
expect(f.narrow).toBeUndefined();
expect(f.groups.flatMap((g) => g.options)).toHaveLength(4);
});
it('**锚填的是新增料号**(库里没有这个值)→ 收窄无从谈起,另一格摆全部', () => {
const f = face('candidateInternalPn', { originalInternalPn: 'BRAND-NEW-PN' }, { 'chip-type': 'originalInternalPn' });
expect(f.narrow).toBeUndefined();
expect(f.groups.flatMap((g) => g.options)).toHaveLength(4);
});
it('类型只有键没有名(字典读不到)→ 分组标题退回摆键本身,不编一个名字', () => {
const bare: LaunchParam = { ...PARAMS[1]!, options: [{ value: 'A', label: 'A', group: '7' }] };
const f = comboFace(bare, [PARAMS[0]!, bare], {}, {});
expect(f.groups.map((g) => g.label)).toEqual(['7']);
});
it('不联动的 combo(没有 linkGroup / 候选不带分组键)→ 一组无标题,谁也不管它', () => {
const f = comboFace(LOOSE, [...PARAMS, LOOSE], { originalInternalPn: 'LM2596S-5.0' }, { 'chip-type': 'originalInternalPn' });
expect(f.narrow).toBeUndefined();
expect(f.groups).toEqual([{ label: undefined, options: LOOSE.options }]);
});
});
describe('staleLinked —— 锚换了类型,另一格里失配的值', () => {
it('在全量候选里、却不在收窄后的候选里 → 点名清空', () => {
expect(staleLinked(PARAMS, { originalInternalPn: 'AMS1117-3.3', candidateInternalPn: 'XL2596S-5.0' }, { 'chip-type': 'originalInternalPn' })).toEqual(['candidateInternalPn']);
});
it('同类型的、未标类型的、人自己新增的、空的 —— 一个都不动', () => {
const anchors = { 'chip-type': 'originalInternalPn' };
const keep = (candidate: string) => staleLinked(PARAMS, { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: candidate }, anchors);
expect(keep('XL2596S-5.0')).toEqual([]);
expect(keep('OLD-001')).toEqual([]);
// 新增的值压根不在候选里 —— 它是人自己写的新料号,不是失配的旧选择
expect(keep('BRAND-NEW-PN')).toEqual([]);
expect(keep('')).toEqual([]);
});
it('锚自己不算失配(它摆的就是全量)', () => {
expect(staleLinked(PARAMS, { originalInternalPn: 'AMS1117-3.3', candidateInternalPn: '' }, { 'chip-type': 'originalInternalPn' })).toEqual([]);
});
});
/**
* 互斥(2026-08-31 用户定:「不应该能选到同样的两个芯片」)—— 与联动是两件事:
* 那个是引导(收窄),**这个是墙**(别的格占了的候选这里不列;自己写一个同样的就按不下启动)。
* W05 那两格**两样都声明了**,所以最后一条专测合起来是什么样。
*/
describe('互斥:别的格占走的候选不列', () => {
const both = (key: string, label: string): LaunchParam => ({ ...combo(key, label), distinctGroup: 'part' });
const W05_PARAMS: LaunchParam[] = [both('originalInternalPn', '原内部料号'), both('candidateInternalPn', '候选内部料号')];
const w05Face = (key: string, values: Record<string, string>, anchors: Record<string, string> = {}) =>
comboFace(W05_PARAMS.find((p) => p.key === key)!, [...W05_PARAMS, LOOSE], values, anchors);
it('原料号选了 LM2596S-5.0 → 候选那格不再列它,并说清是谁占了', () => {
const f = w05Face('candidateInternalPn', { originalInternalPn: 'LM2596S-5.0' });
expect(f.groups.flatMap((g) => g.options.map((o) => o.value))).toEqual(['XL2596S-5.0', 'AMS1117-3.3', 'OLD-001']);
expect(f.taken).toEqual([{ by: '原内部料号', value: 'LM2596S-5.0' }]);
});
it('**反过来也一样**(不分先后,谁占了谁的都不列)—— 候选先填,原料号那格就少它一条', () => {
const f = w05Face('originalInternalPn', { candidateInternalPn: 'XL2596S-5.0' });
expect(f.groups.flatMap((g) => g.options.map((o) => o.value))).toEqual(['LM2596S-5.0', 'AMS1117-3.3', 'OLD-001']);
});
it('**自己此刻填着的那个永远留着** —— 抽掉正在显示的值,下拉当场变空白,人看着像被谁清空了', () => {
const f = w05Face('candidateInternalPn', { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'LM2596S-5.0' });
expect(f.groups.flatMap((g) => g.options.map((o) => o.value))).toContain('LM2596S-5.0');
});
it('不在互斥组里的格子(没有 distinctGroup)谁也不管', () => {
const f = comboFace(LOOSE, [...W05_PARAMS, LOOSE], { originalInternalPn: 'LM2596S-5.0', customerCode: 'CU-0142' }, {});
expect(f.taken).toEqual([]);
expect(f.groups).toEqual([{ label: undefined, options: LOOSE.options }]);
});
it('**联动与互斥合起来**(W05 的真形状):按锚的类型收窄之后,锚占着的那颗照样不列', () => {
const f = w05Face('candidateInternalPn', { originalInternalPn: 'LM2596S-5.0' }, { 'chip-type': 'originalInternalPn' });
expect(f.narrow).toEqual({ by: '原内部料号', label: 'DC-DC 降压稳压器' });
// 收窄留下 LM / XL / OLD-001,互斥再抽掉锚占着的 LM
expect(f.groups.map((g) => [g.label, g.options.map((o) => o.value)])).toEqual([
['DC-DC 降压稳压器', ['XL2596S-5.0']],
['未标类型', ['OLD-001']],
]);
});
describe('distinctIssues —— 还能撞上的那条路(自己「新增」写了一个同样的)', () => {
it('点名**后填的那格**,说清撞上了谁', () => {
expect(distinctIssues(W05_PARAMS, { originalInternalPn: 'BRAND-NEW', candidateInternalPn: 'BRAND-NEW' })).toEqual([
{ key: 'candidateInternalPn', by: '原内部料号' },
]);
});
it('去空白、不分大小写 —— 与服务端同一把尺', () => {
expect(distinctIssues(W05_PARAMS, { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: ' lm2596s-5.0 ' })).toHaveLength(1);
});
it('两颗不同的、空的、不同组的 —— 一个都不算撞上', () => {
expect(distinctIssues(W05_PARAMS, { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'XL2596S-5.0' })).toEqual([]);
expect(distinctIssues(W05_PARAMS, { originalInternalPn: '', candidateInternalPn: '' })).toEqual([]);
expect(distinctIssues([...W05_PARAMS, LOOSE], { originalInternalPn: 'CU-0142', customerCode: 'CU-0142' })).toEqual([]);
});
});
});