feat(activity-ops): 启动表单两格按芯片类型联动 —— 候选带分组键,先填的那格是锚
CI / ci (push) Failing after 10s

契约:launchOption 加 group/groupLabel(x-options.group 从字典表现读人读名)、
launchParam 加 linkGroup(x-link 结组);api-report 已随 --update 入库。
前端 launch-link.ts 纯函数管三条口径:锚摆全量、其余按锚的分组键收窄
(未标类型殿后一组,新增照走);锚粘着 —— 清空只解除收窄不换锚,人改得了主意。
服务端不据此立墙:combo 本就收候选之外的值,类型一致归 S1 收步判据
sub_eval_part_distinct_chip_type_id = 1(数得出来的门)。
W05 清单两格挂上 x-link: chip-type;文档同步。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
xcs
2026-08-31 18:57:29 +08:00
co-authored by Claude Fable 5
parent 04ec718a66
commit 8c5f3a7736
13 changed files with 630 additions and 20 deletions
@@ -0,0 +1,125 @@
/**
* 启动参数的按分组联动(2026-08-31 用户定:原内部料号 / 候选内部料号按芯片类型互相收窄)。
*
* 三条口径由用户当场定死:
* ①**组内第一个填上值的那格是锚**,锚自己永远摆全量;锚粘着不让位 —— 把它清空,收窄整组解除
* (不把锚顺手交给另一格,否则「清空锚再改主意」当场被另一格锁住);整组都空了才重新认锚。
* ②未标类型的老料**列在末尾单独一组**,不是不列。
* ③锚改了类型之后,另一格里失配的值**清空**(人自己新增的值不动 —— 它压根不在候选里)。
*/
import { describe, expect, it } from 'vitest';
import type { LaunchParam } from '@contracts';
import { comboFace, 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([]);
});
});