Fix command prompt to properly return promise results

Add comprehensive tests for createPromptContext covering prompt,
tryCommit, cancel, and handleCall middleware.
This commit is contained in:
hyper 2026-04-23 14:44:24 +08:00
parent 32408c3cee
commit 59b99d2042
2 changed files with 236 additions and 15 deletions

View File

@ -32,12 +32,12 @@ export function createPromptContext() {
const key = call.player ?? "global";
const existing = map.get(key);
if (existing) {
existing.reject("Prompt cancelled");
existing.reject("Prompt overriden");
}
map.set(key, call);
try {
await call.promise;
return await call.promise;
} finally {
if (map.get(key) === call) map.delete(key);
}
@ -64,24 +64,26 @@ export function createPromptContext() {
map.get(player)?.reject(reason);
}
function prompt<TArgs extends any[], TRes>(
async function prompt<TArgs extends any[], TRes>(
def: PromptDef<TArgs>,
validator: PromptValidator<TRes, TArgs>,
player?: string,
) {
const promise = new Promise((resolve, reject) => {
let resolve: (res: TRes) => void;
let reject: (reason: string) => void;
const promise = new Promise<TRes>((res, rej) => {
resolve = res;
reject = rej;
});
const call = {
def,
player,
validator,
resolve,
reject,
resolve: resolve!,
reject: reject!,
promise,
} as PromptCall<unknown[], unknown>;
handleCall.execute(call);
});
return promise;
return await handleCall.execute(call);
}
return {

View File

@ -0,0 +1,219 @@
import { describe, it, expect } from "vitest";
import { createPromptContext } from "@/utils/command/command-prompt";
import type { CommandSchema } from "@/utils/command/types";
const mockSchema: CommandSchema = {
name: "test",
params: [{ name: "value", required: true, variadic: false }],
options: {},
flags: {},
};
describe("createPromptContext", () => {
describe("prompt", () => {
it("should store prompt and return promise", async () => {
const ctx = createPromptContext();
const validator = (value: string) => value;
const promise = ctx.prompt({ schema: mockSchema }, validator);
await new Promise((r) => setTimeout(r, 0));
const result = ctx.tryCommit("global", "hello");
expect(result).toEqual({ ok: true });
await expect(promise).resolves.toBe("hello");
});
it("should associate prompt with specific player", async () => {
const ctx = createPromptContext();
const validator = (value: string) => value;
const promise = ctx.prompt({ schema: mockSchema }, validator, "player1");
await new Promise((r) => setTimeout(r, 0));
const result = ctx.tryCommit("player1", "hello");
expect(result).toEqual({ ok: true });
await expect(promise).resolves.toBe("hello");
});
it("should cancel existing prompt when new prompt starts for same player", async () => {
const ctx = createPromptContext();
const validator = (value: string) => value;
const promise1 = ctx.prompt({ schema: mockSchema }, validator, "player1");
await new Promise((r) => setTimeout(r, 0));
const promise2 = ctx.prompt({ schema: mockSchema }, validator, "player1");
// Resolve promise2 first, then both will settle
ctx.tryCommit("player1", "test");
const [result1, result2] = await Promise.allSettled([promise1, promise2]);
expect(result1.status).toBe("rejected");
expect((result1 as PromiseRejectedResult).reason).toBe(
"Prompt overriden",
);
expect(result2.status).toBe("fulfilled");
});
it("should allow different players to have independent prompts", async () => {
const ctx = createPromptContext();
const validator = (value: string) => value;
const promise1 = ctx.prompt({ schema: mockSchema }, validator, "player1");
const promise2 = ctx.prompt({ schema: mockSchema }, validator, "player2");
await new Promise((r) => setTimeout(r, 0));
ctx.tryCommit("player1", "hello");
ctx.tryCommit("player2", "world");
await expect(promise1).resolves.toBe("hello");
await expect(promise2).resolves.toBe("world");
});
});
describe("tryCommit", () => {
it("should return ok:true when prompt exists and validation passes", () => {
const ctx = createPromptContext();
const validator = (value: string) => value.toUpperCase();
ctx.prompt({ schema: mockSchema }, validator, "player1");
const result = ctx.tryCommit("player1", "hello");
expect(result).toEqual({ ok: true });
});
it("should return ok:false with reason when no prompt exists", () => {
const ctx = createPromptContext();
const result = ctx.tryCommit("nonexistent", "hello");
expect(result).toEqual({ ok: false, reason: "No Prompt" });
});
it("should return ok:false when validator throws string", () => {
const ctx = createPromptContext();
const validator = (value: string) => {
if (value.length < 3) throw "Value too short";
return value;
};
ctx.prompt({ schema: mockSchema }, validator, "player1");
const result = ctx.tryCommit("player1", "ab");
expect(result).toEqual({ ok: false, reason: "Value too short" });
});
it("should throw when validator throws non-string error", () => {
const ctx = createPromptContext();
const validator = () => {
throw new Error("unexpected");
};
ctx.prompt({ schema: mockSchema }, validator, "player1");
expect(() => ctx.tryCommit("player1", "test")).toThrow("unexpected");
});
it("should pass validator return value to promise resolver", async () => {
const ctx = createPromptContext();
const validator = (a: number, b: number) => a + b;
const promise = ctx.prompt({ schema: mockSchema }, validator, "player1");
await new Promise((r) => setTimeout(r, 0));
ctx.tryCommit("player1", 2, 3);
await expect(promise).resolves.toBe(5);
});
});
describe("cancel", () => {
it("should reject promise with default message", async () => {
const ctx = createPromptContext();
const validator = (value: string) => value;
const promise = ctx.prompt({ schema: mockSchema }, validator, "player1");
await new Promise((r) => setTimeout(r, 0));
ctx.cancel("player1");
const result = await Promise.allSettled([promise]);
expect(result[0].status).toBe("rejected");
expect((result[0] as PromiseRejectedResult).reason).toBe(
"Prompt Cancelled",
);
});
it("should reject promise with custom reason", async () => {
const ctx = createPromptContext();
const validator = (value: string) => value;
const promise = ctx.prompt({ schema: mockSchema }, validator, "player1");
await new Promise((r) => setTimeout(r, 0));
ctx.cancel("player1", "Custom cancel reason");
const result = await Promise.allSettled([promise]);
expect(result[0].status).toBe("rejected");
expect((result[0] as PromiseRejectedResult).reason).toBe(
"Custom cancel reason",
);
});
it("should do nothing when no prompt exists for player", () => {
const ctx = createPromptContext();
ctx.cancel("nonexistent");
});
it("should only cancel prompt for specific player", async () => {
const ctx = createPromptContext();
const validator = (value: string) => value;
const promise1 = ctx.prompt({ schema: mockSchema }, validator, "player1");
const promise2 = ctx.prompt({ schema: mockSchema }, validator, "player2");
await new Promise((r) => setTimeout(r, 0));
ctx.cancel("player1");
ctx.tryCommit("player2", "test");
const [result1, result2] = await Promise.allSettled([promise1, promise2]);
expect(result1.status).toBe("rejected");
expect((result1 as PromiseRejectedResult).reason).toBe(
"Prompt Cancelled",
);
expect(result2.status).toBe("fulfilled");
});
});
describe("handleCall", () => {
it("should be a middleware chain that can be extended", async () => {
const ctx = createPromptContext();
const validator = (value: string) => value;
let middlewareCalled = false;
ctx.handleCall.use(async (call, next) => {
middlewareCalled = true;
return next();
});
const promise = ctx.prompt({ schema: mockSchema }, validator);
await new Promise((r) => setTimeout(r, 0));
expect(middlewareCalled).toBe(true);
ctx.tryCommit("global", "test");
await expect(promise).resolves.toBe("test");
});
it("should remove prompt from map after resolution", async () => {
const ctx = createPromptContext();
const validator = (value: string) => value;
const promise = ctx.prompt({ schema: mockSchema }, validator);
await new Promise((r) => setTimeout(r, 0));
ctx.tryCommit("global", "test");
await promise;
const result = ctx.tryCommit("global", "another");
expect(result).toEqual({ ok: false, reason: "No Prompt" });
});
});
});