From 06d967593aaeaaee7313d574dd0e61f284fad6c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E9=98=B3x666?= <2408807389@qq.com> Date: Tue, 21 Jul 2026 15:39:42 +0800 Subject: [PATCH 1/3] Fix command and form error handling --- src/Command/commandClass.ts | 21 +++++++++-- src/Command/parser/ParamTypes.ts | 17 +++++---- src/Command/parser/parser.ts | 10 +++--- src/Form/formManager.ts | 10 +++++- test/command/parser.test.ts | 61 ++++++++++++++++++++++++++++++-- 5 files changed, 102 insertions(+), 17 deletions(-) diff --git a/src/Command/commandClass.ts b/src/Command/commandClass.ts index 04ed4af..ef1776a 100644 --- a/src/Command/commandClass.ts +++ b/src/Command/commandClass.ts @@ -78,7 +78,7 @@ export class Command { /**添加一条分支并在其中添加一条参数 */ addParam(param: ParamDefinition) { - this.paramBranches.push(param); + this.paramBranches.push(Command.cloneParam(param)); return this; } /**添加一条参数分支的多个参数 */ @@ -94,9 +94,8 @@ export class Command { .map((param) => { if (Array.isArray(param)) { return Command.toTreeParam(param); - } else { - return param; } + return Command.cloneParam(param); }) .filter((p) => p != undefined) .sort((a, b) => { @@ -136,6 +135,7 @@ export class Command { if (param) subParams.push(param); } } else { + branch = Command.cloneParam(branch); if (branch.branches) { const params = this.fromParamBranches(branch.branches); branch.subParams = [...(branch.subParams ?? []), ...params]; @@ -149,6 +149,7 @@ export class Command { }); } private static toTreeParam(params: ParamDefinition[]): ParamDefinition | undefined { + params = params.map((param) => Command.cloneParam(param)); for (let i = 0; i < params.length; i++) { const param = params[i]; if (param.branches && param.branches.length != 0) { @@ -164,6 +165,20 @@ export class Command { return params[0]; } + /** Creates an independent parameter tree so caller-owned command definitions remain reusable. */ + private static cloneParam(param: ParamDefinition): ParamDefinition { + return { + ...param, + enums: param.enums ? [...param.enums] : undefined, + branches: param.branches?.map((branch) => + Array.isArray(branch) + ? branch.map((child) => Command.cloneParam(child)) + : Command.cloneParam(branch) + ), + subParams: param.subParams?.map((child) => Command.cloneParam(child)), + }; + } + /**转换为原生命令以便注册(内部调用) */ toNative(nameSpace: string) { const branch = this.getFlatBranch(nameSpace); diff --git a/src/Command/parser/ParamTypes.ts b/src/Command/parser/ParamTypes.ts index 0b67e30..6979d07 100644 --- a/src/Command/parser/ParamTypes.ts +++ b/src/Command/parser/ParamTypes.ts @@ -127,9 +127,14 @@ export const paramParser: Record }, string: { parser(value) { - return new ParseInfo(value[0]); + const text = value[0]; + return new ParseInfo( + text.length >= 2 && text.startsWith('"') && text.endsWith('"') + ? text.slice(1, -1) + : text + ); }, - regex: new RegExp(/^[^\x20]+$/), + regex: new RegExp(/^(?:"[^"]*"|[^\x20]+)$/), }, position: { parser(value, context) { @@ -157,8 +162,8 @@ export const paramParser: Record const coordinate = matchResults[i * 3]; const operator = matchResults[i * 3 + 1]; const offset = matchResults[i * 3 + 2] ?? 0; - const offsetValue = parseInt(offset); - let coordinateValue = coordinate == "~" ? playerPosition[i] : parseInt(coordinate); + const offsetValue = Number(offset); + let coordinateValue = coordinate == "~" ? playerPosition[i] : Number(coordinate); if (operator == "-") { coordinateValue -= offsetValue; } else { @@ -168,7 +173,7 @@ export const paramParser: Record } return new ParseInfo(Vector3Utils.fromArray(parsedCoordinates as any), j); }, - regex: new RegExp(/^(?:-?\d+|~)\S*$/), + regex: new RegExp(/^(?:-?(?:\d+(?:\.\d*)?|\.\d+)|~)\S*$/), regexError: "不是坐标格式", }, flag: { @@ -212,4 +217,4 @@ export const paramParser: Record }; const TOKEN_SPLIT_REGEX = /[^~\s)]+|~[^\s~]*/g; -const TOKEN_REGEX = /^(-?\d+|~)(?:(\+|-)?(\d+))?$/; +const TOKEN_REGEX = /^(-?(?:\d+(?:\.\d*)?|\.\d+)|~)(?:(\+|-)?(\d+(?:\.\d*)?|\.\d+))?$/; diff --git a/src/Command/parser/parser.ts b/src/Command/parser/parser.ts index 9d44b7d..0aa2330 100644 --- a/src/Command/parser/parser.ts +++ b/src/Command/parser/parser.ts @@ -44,10 +44,12 @@ export class CommandParser { if (command && command.isClientCommand) return chatOpe.skipsend; if (!command || (command.isAdmin && !isAdmin(player))) { if (!this.manager.testMode) - player.sendMessage([ - { text: "§c" }, - { translate: "commands.generic.unknown", with: [name] }, - ]); + player.sendMessage({ + rawtext: [ + { text: "§c" }, + { translate: "commands.generic.unknown", with: [name ?? ""] }, + ], + }); return chatOpe.cancel; } //命中,解析命令 diff --git a/src/Form/formManager.ts b/src/Form/formManager.ts index 0104b87..e0923e3 100644 --- a/src/Form/formManager.ts +++ b/src/Form/formManager.ts @@ -1,4 +1,5 @@ import { Player, ScriptEventCommandMessageAfterEvent, system } from "@minecraft/server"; +import { FormRejectError } from "@minecraft/server-ui"; import { LibConfig } from "../Config"; import { ScriptEventBus, intervalBus } from "../Event"; import { LibErrorMes, getPlayerById } from "../func"; @@ -78,7 +79,14 @@ export class FormManagerClass { return; } const builtForm = await form.builder(context.player, context.args); - const response = await builtForm.show(player); + let response; + try { + response = await builtForm.show(player); + } catch (err) { + // A rejected form is expected when a player closes it or leaves the game. + if (err instanceof FormRejectError) return; + throw err; + } await form.handler(response, context); this._handleShow(context); diff --git a/test/command/parser.test.ts b/test/command/parser.test.ts index 8536f7c..20eb2b0 100644 --- a/test/command/parser.test.ts +++ b/test/command/parser.test.ts @@ -1,7 +1,7 @@ // ===================================================== // Command Parser - Comprehensive Vitest Tests // ===================================================== -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // ─── Hoisted helpers: classes shared by mock and tests ─ const { MockPlayerClass, MockItemType, MockBlockType, MockEntityType, MockEntity } = @@ -171,6 +171,7 @@ import { paramParser } from "../../src/Command/parser/ParamTypes"; import { Command } from "../../src/Command/commandClass"; import { ParseInfo, ParseError, ParamObject } from "../../src/Command/interface"; import { NativeCommandParser } from "../../src/Command/parser/nativeParser"; +import { LibConfig } from "../../src/Config"; import { ItemType, GameMode, @@ -315,7 +316,7 @@ describe("paramParser - 参数级解析单元测试", () => { expect((result as ParseInfo).value).toBe("hello"); }); - it("应支持带引号的字符串", () => { + it("应去除字符串外层的引号", () => { const result = paramParser.string.parser(['"hello world"'], { player: mockPlayer as any, param: { name: "msg", type: "string" }, @@ -323,7 +324,7 @@ describe("paramParser - 参数级解析单元测试", () => { index: 0, }); expect(result).toBeInstanceOf(ParseInfo); - expect((result as ParseInfo).value).toBe('"hello world"'); + expect((result as ParseInfo).value).toBe("hello world"); }); }); @@ -439,6 +440,18 @@ describe("paramParser - 参数级解析单元测试", () => { expect(info.value).toEqual({ x: 105, y: 64, z: 190 }); }); + it("应解析带小数的绝对和相对坐标", () => { + const player = new MockPlayerClass({ location: { x: 100, y: 64, z: 200 } }); + const result = paramParser.position.parser(["1.5"] as any, { + player: player as any, + param: { name: "pos", type: "position" }, + paramStrings: ["1.5", "~0.25", "-2.75"], + index: 0, + }); + expect(result).toBeInstanceOf(ParseInfo); + expect((result as ParseInfo).value).toEqual({ x: 1.5, y: 64.25, z: -2.75 }); + }); + it("缺少坐标时返回 ParseError", () => { const result = paramParser.position.parser(["1"] as any, { player: new MockPlayerClass() as any, @@ -610,6 +623,10 @@ describe("CommandParser - 集成测试", () => { vi.mocked(world.getAllPlayers).mockReturnValue([mockPlayer]); }); + afterEach(() => { + LibConfig.isHost = false; + }); + function createSimpleCmd(name: string, params: ParamObject[]) { return new Command(name, "测试命令", false).addParamBranches([params]).setHandler(handlerSpy); } @@ -640,6 +657,12 @@ describe("CommandParser - 集成测试", () => { expect(handlerSpy).toHaveBeenCalledWith(mockPlayer, { msg: "hello" }); }); + it("应去除命令输入中 string 参数的外层引号", () => { + const cmd = createSimpleCmd("test", [{ name: "msg", type: "string" }]); + parser.parseSubCommand(cmd, ['"hello world"'], mockPlayer); + expect(handlerSpy).toHaveBeenCalledWith(mockPlayer, { msg: "hello world" }); + }); + it("应正确解析 enum 参数", () => { const cmd = createSimpleCmd("test", [ { name: "dir", type: "enum", enums: ["up", "down"] }, @@ -669,6 +692,17 @@ describe("CommandParser - 集成测试", () => { }); }); + it("未知命令应发送 RawMessage,而不是导致 ScriptAPI 类型转换异常", () => { + LibConfig.isHost = true; + parser.parseCommand("missing", mockPlayer); + expect(mockPlayer.sendMessage).toHaveBeenCalledWith({ + rawtext: [ + { text: "§c" }, + { translate: "commands.generic.unknown", with: ["missing"] }, + ], + }); + }); + // ─── 可选参数 ─────────────────────────────────── describe("可选参数 (optional)", () => { it("应正常解析带可选参数的完整输入", () => { @@ -1344,6 +1378,27 @@ describe("边缘案例", () => { }) as any); }); + it("Command.fromObject 不应改写调用方的参数定义", () => { + const definition: any = { + name: "immutable", + explain: "测试", + paramBranches: [ + { + name: "facing", + type: "flag", + branches: [[{ name: "pos", type: "position" }]], + }, + ], + }; + + Command.fromObject(definition); + + expect(definition.paramBranches[0].branches).toEqual([ + [{ name: "pos", type: "position" }], + ]); + expect(definition.paramBranches[0].subParams).toBeUndefined(); + }); + it("无参数命令应正确工作", () => { const cmd = new Command("test", "测试", false, handlerSpy); parser.parseSubCommand(cmd, [], mockPlayer); From 39cd0870268aa82cde517b3bea82a3077bebc4d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E9=98=B3x666?= <2408807389@qq.com> Date: Fri, 31 Jul 2026 17:25:43 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(Form):=20InputForm=20=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=20label/divider=20=E7=AD=89=E5=B1=95=E7=A4=BA=E5=AD=97?= =?UTF-8?q?=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基岩版 1.21+ 中 label/divider 会占据 formValues 槽位(值为 undefined), 而 InputForm 原先生成字段时只保留值字段做长度校验,导致使用 LabelField 时 values.length !== fields.length,提交报字段长度不匹配并重开表单。 改为保留全部字段与 formValues 槽位对齐,解析循环跳过非值字段。 Co-Authored-By: Claude --- src/Form/commonForm/InputForm.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Form/commonForm/InputForm.ts b/src/Form/commonForm/InputForm.ts index 221406c..ab7dc11 100644 --- a/src/Form/commonForm/InputForm.ts +++ b/src/Form/commonForm/InputForm.ts @@ -8,7 +8,7 @@ import { CommonFormData, TextType } from "./commonFormInterface"; import { BaseField, FieldParseError, ValueField } from "./InputFormFields"; export interface InputFormArgs extends contextArgs { - fields?: ValueField[]; + fields?: BaseField[]; } /** @@ -86,7 +86,8 @@ export class InputForm implements SAPIPr field.build(form, t); } - args.fields = fields.filter((f) => f.isValueField) as ValueField[]; + // 保留全部字段(含 label/divider 等展示字段),与 formValues 槽位对齐 + args.fields = fields; return form; } @@ -97,7 +98,7 @@ export class InputForm implements SAPIPr return; } - const fields = ctx.args.fields as ValueField[]; + const fields = ctx.args.fields as BaseField[]; const values = res.formValues; const t = translator.createPureFor(ctx.player); @@ -114,7 +115,9 @@ export class InputForm implements SAPIPr // 1. 字段级解析与基础验证 for (let i = 0; i < fields.length; i++) { - const field = fields[i]; + const field = fields[i] as ValueField; + // 展示字段(label/divider 等)占据 formValues 槽位但不产生输入值,跳过 + if (!field.isValueField) continue; const rawValue = values[i]; try { From 74d7d1a571433ed4ab1093c7a028d81a931cc6e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E9=98=B3x666?= <2408807389@qq.com> Date: Fri, 31 Jul 2026 17:42:47 +0800 Subject: [PATCH 3/3] =?UTF-8?q?test(Form):=20InputForm=20label/divider=20?= =?UTF-8?q?=E6=A7=BD=E4=BD=8D=E5=AF=B9=E9=BD=90=E6=B7=BB=E5=8A=A0=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖:保留全部字段与 formValues 槽位对齐、解析循环跳过展示字段、 必填校验按槽位定位、长度不匹配拦截、取消路径。修复 39cd087 无测试覆盖的遗留风险。 Co-Authored-By: Claude --- test/form/inputForm.test.ts | 176 ++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 test/form/inputForm.test.ts diff --git a/test/form/inputForm.test.ts b/test/form/inputForm.test.ts new file mode 100644 index 0000000..0e559d5 --- /dev/null +++ b/test/form/inputForm.test.ts @@ -0,0 +1,176 @@ +// ===================================================== +// InputForm - label/divider 槽位对齐与解析测试 +// ===================================================== +import { describe, it, expect, vi } from "vitest"; + +// ─── Mock @minecraft/server-ui ──────────────────────── +vi.mock("@minecraft/server-ui", () => { + class MockModalFormData { + title = vi.fn(() => this); + submitButton = vi.fn(() => this); + textField = vi.fn(() => this); + toggle = vi.fn(() => this); + dropdown = vi.fn(() => this); + slider = vi.fn(() => this); + label = vi.fn(() => this); + divider = vi.fn(() => this); + show = vi.fn(); + } + return { + ModalFormData: MockModalFormData, + ModalFormResponse: class {}, + ActionFormData: class {}, + MessageFormData: class {}, + FormRejectError: class extends Error {}, + }; +}); + +// ─── Mock @minecraft/server(InputForm 中仅类型使用,防御性 mock)─ +vi.mock("@minecraft/server", () => ({ + Player: class { + sendMessage = vi.fn(); + }, + RawMessage: Object, + PlayerPermissionLevel: { Operator: "operator", Member: "member" }, + system: { run: vi.fn(), runTimeout: vi.fn() }, +})); + +// ─── Mock Translate:defineLangTree 保留树形,translator 模拟按 zh_CN 翻译 ─ +vi.mock("../../src/Translate", () => ({ + defineLangTree: (tree: Record) => tree, + translator: { + createPureFor: vi.fn(() => (text: any, params?: Record) => { + if (typeof text === "string") return text; + const zh = text?.zh_CN ?? "translated"; + // 模拟 {index} 之类占位符替换,便于断言提示文本 + return params ? zh.replace(/\{(\w+)\}/g, (_: string, k: string) => String(params[k])) : zh; + }), + createUniversal: vi.fn(() => (text: any) => { + if (typeof text === "string") return text; + return text?.zh_CN ?? "translated"; + }), + }, +})); + +// ─── Imports (after mocks) ─────────────────────────── +import { InputForm } from "../../src/Form/commonForm/InputForm"; +import { + BaseField, + TextField, + ToggleField, + LabelField, + DividerField, +} from "../../src/Form/commonForm/InputFormFields"; + +/** 构造一个最小化的 formContext mock */ +function createContext(fields: BaseField[]) { + const player = { sendMessage: vi.fn() } as any; + const reopen = vi.fn(); + const ctx = { args: { fields }, player, reopen } as any; + return { ctx, player, reopen }; +} + +/** 构造一个非取消的 ModalFormResponse mock */ +function createResponse(formValues: unknown[]) { + return { canceled: false, formValues } as any; +} + +describe("InputForm - label/divider 槽位对齐", () => { + it("builder 保留全部字段(含 label/divider),与 formValues 槽位对齐", () => { + const fields = [new LabelField("标题"), new TextField("名字", "请输入").key("name")]; + const genFields = [new DividerField(), new ToggleField("启用").key("enabled")]; + const form = new InputForm({ fields, fieldsGenerator: () => genFields } as any); + + const args: Record = {}; + form.builder({} as any, args as any); + + expect(args.fields).toEqual([...fields, ...genFields]); + expect(args.fields).toHaveLength(4); + }); + + it("label/divider 占据 formValues 槽位(值为 undefined)时正常解析并提交", async () => { + const fields = [ + new LabelField("标题"), + new TextField("名字", "请输入").key("name"), + new DividerField(), + new ToggleField("启用").key("enabled"), + ]; + const { ctx, player, reopen } = createContext(fields); + const onSubmit = vi.fn(); + const form = new InputForm({ onSubmit } as any); + + // 1.21+ 中 label/divider 占用槽位,值为 undefined + const res = createResponse([undefined, "Alice", undefined, true]); + await form.handler(res, ctx); + + // 长度校验通过:label/divider 与 undefined 槽位一一对应 + expect(reopen).not.toHaveBeenCalled(); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith({ name: "Alice", enabled: true }, ctx); + expect(player.sendMessage).not.toHaveBeenCalled(); + }); + + it("无 label/divider 的普通表单不受影响", async () => { + const fields = [ + new TextField("名字", "请输入").key("name"), + new ToggleField("启用").key("enabled"), + ]; + const { ctx, reopen } = createContext(fields); + const onSubmit = vi.fn(); + const form = new InputForm({ onSubmit } as any); + + await form.handler(createResponse(["Bob", false]), ctx); + + expect(reopen).not.toHaveBeenCalled(); + expect(onSubmit).toHaveBeenCalledWith({ name: "Bob", enabled: false }, ctx); + }); + + it("字段校验失败时按槽位定位并重新打开表单", async () => { + const fields = [ + new LabelField("标题"), + new TextField("名字", "请输入").key("name"), // 必填 + new ToggleField("启用").key("enabled"), + ]; + const { ctx, player, reopen } = createContext(fields); + const onSubmit = vi.fn(); + const form = new InputForm({ onSubmit } as any); + + // name 为空字符串 → 必填校验失败;label 占位使 name 位于第 2 个槽位 + await form.handler(createResponse([undefined, "", false]), ctx); + + expect(onSubmit).not.toHaveBeenCalled(); + expect(reopen).toHaveBeenCalledTimes(1); + expect(player.sendMessage).toHaveBeenCalledWith( + "§c第 2 个输入项验证失败: 该项为必填项,不能为空" + ); + }); + + it("formValues 与字段数不一致时提示长度不匹配并重新打开", async () => { + const fields = [new TextField("名字", "请输入").key("name")]; + const { ctx, player, reopen } = createContext(fields); + const onSubmit = vi.fn(); + const form = new InputForm({ onSubmit } as any); + + // 值比字段多(例如未对齐的槽位),应被长度校验拦截 + await form.handler(createResponse(["A", "B"]), ctx); + + expect(onSubmit).not.toHaveBeenCalled(); + expect(reopen).toHaveBeenCalledTimes(1); + expect(player.sendMessage).toHaveBeenCalledWith( + "§c表单处理异常:提交的数据量与预期不符" + ); + }); + + it("表单被取消时调用 onCancel 且不触发提交", async () => { + const fields = [new TextField("名字", "请输入").key("name")]; + const { ctx } = createContext(fields); + const onSubmit = vi.fn(); + const onCancel = vi.fn(); + const form = new InputForm({ onSubmit, onCancel } as any); + + await form.handler({ canceled: true, formValues: ["x"] } as any, ctx); + + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onSubmit).not.toHaveBeenCalled(); + }); +});