chore: add tests

This commit is contained in:
2026-04-04 00:48:44 +08:00
parent 3395a315a6
commit 32509d7812
9 changed files with 1076 additions and 202 deletions
+5 -2
View File
@@ -13,7 +13,9 @@
},
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"peerDependencies": {
"@preact/signals-core": "^1.5.1",
@@ -29,6 +31,7 @@
"phaser": "^3.80.1",
"preact": "^10.19.3",
"tsup": "^8.0.2",
"typescript": "^5.3.3"
"typescript": "^5.3.3",
"vitest": "^3.2.4"
}
}
@@ -0,0 +1,286 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { InputMapper, createInputMapper, type InputMapperOptions } from './InputMapper.js';
import Phaser from 'phaser';
// Mock Phaser
function createMockScene() {
const inputEvents = new Map<string, Function[]>();
const mockScene = {
input: {
on: vi.fn((event: string, callback: Function) => {
if (!inputEvents.has(event)) {
inputEvents.set(event, []);
}
inputEvents.get(event)!.push(callback);
}),
off: vi.fn((event: string, callback: Function) => {
const handlers = inputEvents.get(event);
if (handlers) {
const index = handlers.indexOf(callback);
if (index > -1) {
handlers.splice(index, 1);
}
}
}),
_emit: (event: string, ...args: any[]) => {
const handlers = inputEvents.get(event);
if (handlers) {
handlers.forEach(handler => handler(...args));
}
},
},
} as unknown as Phaser.Scene;
return {
mockScene,
inputEvents,
emitPointerDown: (pointer: Partial<Phaser.Input.Pointer>) => {
inputEvents.get('pointerdown')?.forEach(handler => handler(pointer));
},
};
}
function createMockGameObject() {
const events = new Map<string, Function[]>();
const mockObj = {
setInteractive: vi.fn(),
on: vi.fn((event: string, handler: Function) => {
if (!events.has(event)) {
events.set(event, []);
}
events.get(event)!.push(handler);
}),
off: vi.fn((event: string, handler: Function) => {
const handlers = events.get(event);
if (handlers) {
const index = handlers.indexOf(handler);
if (index > -1) {
handlers.splice(index, 1);
}
}
}),
_emit: (event: string, ...args: any[]) => {
events.get(event)?.forEach(handler => handler(...args));
},
} as unknown as Phaser.GameObjects.GameObject;
return {
mockObj,
emitPointerDown: () => events.get('pointerdown')?.forEach(handler => handler()),
};
}
describe('InputMapper', () => {
let options: InputMapperOptions;
let onSubmit: ReturnType<typeof vi.fn>;
beforeEach(() => {
onSubmit = vi.fn();
options = {
onSubmit,
};
});
describe('constructor', () => {
it('should create instance with options', () => {
const { mockScene } = createMockScene();
const mapper = new InputMapper(mockScene, options);
expect(mapper).toBeDefined();
});
});
describe('createInputMapper', () => {
it('should create instance via factory', () => {
const { mockScene } = createMockScene();
const mapper = createInputMapper(mockScene, options);
expect(mapper).toBeDefined();
});
});
describe('mapGridClick', () => {
it('should register pointerdown listener', () => {
const { mockScene, inputEvents } = createMockScene();
const mapper = new InputMapper(mockScene, options);
mapper.mapGridClick(
{ x: 50, y: 50 },
{ x: 100, y: 100 },
{ cols: 3, rows: 3 },
vi.fn(() => null),
);
expect(mockScene.input.on).toHaveBeenCalledWith('pointerdown', expect.any(Function));
});
it('should call onSubmit with command from onCellClick', () => {
const { mockScene, emitPointerDown } = createMockScene();
const mapper = new InputMapper(mockScene, options);
const onCellClick = vi.fn((col, row) => `place ${col} ${row}`);
mapper.mapGridClick(
{ x: 50, y: 50 },
{ x: 100, y: 100 },
{ cols: 3, rows: 3 },
onCellClick,
);
// Simulate pointer at position (120, 130)
// Local: (120-100, 130-100) = (20, 30)
// Cell: (floor(20/50), floor(30/50)) = (0, 0)
emitPointerDown({ x: 120, y: 130 });
expect(onCellClick).toHaveBeenCalledWith(0, 0);
expect(onSubmit).toHaveBeenCalledWith('place 0 0');
});
it('should ignore clicks outside grid', () => {
const { mockScene, emitPointerDown } = createMockScene();
const mapper = new InputMapper(mockScene, options);
const onCellClick = vi.fn(() => 'click');
mapper.mapGridClick(
{ x: 50, y: 50 },
{ x: 100, y: 100 },
{ cols: 3, rows: 3 },
onCellClick,
);
// Click outside grid (negative local coordinates)
emitPointerDown({ x: 50, y: 50 });
expect(onCellClick).not.toHaveBeenCalled();
expect(onSubmit).not.toHaveBeenCalled();
});
it('should ignore clicks outside grid bounds', () => {
const { mockScene, emitPointerDown } = createMockScene();
const mapper = new InputMapper(mockScene, options);
const onCellClick = vi.fn(() => 'click');
mapper.mapGridClick(
{ x: 50, y: 50 },
{ x: 100, y: 100 },
{ cols: 3, rows: 3 },
onCellClick,
);
// Click at col 5 (beyond cols 0-2)
// Local X: 300-100 = 200, col = floor(200/50) = 4
emitPointerDown({ x: 300, y: 120 });
expect(onCellClick).not.toHaveBeenCalled();
});
it('should not call onSubmit when onCellClick returns null', () => {
const { mockScene, emitPointerDown } = createMockScene();
const mapper = new InputMapper(mockScene, options);
const onCellClick = vi.fn(() => null);
mapper.mapGridClick(
{ x: 50, y: 50 },
{ x: 100, y: 100 },
{ cols: 3, rows: 3 },
onCellClick,
);
emitPointerDown({ x: 120, y: 130 });
expect(onCellClick).toHaveBeenCalled();
expect(onSubmit).not.toHaveBeenCalled();
});
});
describe('mapObjectClick', () => {
it('should set game objects as interactive', () => {
const { mockScene } = createMockScene();
const { mockObj: obj1 } = createMockGameObject();
const { mockObj: obj2 } = createMockGameObject();
const mapper = new InputMapper(mockScene, options);
mapper.mapObjectClick([obj1, obj2], vi.fn(() => null));
expect(obj1.setInteractive).toHaveBeenCalledWith({ useHandCursor: true });
expect(obj2.setInteractive).toHaveBeenCalledWith({ useHandCursor: true });
});
it('should call onSubmit when object is clicked', () => {
const { mockScene } = createMockScene();
const { mockObj, emitPointerDown } = createMockGameObject();
const onClick = vi.fn((obj) => `clicked ${obj}`);
const mapper = new InputMapper(mockScene, options);
mapper.mapObjectClick([mockObj], onClick);
emitPointerDown();
expect(onClick).toHaveBeenCalledWith(mockObj);
expect(onSubmit).toHaveBeenCalledWith(`clicked ${mockObj}`);
});
it('should skip objects without setInteractive', () => {
const { mockScene } = createMockScene();
const nonInteractiveObj = {} as Phaser.GameObjects.GameObject;
const mapper = new InputMapper(mockScene, options);
// Should not throw
expect(() => {
mapper.mapObjectClick([nonInteractiveObj], vi.fn());
}).not.toThrow();
});
it('should not call onSubmit when onClick returns null', () => {
const { mockScene } = createMockScene();
const { mockObj, emitPointerDown } = createMockGameObject();
const onClick = vi.fn(() => null);
const mapper = new InputMapper(mockScene, options);
mapper.mapObjectClick([mockObj], onClick);
emitPointerDown();
expect(onClick).toHaveBeenCalled();
expect(onSubmit).not.toHaveBeenCalled();
});
});
describe('destroy', () => {
it('should remove pointerdown listener from mapGridClick', () => {
const { mockScene } = createMockScene();
const mapper = new InputMapper(mockScene, options);
mapper.mapGridClick(
{ x: 50, y: 50 },
{ x: 100, y: 100 },
{ cols: 3, rows: 3 },
vi.fn(() => null),
);
mapper.destroy();
expect(mockScene.input.off).toHaveBeenCalledWith('pointerdown', expect.any(Function));
});
it('should remove object listeners from mapObjectClick', () => {
const { mockScene } = createMockScene();
const { mockObj } = createMockGameObject();
const mapper = new InputMapper(mockScene, options);
mapper.mapObjectClick([mockObj], vi.fn(() => null));
mapper.destroy();
expect(mockObj.off).toHaveBeenCalledWith('pointerdown', expect.any(Function));
});
it('should be safe to call without any mappings', () => {
const { mockScene } = createMockScene();
const mapper = new InputMapper(mockScene, options);
expect(() => mapper.destroy()).not.toThrow();
});
});
});
@@ -0,0 +1,93 @@
import Phaser from 'phaser';
export interface InputMapperOptions {
onSubmit: (input: string) => string | null;
}
export class InputMapper {
private scene: Phaser.Scene;
private onSubmit: (input: string) => string | null;
private pointerDownCallback: ((pointer: Phaser.Input.Pointer) => void) | null = null;
/** Track interactive objects registered via mapObjectClick for cleanup */
private trackedObjects: Array<{
obj: Phaser.GameObjects.GameObject;
handler: () => void;
}> = [];
constructor(scene: Phaser.Scene, options: InputMapperOptions) {
this.scene = scene;
this.onSubmit = options.onSubmit;
}
mapGridClick(
cellSize: { x: number; y: number },
offset: { x: number; y: number },
gridDimensions: { cols: number; rows: number },
onCellClick: (col: number, row: number) => string | null,
): void {
const pointerDown = (pointer: Phaser.Input.Pointer) => {
const localX = pointer.x - offset.x;
const localY = pointer.y - offset.y;
if (localX < 0 || localY < 0) return;
const col = Math.floor(localX / cellSize.x);
const row = Math.floor(localY / cellSize.y);
if (col < 0 || col >= gridDimensions.cols || row < 0 || row >= gridDimensions.rows) return;
const cmd = onCellClick(col, row);
if (cmd) {
this.onSubmit(cmd);
}
};
this.pointerDownCallback = pointerDown;
this.scene.input.on('pointerdown', pointerDown);
}
mapObjectClick<T>(
gameObjects: Phaser.GameObjects.GameObject[],
onClick: (obj: T) => string | null,
): void {
for (const obj of gameObjects) {
if ('setInteractive' in obj && typeof (obj as any).setInteractive === 'function') {
const interactiveObj = obj as any;
interactiveObj.setInteractive({ useHandCursor: true });
const handler = () => {
const cmd = onClick(obj as unknown as T);
if (cmd) {
this.onSubmit(cmd);
}
};
interactiveObj.on('pointerdown', handler);
this.trackedObjects.push({ obj, handler });
}
}
}
destroy(): void {
// Remove global pointerdown listener from mapGridClick
if (this.pointerDownCallback) {
this.scene.input.off('pointerdown', this.pointerDownCallback);
this.pointerDownCallback = null;
}
// Remove per-object pointerdown listeners from mapObjectClick
for (const { obj, handler } of this.trackedObjects) {
if ('off' in obj && typeof (obj as any).off === 'function') {
(obj as any).off('pointerdown', handler);
}
}
this.trackedObjects = [];
}
}
export function createInputMapper(
scene: Phaser.Scene,
options: InputMapperOptions,
): InputMapper {
return new InputMapper(scene, options);
}
@@ -0,0 +1,270 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { PromptHandler, type PromptHandlerOptions } from './PromptHandler.js';
import type { PromptEvent } from 'boardgame-core';
// 内联 AsyncQueue 实现用于测试(避免导入 boardgame-core 内部模块)
class AsyncQueue<T> {
private items: T[] = [];
private resolvers: ((value: T) => void)[] = [];
push(item: T): void {
if (this.resolvers.length > 0) {
const resolve = this.resolvers.shift()!;
resolve(item);
} else {
this.items.push(item);
}
}
pushAll(items: Iterable<T>): void {
for (const item of items) {
this.push(item);
}
}
async pop(): Promise<T> {
if (this.items.length > 0) {
return this.items.shift()!;
}
return new Promise<T>((resolve) => {
this.resolvers.push(resolve);
});
}
get length(): number {
return this.items.length - this.resolvers.length;
}
}
// Mock types
interface MockCommandContext {
commands: {
promptQueue: AsyncQueue<PromptEvent>;
};
}
function createMockPromptEvent(
schema: any = { name: 'test', params: [] },
tryCommitImpl?: (input: string | any) => string | null,
): PromptEvent {
return {
schema,
tryCommit: tryCommitImpl ?? vi.fn(() => null),
cancel: vi.fn(),
};
}
function createMockCommands(): MockCommandContext['commands'] {
return {
promptQueue: new AsyncQueue<PromptEvent>(),
};
}
describe('PromptHandler', () => {
let options: PromptHandlerOptions;
let mockCommands: MockCommandContext['commands'];
let onPrompt: ReturnType<typeof vi.fn>;
let onCancel: ReturnType<typeof vi.fn>;
beforeEach(() => {
onPrompt = vi.fn();
onCancel = vi.fn();
mockCommands = createMockCommands();
options = {
commands: mockCommands as any,
onPrompt,
onCancel,
};
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('constructor', () => {
it('should create instance with options', () => {
const handler = new PromptHandler(options);
expect(handler).toBeDefined();
});
});
describe('createPromptHandler', () => {
it('should create instance via factory', async () => {
const { createPromptHandler } = await import('./PromptHandler.js');
const handler = createPromptHandler(options);
expect(handler).toBeDefined();
});
});
describe('start', () => {
it('should listen for prompts when started', async () => {
const handler = new PromptHandler(options);
handler.start();
const mockEvent = createMockPromptEvent();
mockCommands.promptQueue.push(mockEvent);
// Wait for async operation
await new Promise(resolve => setTimeout(resolve, 10));
expect(onPrompt).toHaveBeenCalledWith(mockEvent);
});
it('should not listen when not started', async () => {
const handler = new PromptHandler(options);
// Don't call start()
const mockEvent = createMockPromptEvent();
mockCommands.promptQueue.push(mockEvent);
await new Promise(resolve => setTimeout(resolve, 10));
expect(onPrompt).not.toHaveBeenCalled();
});
});
describe('submit', () => {
it('should submit to active prompt successfully', async () => {
const handler = new PromptHandler(options);
handler.start();
const mockEvent = createMockPromptEvent();
mockCommands.promptQueue.push(mockEvent);
await new Promise(resolve => setTimeout(resolve, 10));
const result = handler.submit('test input');
expect(mockEvent.tryCommit).toHaveBeenCalledWith('test input');
expect(result).toBeNull();
});
it('should return error when submit fails', async () => {
const mockEvent = createMockPromptEvent(
{ name: 'test', params: [] },
vi.fn(() => 'Invalid input'),
);
const handler = new PromptHandler(options);
handler.start();
mockCommands.promptQueue.push(mockEvent);
await new Promise(resolve => setTimeout(resolve, 10));
const result = handler.submit('bad input');
expect(result).toBe('Invalid input');
expect(onPrompt).toHaveBeenCalledWith(mockEvent);
});
it('should store input as pending when no active prompt', () => {
const handler = new PromptHandler(options);
// Don't start, so no active prompt
const result = handler.submit('pending input');
expect(result).toBeNull();
});
it('should auto-submit pending input when new prompt arrives', async () => {
const tryCommitFn = vi.fn(() => null);
const mockEvent = createMockPromptEvent(
{ name: 'test', params: [] },
tryCommitFn,
);
const handler = new PromptHandler(options);
// Submit before starting (pending input)
handler.submit('pending input');
// Start and push event
handler.start();
mockCommands.promptQueue.push(mockEvent);
await new Promise(resolve => setTimeout(resolve, 10));
expect(tryCommitFn).toHaveBeenCalledWith('pending input');
expect(onPrompt).not.toHaveBeenCalled(); // Auto-submitted, no UI needed
});
});
describe('cancel', () => {
it('should cancel active prompt', async () => {
const mockEvent = createMockPromptEvent();
const handler = new PromptHandler(options);
handler.start();
mockCommands.promptQueue.push(mockEvent);
await new Promise(resolve => setTimeout(resolve, 10));
handler.cancel('user cancelled');
expect(mockEvent.cancel).toHaveBeenCalledWith('user cancelled');
expect(onCancel).toHaveBeenCalledWith('user cancelled');
});
it('should call onCancel without active prompt', () => {
const handler = new PromptHandler(options);
handler.cancel('no prompt');
expect(onCancel).toHaveBeenCalledWith('no prompt');
});
});
describe('stop', () => {
it('should stop listening for new prompts after stop', async () => {
const handler = new PromptHandler(options);
handler.start();
// Push first event, it should be handled
const mockEvent1 = createMockPromptEvent();
mockCommands.promptQueue.push(mockEvent1);
await new Promise(resolve => setTimeout(resolve, 10));
expect(onPrompt).toHaveBeenCalledWith(mockEvent1);
onPrompt.mockClear();
// Now stop
handler.stop();
// Push another event, should not be handled
const mockEvent2 = createMockPromptEvent();
mockCommands.promptQueue.push(mockEvent2);
await new Promise(resolve => setTimeout(resolve, 10));
// The second event should not trigger onPrompt
// (Note: due to async nature, we check it wasn't called AFTER stop)
expect(onPrompt).not.toHaveBeenCalledWith(mockEvent2);
});
it('should cancel active prompt on stop', async () => {
const mockEvent = createMockPromptEvent();
const handler = new PromptHandler(options);
handler.start();
mockCommands.promptQueue.push(mockEvent);
await new Promise(resolve => setTimeout(resolve, 10));
handler.stop();
expect(mockEvent.cancel).toHaveBeenCalledWith('Handler stopped');
});
});
describe('destroy', () => {
it('should call stop', async () => {
const mockEvent = createMockPromptEvent();
const handler = new PromptHandler(options);
handler.start();
mockCommands.promptQueue.push(mockEvent);
await new Promise(resolve => setTimeout(resolve, 10));
handler.destroy();
expect(mockEvent.cancel).toHaveBeenCalledWith('Handler stopped');
});
});
});
@@ -0,0 +1,102 @@
import type { IGameContext, PromptEvent } from 'boardgame-core';
export interface PromptHandlerOptions {
commands: IGameContext<any>['commands'];
onPrompt: (prompt: PromptEvent) => void;
onCancel: (reason?: string) => void;
}
export class PromptHandler {
private commands: IGameContext<any>['commands'];
private onPrompt: (prompt: PromptEvent) => void;
private onCancel: (reason?: string) => void;
private activePrompt: PromptEvent | null = null;
private isListening = false;
private pendingInput: string | null = null;
constructor(options: PromptHandlerOptions) {
this.commands = options.commands;
this.onPrompt = options.onPrompt;
this.onCancel = options.onCancel;
}
start(): void {
this.isListening = true;
this.listenForPrompt();
}
private listenForPrompt(): void {
if (!this.isListening) return;
this.commands.promptQueue.pop()
.then((promptEvent) => {
this.activePrompt = promptEvent;
// 如果有等待的输入,自动提交
if (this.pendingInput) {
const input = this.pendingInput;
this.pendingInput = null;
const error = this.activePrompt.tryCommit(input);
if (error === null) {
this.activePrompt = null;
this.listenForPrompt();
} else {
// 提交失败,把 prompt 交给 UI 显示错误
this.onPrompt(promptEvent);
}
return;
}
this.onPrompt(promptEvent);
})
.catch((reason) => {
this.activePrompt = null;
this.onCancel(reason?.message || 'Cancelled');
});
}
/**
* Submit an input string to the current prompt.
* @returns null on success (input accepted), error string on validation failure
*/
submit(input: string): string | null {
if (!this.activePrompt) {
// 没有活跃 prompt,保存为待处理输入
this.pendingInput = input;
return null;
}
const error = this.activePrompt.tryCommit(input);
if (error === null) {
this.activePrompt = null;
this.listenForPrompt();
}
return error;
}
cancel(reason?: string): void {
if (this.activePrompt) {
this.activePrompt.cancel(reason);
this.activePrompt = null;
}
this.onCancel(reason);
}
stop(): void {
this.isListening = false;
if (this.activePrompt) {
this.activePrompt.cancel('Handler stopped');
this.activePrompt = null;
}
}
destroy(): void {
this.stop();
}
}
export function createPromptHandler(
options: PromptHandlerOptions,
): PromptHandler {
return new PromptHandler(options);
}
+10 -198
View File
@@ -1,199 +1,11 @@
import Phaser from 'phaser';
import type { IGameContext, PromptEvent } from 'boardgame-core';
export {
InputMapper,
createInputMapper,
type InputMapperOptions,
} from './InputMapper.js';
// ─── InputMapper ───────────────────────────────────────────────────────────
export interface InputMapperOptions {
onSubmit: (input: string) => string | null;
}
export class InputMapper {
private scene: Phaser.Scene;
private onSubmit: (input: string) => string | null;
private pointerDownCallback: ((pointer: Phaser.Input.Pointer) => void) | null = null;
/** Track interactive objects registered via mapObjectClick for cleanup */
private trackedObjects: Array<{
obj: Phaser.GameObjects.GameObject;
handler: () => void;
}> = [];
constructor(scene: Phaser.Scene, options: InputMapperOptions) {
this.scene = scene;
this.onSubmit = options.onSubmit;
}
mapGridClick(
cellSize: { x: number; y: number },
offset: { x: number; y: number },
gridDimensions: { cols: number; rows: number },
onCellClick: (col: number, row: number) => string | null,
): void {
const pointerDown = (pointer: Phaser.Input.Pointer) => {
const localX = pointer.x - offset.x;
const localY = pointer.y - offset.y;
if (localX < 0 || localY < 0) return;
const col = Math.floor(localX / cellSize.x);
const row = Math.floor(localY / cellSize.y);
if (col < 0 || col >= gridDimensions.cols || row < 0 || row >= gridDimensions.rows) return;
const cmd = onCellClick(col, row);
if (cmd) {
this.onSubmit(cmd);
}
};
this.pointerDownCallback = pointerDown;
this.scene.input.on('pointerdown', pointerDown);
}
mapObjectClick<T>(
gameObjects: Phaser.GameObjects.GameObject[],
onClick: (obj: T) => string | null,
): void {
for (const obj of gameObjects) {
if ('setInteractive' in obj && typeof (obj as any).setInteractive === 'function') {
const interactiveObj = obj as any;
interactiveObj.setInteractive({ useHandCursor: true });
const handler = () => {
const cmd = onClick(obj as unknown as T);
if (cmd) {
this.onSubmit(cmd);
}
};
interactiveObj.on('pointerdown', handler);
this.trackedObjects.push({ obj, handler });
}
}
}
destroy(): void {
// Remove global pointerdown listener from mapGridClick
if (this.pointerDownCallback) {
this.scene.input.off('pointerdown', this.pointerDownCallback);
this.pointerDownCallback = null;
}
// Remove per-object pointerdown listeners from mapObjectClick
for (const { obj, handler } of this.trackedObjects) {
if ('off' in obj && typeof (obj as any).off === 'function') {
(obj as any).off('pointerdown', handler);
}
}
this.trackedObjects = [];
}
}
export function createInputMapper(
scene: Phaser.Scene,
options: InputMapperOptions,
): InputMapper {
return new InputMapper(scene, options);
}
// ─── PromptHandler ─────────────────────────────────────────────────────────
export interface PromptHandlerOptions {
commands: IGameContext<any>['commands'];
onPrompt: (prompt: PromptEvent) => void;
onCancel: (reason?: string) => void;
}
export class PromptHandler {
private commands: IGameContext<any>['commands'];
private onPrompt: (prompt: PromptEvent) => void;
private onCancel: (reason?: string) => void;
private activePrompt: PromptEvent | null = null;
private isListening = false;
private pendingInput: string | null = null;
constructor(options: PromptHandlerOptions) {
this.commands = options.commands;
this.onPrompt = options.onPrompt;
this.onCancel = options.onCancel;
}
start(): void {
this.isListening = true;
this.listenForPrompt();
}
private listenForPrompt(): void {
if (!this.isListening) return;
this.commands.promptQueue.pop()
.then((promptEvent) => {
this.activePrompt = promptEvent;
// 如果有等待的输入,自动提交
if (this.pendingInput) {
const input = this.pendingInput;
this.pendingInput = null;
const error = this.activePrompt.tryCommit(input);
if (error === null) {
this.activePrompt = null;
this.listenForPrompt();
} else {
// 提交失败,把 prompt 交给 UI 显示错误
this.onPrompt(promptEvent);
}
return;
}
this.onPrompt(promptEvent);
})
.catch((reason) => {
this.activePrompt = null;
this.onCancel(reason?.message || 'Cancelled');
});
}
/**
* Submit an input string to the current prompt.
* @returns null on success (input accepted), error string on validation failure
*/
submit(input: string): string | null {
if (!this.activePrompt) {
// 没有活跃 prompt,保存为待处理输入
this.pendingInput = input;
return null;
}
const error = this.activePrompt.tryCommit(input);
if (error === null) {
this.activePrompt = null;
this.listenForPrompt();
}
return error;
}
cancel(reason?: string): void {
if (this.activePrompt) {
this.activePrompt.cancel(reason);
this.activePrompt = null;
}
this.onCancel(reason);
}
stop(): void {
this.isListening = false;
if (this.activePrompt) {
this.activePrompt.cancel('Handler stopped');
this.activePrompt = null;
}
}
destroy(): void {
this.stop();
}
}
export function createPromptHandler(
options: PromptHandlerOptions,
): PromptHandler {
return new PromptHandler(options);
}
export {
PromptHandler,
createPromptHandler,
type PromptHandlerOptions,
} from './PromptHandler.js';
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
},
});