diff --git a/src/utils/command/command-prompt.ts b/src/utils/command/command-prompt.ts index a60172a..1d0cf8b 100644 --- a/src/utils/command/command-prompt.ts +++ b/src/utils/command/command-prompt.ts @@ -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( + async function prompt( def: PromptDef, validator: PromptValidator, player?: string, ) { - const promise = new Promise((resolve, reject) => { - const call = { - def, - player, - validator, - - resolve, - reject, - promise, - } as PromptCall; - handleCall.execute(call); + let resolve: (res: TRes) => void; + let reject: (reason: string) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; }); - return promise; + const call = { + def, + player, + validator, + resolve: resolve!, + reject: reject!, + promise, + } as PromptCall; + return await handleCall.execute(call); } return { diff --git a/tests/utils/command-prompt.test.ts b/tests/utils/command-prompt.test.ts new file mode 100644 index 0000000..16c753a --- /dev/null +++ b/tests/utils/command-prompt.test.ts @@ -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" }); + }); + }); +});