Browse Source

fix(chase): guard seed input lifecycle and add setup reset path

Made-with: Cursor
master
npmrun 2 weeks ago
parent
commit
9d2d061bce
  1. 59
      src/stages/page_chase.ts
  2. 86
      tests/stages/page_chase.test.ts

59
src/stages/page_chase.ts

@ -2,15 +2,23 @@ import { BaseScene } from "@/scene/BaseScene";
import { SceneType } from "@/enums/SceneType";
import { ChaseGameModel } from "@/game/chase/model";
import type { Difficulty, NodeId } from "@/game/chase/types";
import { Container, Graphics, Text, TextStyle } from "pixi.js";
import { Container, Graphics, Text, TextStyle, type Ticker } from "pixi.js";
const NODE_RADIUS = 18;
const NODE_GAP = 58;
type ChaseModelLike = Pick<ChaseGameModel, "getState" | "moveThief" | "newRound">;
type DocumentLike = {
body: {
appendChild: (node: HTMLInputElement) => void;
contains: (node: HTMLInputElement) => boolean;
};
createElement: (tag: string) => HTMLInputElement;
};
type ChaseSceneOptions = {
model?: ChaseModelLike;
modelFactory?: () => ChaseModelLike;
documentRef?: DocumentLike;
};
export default class ChaseScene extends BaseScene {
@ -28,14 +36,17 @@ export default class ChaseScene extends BaseScene {
};
private startButton = new Graphics();
private seedInputElement?: HTMLInputElement;
private readonly documentRef?: DocumentLike;
private selectedDifficulty: Difficulty = "normal";
private seedInput = "";
private difficultyLocked = false;
private inputAttached = false;
constructor(options: ChaseSceneOptions = {}) {
super("chase", SceneType.Normal);
this.model = options.model;
this.modelFactory = options.modelFactory;
this.documentRef = options.documentRef;
}
protected async onSceneLayout(): Promise<void> {
@ -85,13 +96,17 @@ export default class ChaseScene extends BaseScene {
}
protected onSceneEnter(): void {
this.attachSeedInputBridge();
this.syncSeedInputBridge();
}
protected onSceneExit(): void {
this.detachSeedInputBridge();
}
update(_dt: number, _name: string, _ticker: Ticker): void {
this.syncSeedInputBridge();
}
public refreshView(): void {
if (!this.model || !this.hudText || !this.setupText) {
return;
@ -173,6 +188,15 @@ export default class ChaseScene extends BaseScene {
this.refreshView();
}
public resetSetup(): void {
this.difficultyLocked = false;
this.seedInput = "";
if (this.seedInputElement) {
this.seedInputElement.value = "";
}
this.refreshView();
}
private buildSetupControls(): void {
this.setupLayer.removeChildren();
const difficulties: Difficulty[] = ["easy", "normal", "hard"];
@ -240,10 +264,11 @@ export default class ChaseScene extends BaseScene {
}
private attachSeedInputBridge(): void {
if (typeof document === "undefined" || this.seedInputElement) {
const doc = this.resolveDocument();
if (!doc || this.seedInputElement || this.inputAttached) {
return;
}
const input = document.createElement("input");
const input = doc.createElement("input");
input.type = "text";
input.placeholder = "Seed (optional)";
input.value = this.seedInput;
@ -253,17 +278,31 @@ export default class ChaseScene extends BaseScene {
input.style.zIndex = "10";
input.style.width = "180px";
input.addEventListener("input", this.handleSeedInputEvent);
document.body.appendChild(input);
doc.body.appendChild(input);
this.seedInputElement = input;
this.inputAttached = true;
}
private detachSeedInputBridge(): void {
if (!this.seedInputElement) {
this.inputAttached = false;
return;
}
this.seedInputElement.removeEventListener("input", this.handleSeedInputEvent);
this.seedInputElement.remove();
this.seedInputElement = undefined;
this.inputAttached = false;
}
private syncSeedInputBridge(): void {
if (!this.resolveDocument()) {
return;
}
if (this.stage.visible) {
this.attachSeedInputBridge();
} else {
this.detachSeedInputBridge();
}
}
private readonly handleSeedInputEvent = (event: Event): void => {
@ -271,4 +310,14 @@ export default class ChaseScene extends BaseScene {
this.seedInput = target?.value ?? "";
this.refreshView();
};
private resolveDocument(): DocumentLike | undefined {
if (this.documentRef) {
return this.documentRef;
}
if (typeof document !== "undefined") {
return document;
}
return undefined;
}
}

86
tests/stages/page_chase.test.ts

@ -22,6 +22,38 @@ function createSceneState() {
};
}
function createFakeDocument() {
const bodyNodes = new Set<HTMLInputElement>();
return {
body: {
appendChild: (node: HTMLInputElement) => {
bodyNodes.add(node);
},
contains: (node: HTMLInputElement) => bodyNodes.has(node),
},
createElement: (_tag: string) => {
const listeners: Record<string, Array<(event: Event) => void>> = {};
const node = {
type: "",
placeholder: "",
value: "",
style: {} as Record<string, string>,
addEventListener: (name: string, handler: (event: Event) => void) => {
listeners[name] = listeners[name] ?? [];
listeners[name].push(handler);
},
removeEventListener: (name: string, handler: (event: Event) => void) => {
listeners[name] = (listeners[name] ?? []).filter((fn) => fn !== handler);
},
remove: () => {
bodyNodes.delete(node as unknown as HTMLInputElement);
},
};
return node as unknown as HTMLInputElement;
},
};
}
describe("chase stage skeleton", () => {
it("can instantiate chase scene with name=chase", () => {
const scene = new ChaseScene();
@ -152,6 +184,28 @@ describe("chase stage skeleton", () => {
expect(newRound.mock.calls[1][1]).toBe("easy");
});
it("allows difficulty change again after resetSetup", () => {
const newRound = vi.fn();
const getState = vi.fn().mockReturnValue(createSceneState());
const scene = new ChaseScene({
model: { getState, moveThief: vi.fn(), newRound } as any,
});
void (scene as any).onSceneLayout();
(scene as any).difficultyButtons.easy.emit("pointerdown");
(scene as any).startButton.emit("pointerdown");
(scene as any).difficultyButtons.hard.emit("pointerdown");
(scene as any).startButton.emit("pointerdown");
(scene as any).resetSetup();
(scene as any).difficultyButtons.hard.emit("pointerdown");
(scene as any).startButton.emit("pointerdown");
expect(newRound.mock.calls[0][1]).toBe("easy");
expect(newRound.mock.calls[1][1]).toBe("easy");
expect(newRound.mock.calls[2][1]).toBe("hard");
});
it("uses normal as default difficulty", () => {
const newRound = vi.fn();
const getState = vi.fn().mockReturnValue(createSceneState());
@ -164,4 +218,36 @@ describe("chase stage skeleton", () => {
(scene as any).startButton.emit("pointerdown");
expect(newRound).toHaveBeenCalledWith(1001, "normal");
});
it("mounts seed input on enter and removes on exit", () => {
const fakeDocument = createFakeDocument();
const getState = vi.fn().mockReturnValue(createSceneState());
const scene = new ChaseScene({
documentRef: fakeDocument as any,
model: { getState, moveThief: vi.fn(), newRound: vi.fn() } as any,
});
void (scene as any).onSceneLayout();
(scene as any).onSceneEnter();
expect((scene as any).seedInputElement).toBeTruthy();
expect(fakeDocument.body.contains((scene as any).seedInputElement)).toBe(true);
(scene as any).onSceneExit();
expect((scene as any).seedInputElement).toBeUndefined();
});
it("detaches seed input when scene becomes invisible", () => {
const fakeDocument = createFakeDocument();
const getState = vi.fn().mockReturnValue(createSceneState());
const scene = new ChaseScene({
documentRef: fakeDocument as any,
model: { getState, moveThief: vi.fn(), newRound: vi.fn() } as any,
});
void (scene as any).onSceneLayout();
(scene as any).onSceneEnter();
expect((scene as any).seedInputElement).toBeTruthy();
scene.stage.visible = false;
scene.update(0, "chase", {} as any);
expect((scene as any).seedInputElement).toBeUndefined();
});
});

Loading…
Cancel
Save