From a3d1f7b545a364be8a583d6bff34db65a43b1cc1 Mon Sep 17 00:00:00 2001 From: xzy1997123 <1977261+xzy1997123@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:02:35 +0800 Subject: [PATCH] fix(OptionEditor): render nested options under multi-select (checkbox) options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getSelectedCase()` returned `undefined` for checkbox options, so `nestedOptionKeys` was always empty and no child option was ever rendered below a multi-select parent. Only switch and select parents had a nested option block; checkbox groups such as `战斗检测目标舰种` (12 cases, each with its own sub-options) silently fell through, leaving the sub-options unreachable from the UI. - add `getSelectedCasesWithOptions()`: for checkbox returns every checked case that declares sub-options, in `cases` order; switch/select keep the previous single-case behaviour; input/hotkey return an empty list - use it in `OptionEditor`, and render the collected groups in the checkbox branch by reusing the existing collapse arrow and grid animation - group sub-options per case and only show the case label when more than one checked case contributes sub-options - add tests for the regression: 2 checked cases with 3 sub-options each must yield 6 entries (the old implementation yielded 0) Refs #187 --- src/components/OptionEditor.tsx | 108 +++++++++++++----- src/utils/optionHelpers.ts | 41 ++++++- tests/optionHelpers.test.ts | 190 ++++++++++++++++++++++++++++++++ 3 files changed, 309 insertions(+), 30 deletions(-) create mode 100644 tests/optionHelpers.test.ts diff --git a/src/components/OptionEditor.tsx b/src/components/OptionEditor.tsx index 142a1e12..667dca35 100644 --- a/src/components/OptionEditor.tsx +++ b/src/components/OptionEditor.tsx @@ -26,7 +26,7 @@ import { ChevronRight, } from 'lucide-react'; import { getInterfaceLangKey } from '@/i18n'; -import { findSwitchCase } from '@/utils/optionHelpers'; +import { getSelectedCasesWithOptions } from '@/utils/optionHelpers'; import { getCheckboxMaxCount, getCheckboxMinCount } from '@/utils/checkboxOptionValidation'; import { stripInlineRichText } from '@/utils/richText'; import { SwitchButton, TextInput, FileInput, TimeInput, HotkeyInput } from './FormControls'; @@ -40,6 +40,13 @@ export function switchHasNestedOptions(optionDef: OptionDefinition): boolean { return optionDef.cases.some((c: CaseItem) => c.option && c.option.length > 0); } +/** 嵌套子选项分组:checkbox 多选时,不同 case 的子项需要分开渲染才能区分归属 */ +interface NestedOptionGroup { + caseName: string; + label: string; + optionKeys: string[]; +} + /** 子选项折叠箭头:复用任务标题的 ChevronRight 样式,位于开关/下拉框左侧 */ function OptionCollapseArrow({ collapsed, @@ -476,24 +483,18 @@ export function OptionEditor({ : undefined; const effectiveDisabled = disabled || isOptionIncompatible; - // 获取当前选中的 case(用于渲染嵌套选项) - const getSelectedCase = (): CaseItem | undefined => { - if (optionDef.type === 'switch') { - const isChecked = effectiveValue?.type === 'switch' ? effectiveValue.value : false; - return findSwitchCase(optionDef.cases, isChecked); - } - if (optionDef.type === 'select' || !optionDef.type) { - const caseName = - effectiveValue?.type === 'select' - ? effectiveValue.caseName - : optionDef.default_case || optionDef.cases?.[0]?.name; - return optionDef.cases?.find((c) => c.name === caseName); - } - return undefined; - }; + // 嵌套子选项分组:switch/select 单选取当前 case;checkbox 为多选, + // 协议允许任意 case 挂 option,故按被选中的 case 逐组汇总(每组保留标签用于区分来源) + const toNestedGroup = (caseItem: CaseItem): NestedOptionGroup => ({ + caseName: caseItem.name, + label: isMxuOption + ? t(caseItem.label || caseItem.name) + : resolveI18nText(caseItem.label, langKey) || caseItem.name, + optionKeys: caseItem.option ?? [], + }); - const selectedCase = getSelectedCase(); - const nestedOptionKeys = selectedCase?.option || []; + const nestedGroups = getSelectedCasesWithOptions(optionDef, effectiveValue).map(toNestedGroup); + const nestedOptionKeys = nestedGroups.flatMap((g) => g.optionKeys); // 当前选项的子选项是否处于折叠状态(缺省展开) const isCollapsed = globalScope ? localCollapsed : !!collapsedOptions?.[optionKey]; @@ -628,17 +629,28 @@ export function OptionEditor({ isOptionIncompatible && 'opacity-60', )} > - - +
+
+ + +
+ {nestedOptionKeys.length > 0 && ( + + )} +
{optionDef.cases.map((caseItem) => { const caseLabel = isMxuOption @@ -710,6 +722,44 @@ export function OptionEditor({
)} + {/* 渲染嵌套选项(可折叠,复用任务标题的 grid 展开动画)。 + checkbox 多选时同一层可能挂多组子项,按 case 分组并标注归属,避免来源混淆 */} + {nestedOptionKeys.length > 0 && ( +
+
+
+ {nestedGroups.map((group) => ( +
+ {nestedGroups.length > 1 && ( +
+ + + +
+ )} + {group.optionKeys.map((nestedKey) => ( + + ))} +
+ ))} +
+
+
+ )} ); } diff --git a/src/utils/optionHelpers.ts b/src/utils/optionHelpers.ts index 3eab179d..a9d8d6cc 100644 --- a/src/utils/optionHelpers.ts +++ b/src/utils/optionHelpers.ts @@ -2,7 +2,7 @@ * 选项相关的工具函数 */ -import type { CaseItem } from '@/types/interface'; +import type { CaseItem, OptionDefinition, OptionValue } from '@/types/interface'; const YES_CASE_NAMES = ['Yes', 'yes', 'Y', 'y'] as const; @@ -23,3 +23,42 @@ export function findSwitchCase( const targetNames: readonly string[] = isChecked ? YES_CASE_NAMES : NO_CASE_NAMES; return cases.find((c) => targetNames.includes(c.name)); } + +/** + * 找出「当前选中且带子选项」的 case 列表,供渲染嵌套 option 使用。 + * + * - switch / select:单选,最多返回 1 个 + * - checkbox:多选,可能返回多个(按 cases 定义顺序,保证渲染顺序稳定) + * - input / hotkey:无 case,返回空数组 + * + * 只返回 option 非空的 case,调用方无需再判断。 + */ +export function getSelectedCasesWithOptions( + optionDef: OptionDefinition, + value: OptionValue | undefined, +): CaseItem[] { + const hasNestedOption = (caseItem: CaseItem) => (caseItem.option?.length ?? 0) > 0; + + if (optionDef.type === 'checkbox') { + const selectedCases = + value?.type === 'checkbox' ? value.caseNames : (optionDef.default_case ?? []); + return optionDef.cases.filter((c) => selectedCases.includes(c.name) && hasNestedOption(c)); + } + + if (optionDef.type === 'switch') { + const isChecked = value?.type === 'switch' ? value.value : false; + const matched = findSwitchCase(optionDef.cases, isChecked); + return matched && hasNestedOption(matched) ? [matched] : []; + } + + if (optionDef.type === 'select' || !optionDef.type) { + const caseName = + value?.type === 'select' + ? value.caseName + : optionDef.default_case || optionDef.cases?.[0]?.name; + const matched = optionDef.cases?.find((c) => c.name === caseName); + return matched && hasNestedOption(matched) ? [matched] : []; + } + + return []; +} diff --git a/tests/optionHelpers.test.ts b/tests/optionHelpers.test.ts new file mode 100644 index 00000000..290598b4 --- /dev/null +++ b/tests/optionHelpers.test.ts @@ -0,0 +1,190 @@ +/** + * 嵌套子选项推导的单测。 + * + * 背景:ProjectInterface 协议允许 case.option 挂子配置项(最大嵌套无限层)。 + * switch / select 是单选,取当前选中的 case 即可;checkbox 是多选, + * 任意被勾选的 case 都可能带子项,必须逐个汇总 —— 早期实现按单选处理, + * 导致 checkbox 下的子配置项永远推导不出来。 + * + * 被测模块只有 type-only import,运行时无需 DOM / Tauri 环境。 + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { getSelectedCasesWithOptions, findSwitchCase } from '../src/utils/optionHelpers.ts'; + +type CaseItem = { + name: string; + label?: string; + option?: string[]; +}; + +function checkbox(cases: CaseItem[], default_case?: string[]) { + return { type: 'checkbox' as const, cases, default_case }; +} + +test('checkbox:勾选一个带子项的 case 时返回它', () => { + const optionDef = checkbox([ + { name: '节点2舰种1', option: ['节点2检测目标名', '节点2反向检测目标'] }, + { name: '节点2舰种2', option: ['节点2检测目标名2'] }, + ]); + const result = getSelectedCasesWithOptions(optionDef, { + type: 'checkbox', + caseNames: ['节点2舰种1'], + }); + assert.deepEqual( + result.map((c) => c.name), + ['节点2舰种1'], + ); +}); + +test('checkbox:同时勾选多个带子项的 case 时全部返回,且保持定义顺序', () => { + const optionDef = checkbox([ + { name: 'A', option: ['a1'] }, + { name: 'B', option: ['b1'] }, + { name: 'C', option: ['c1'] }, + ]); + // 故意让勾选顺序与定义顺序相反,返回值仍应按定义顺序 + const result = getSelectedCasesWithOptions(optionDef, { + type: 'checkbox', + caseNames: ['C', 'A'], + }); + assert.deepEqual( + result.map((c) => c.name), + ['A', 'C'], + ); +}); + +test('checkbox:勾选了不带子项的 case 时返回空数组', () => { + const optionDef = checkbox([{ name: '纯开关项' }, { name: '带子项', option: ['x'] }]); + const result = getSelectedCasesWithOptions(optionDef, { + type: 'checkbox', + caseNames: ['纯开关项'], + }); + assert.deepEqual(result, []); +}); + +test('checkbox:value 缺失时回退到 default_case', () => { + const optionDef = checkbox( + [ + { name: 'A', option: ['a1'] }, + { name: 'B', option: ['b1'] }, + ], + ['B'], + ); + const result = getSelectedCasesWithOptions(optionDef, undefined); + assert.deepEqual( + result.map((c) => c.name), + ['B'], + ); +}); + +test('checkbox:既无 value 也无 default_case 时返回空数组,不抛错', () => { + const optionDef = checkbox([{ name: 'A', option: ['a1'] }]); + assert.deepEqual(getSelectedCasesWithOptions(optionDef, undefined), []); +}); + +test('switch:按布尔值取对应分支,两个分支都可挂子项', () => { + const optionDef = { + type: 'switch' as const, + cases: [ + { name: 'Yes', option: ['on1'] }, + { name: 'No', option: ['off1'] }, + ] as [CaseItem, CaseItem], + }; + const on = getSelectedCasesWithOptions(optionDef, { type: 'switch', value: true }); + const off = getSelectedCasesWithOptions(optionDef, { type: 'switch', value: false }); + assert.deepEqual( + on.map((c) => c.name), + ['Yes'], + ); + assert.deepEqual( + off.map((c) => c.name), + ['No'], + ); +}); + +test('switch:value 缺失时按未选中处理', () => { + const optionDef = { + type: 'switch' as const, + cases: [{ name: 'Yes', option: ['on1'] }, { name: 'No' }] as [CaseItem, CaseItem], + }; + assert.deepEqual(getSelectedCasesWithOptions(optionDef, undefined), []); +}); + +test('select:按 caseName 取对应 case', () => { + const optionDef = { + type: 'select' as const, + cases: [{ name: '简单' }, { name: '完整', option: ['子项1', '子项2'] }], + }; + const result = getSelectedCasesWithOptions(optionDef, { type: 'select', caseName: '完整' }); + assert.deepEqual( + result.map((c) => c.name), + ['完整'], + ); +}); + +test('select:无 value 时依次回退 default_case、首个 case', () => { + const withDefault = { + type: 'select' as const, + cases: [{ name: 'A' }, { name: 'B', option: ['b1'] }], + default_case: 'B', + }; + assert.deepEqual( + getSelectedCasesWithOptions(withDefault, undefined).map((c) => c.name), + ['B'], + ); + + const withoutDefault = { type: 'select' as const, cases: [{ name: 'A', option: ['a1'] }] }; + assert.deepEqual( + getSelectedCasesWithOptions(withoutDefault, undefined).map((c) => c.name), + ['A'], + ); +}); + +test('select:省略 type 时按 select 处理(协议允许)', () => { + const optionDef = { cases: [{ name: 'A', option: ['a1'] }] }; + assert.deepEqual( + getSelectedCasesWithOptions(optionDef, undefined).map((c) => c.name), + ['A'], + ); +}); + +test('input / hotkey:没有 case,返回空数组', () => { + const optionDef = { + type: 'input' as const, + inputs: [{ name: 'field' }], + }; + assert.deepEqual(getSelectedCasesWithOptions(optionDef, undefined), []); +}); + +test('回归:checkbox 不得按单选逻辑只返回第一个分支', () => { + // 真实场景:战斗检测目标舰种 12 个 case,每个挂 3 个子项 + const cases: CaseItem[] = []; + for (let node = 1; node <= 2; node += 1) { + for (let ship = 1; ship <= 2; ship += 1) { + cases.push({ + name: `节点${node}舰种${ship}`, + option: [`节点${node}检测目标名`, `节点${node}反向检测目标`], + }); + } + } + const optionDef = checkbox(cases); + const result = getSelectedCasesWithOptions(optionDef, { + type: 'checkbox', + caseNames: ['节点2舰种1', '节点1舰种2'], + }); + assert.equal(result.length, 2, '两个勾选项都应被推导出来'); + assert.deepEqual( + result.flatMap((c) => c.option ?? []), + ['节点1检测目标名', '节点1反向检测目标', '节点2检测目标名', '节点2反向检测目标'], + ); +}); + +test('findSwitchCase:仍按 Yes/No 别名匹配', () => { + const cases: CaseItem[] = [{ name: 'Yes' }, { name: 'No' }]; + assert.equal(findSwitchCase(cases, true)?.name, 'Yes'); + assert.equal(findSwitchCase(cases, false)?.name, 'No'); + assert.equal(findSwitchCase(undefined, true), undefined); +});