import { beforeEach, describe, expect, it, vi } from "vitest"; import { TransitionTransaction } from "@/scene/TransitionTransaction"; import { SceneType } from "@/enums/SceneType"; import type { IBaseScene } from "@/scene/types"; class MockContainer { visible = true; label = ""; _isHolderLast?: boolean; children: MockContainer[] = []; addChild(child: MockContainer): MockContainer { this.children.push(child); return child; } removeChild(child: MockContainer): MockContainer { this.children = this.children.filter((item) => item !== child); return child; } destroy(options?: { children?: boolean }): void { if (options?.children) { this.children = []; } } } class MockGraphics extends MockContainer { clear(): this { return this; } rect(): this { return this; } roundRect(): this { return this; } fill(): this { return this; } stroke(): this { return this; } } class MockText extends MockContainer { text = ""; style: unknown; anchor = { set: () => {}, }; position = { set: () => {}, }; } class MockTextStyle { constructor(_style?: Record) {} } vi.mock("pixi.js", () => ({ Container: MockContainer, Graphics: MockGraphics, Text: MockText, TextStyle: MockTextStyle, })); vi.mock("@/core/Game", () => { class MockGame { private static instance: MockGame; readonly stage = new MockContainer(); static getInstance(): MockGame { if (!MockGame.instance) { MockGame.instance = new MockGame(); } return MockGame.instance; } } return { default: MockGame }; }); function createScene( name: string, type: SceneType = SceneType.Normal, overrides?: Partial ): IBaseScene { const stage = new MockContainer(); stage.label = name; return { name, type, stage: stage as never, _assetsLoaded: true, _layoutDone: true, changeScene: () => {}, loadBundle: async () => {}, unLoadBundle: async () => {}, layout: async () => {}, onLoad: async () => {}, onUnLoad: async () => {}, ...overrides, }; } describe("TransitionTransaction", () => { it("rolls back when prepare fails and never commits", async () => { const prepareError = new Error("prepare failed"); const prepare = vi.fn(async () => { throw prepareError; }); const commit = vi.fn(async () => {}); const rollback = vi.fn(async () => {}); const transaction = new TransitionTransaction({ prepare, commit, rollback, }); await expect(transaction.execute()).rejects.toThrowError("prepare failed"); expect(prepare).toHaveBeenCalledTimes(1); expect(rollback).toHaveBeenCalledTimes(1); expect(commit).not.toHaveBeenCalled(); }); it("rolls back when commit fails", async () => { const commitError = new Error("commit failed"); const prepare = vi.fn(async () => {}); const commit = vi.fn(async () => { throw commitError; }); const rollback = vi.fn(async () => {}); const transaction = new TransitionTransaction({ prepare, commit, rollback, }); await expect(transaction.execute()).rejects.toThrowError("commit failed"); expect(prepare).toHaveBeenCalledTimes(1); expect(commit).toHaveBeenCalledTimes(1); expect(rollback).toHaveBeenCalledTimes(1); }); }); describe("SceneManager rollback safety", () => { beforeEach(async () => { vi.resetModules(); const module = await import("@/scene/SceneManager"); (module.default as unknown as { instance?: unknown }).instance = undefined; }); it("keeps previous stage content intact when target load fails", async () => { const module = await import("@/scene/SceneManager"); const SceneManager = module.default; const manager = SceneManager.getInstance(); const previous = createScene("previous"); const previousContent = new MockContainer(); (previous.stage as unknown as MockContainer).addChild(previousContent); const target = createScene("target", SceneType.Normal, { onLoad: async () => { throw new Error("target onLoad failed"); }, }); manager.registerScene(previous); manager.registerScene(target); await manager.initScene("previous"); await expect(manager.changeScene("target")).rejects.toThrowError( "target onLoad failed" ); const stage = manager["game"].stage as unknown as MockContainer; expect(manager.currentScene).toBe(previous); expect(previous.stage).not.toBeUndefined(); expect((previous.stage as unknown as MockContainer).children).toContain( previousContent ); expect((previous.stage as unknown as MockContainer).visible).toBe(true); expect(stage.children.includes(previous.stage as never)).toBe(true); expect(stage.children.includes(target.stage as never)).toBe(false); }); it("reloads current scene without leaving stage empty", async () => { const module = await import("@/scene/SceneManager"); const SceneManager = module.default; const manager = SceneManager.getInstance(); const current = createScene("same", SceneType.Normal, { _assetsLoaded: true, _layoutDone: true, onUnLoad: vi.fn(async () => {}), unLoadBundle: vi.fn(async () => {}), loadBundle: vi.fn(async () => {}), layout: vi.fn(async () => {}), onLoad: vi.fn(async function (this: IBaseScene) { (this.stage as unknown as MockContainer).addChild(new MockContainer()); }), }); manager.registerScene(current); await manager.initScene("same"); const beforeStage = current.stage; await manager.changeScene("same"); const afterStage = current.stage as unknown as MockContainer; const rootStage = manager["game"].stage as unknown as MockContainer; expect(manager.currentScene).toBe(current); expect(afterStage).not.toBe(beforeStage); expect(afterStage.children.length).toBeGreaterThan(0); expect(rootStage.children.includes(afterStage as never)).toBe(true); }); });