games-development-ai/apps/web/src/components/runtime/WebPlatformAdapter.test.ts
2026-06-05 12:16:13 +08:00

272 lines
12 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { createTelemetryBuffer } from "../../../../../packages/runtime-sdk/src/index";
import { WebPlatformAdapter } from "./WebPlatformAdapter";
import type { WebPlatformAdapterOptions } from "./WebPlatformAdapter";
describe("S4 WebPlatformAdapter", () => {
it("exposes runtime SDK sections while keeping canvas handles opaque", () => {
const env = createDomEnvironment();
const sdk = new WebPlatformAdapter({
canvasElement: env.canvas as unknown as WebPlatformAdapterOptions["canvasElement"],
windowTarget: env.windowTarget as unknown as WebPlatformAdapterOptions["windowTarget"],
storage: env.storage,
fetcher: async () => ({ ok: true, status: 200, json: async () => ({ ok: true }) }),
telemetry: createTelemetryBuffer({ gameVersionId: "version-web-adapter", sender: async () => undefined })
}).createSdk();
expect(Object.keys(sdk)).toEqual(["version", "Canvas", "Input", "Audio", "Network", "Storage", "Lifecycle", "Telemetry"]);
const handle = sdk.Canvas.createCanvas(320, 180);
expect(handle).toEqual({ handleId: expect.stringMatching(/^web-canvas-/) });
expect("canvas" in handle).toBe(false);
expect("getContext" in handle).toBe(false);
});
it("converts DOM pointer and keyboard events into RuntimeSdk DTOs", () => {
const env = createDomEnvironment();
const sdk = new WebPlatformAdapter({
canvasElement: env.canvas as unknown as WebPlatformAdapterOptions["canvasElement"],
windowTarget: env.windowTarget as unknown as WebPlatformAdapterOptions["windowTarget"],
storage: env.storage,
fetcher: async () => ({ ok: true, status: 200, json: async () => ({ ok: true }) }),
telemetry: createTelemetryBuffer({ gameVersionId: "version-web-adapter-input", sender: async () => undefined })
}).createSdk();
const touchEvents: unknown[] = [];
const keyEvents: unknown[] = [];
sdk.Input.onTouchStart((event) => touchEvents.push(event));
sdk.Input.onKeyDown((event) => keyEvents.push(event));
env.canvas.dispatch("pointerdown", {
clientX: 14,
clientY: 25,
pointerId: 7,
preventDefault: vi.fn()
});
env.windowTarget.dispatch("keydown", {
key: "ArrowLeft",
preventDefault: vi.fn()
});
expect(touchEvents).toEqual([
expect.objectContaining({ type: "touch_start", pointerId: "7", x: 4, y: 5, occurredAt: expect.any(String) })
]);
expect(keyEvents).toEqual([expect.objectContaining({ type: "key_down", key: "ArrowLeft", occurredAt: expect.any(String) })]);
});
it("renders commands through Canvas2D without exposing context to game logic", () => {
const env = createDomEnvironment();
const sdk = new WebPlatformAdapter({
canvasElement: env.canvas as unknown as WebPlatformAdapterOptions["canvasElement"],
windowTarget: env.windowTarget as unknown as WebPlatformAdapterOptions["windowTarget"],
storage: env.storage,
fetcher: async () => ({ ok: true, status: 200, json: async () => ({ ok: true }) }),
telemetry: createTelemetryBuffer({ gameVersionId: "version-web-adapter-render", sender: async () => undefined })
}).createSdk();
const handle = sdk.Canvas.createCanvas(320, 180);
sdk.Canvas.submitRenderCommands(handle, [
{ type: "clear", color: "#000000" },
{ type: "rect", x: 1, y: 2, width: 3, height: 4, fill: "#ffffff" },
{ type: "text", text: "Coins 1", x: 8, y: 16, fill: "#ffffff", fontSize: 12 }
]);
expect(env.context.calls).toEqual([
["clearRect", 0, 0, 320, 180],
["fillRect", 0, 0, 320, 180],
["fillRect", 1, 2, 3, 4],
["fillText", "Coins 1", 8, 16]
]);
});
it("uses endpoint ids for network and retries failed requests", async () => {
const env = createDomEnvironment();
const requested: string[] = [];
const sdk = new WebPlatformAdapter({
canvasElement: env.canvas as unknown as WebPlatformAdapterOptions["canvasElement"],
windowTarget: env.windowTarget as unknown as WebPlatformAdapterOptions["windowTarget"],
storage: env.storage,
endpoints: { score_submit: "/api/runtime/score" },
fetcher: async (url) => {
requested.push(url);
if (requested.length === 1) throw new Error("network down");
return { ok: true, status: 202, json: async () => ({ accepted: true }) };
},
retryDelayMs: 0,
telemetry: createTelemetryBuffer({ gameVersionId: "version-web-adapter-network", sender: async () => undefined })
}).createSdk();
await expect(
sdk.Network.request({ endpointId: "score_submit", method: "POST", timeoutMs: 1_000, body: { score: 1 }, retry: { attempts: 2, backoffMs: 1 } })
).resolves.toEqual({ ok: true, status: 202, body: { accepted: true } });
expect(requested).toEqual(["/api/runtime/score", "/api/runtime/score"]);
});
it("aborts hanging network requests with timeout and clears the attempt timer", async () => {
const env = createDomEnvironment();
const timeoutHandlers: Array<() => void> = [];
const clearTimeoutSpy = vi.fn();
env.windowTarget.setTimeout = vi.fn((handler: () => void) => {
timeoutHandlers.push(handler);
return 77;
});
env.windowTarget.clearTimeout = clearTimeoutSpy;
const sdk = new WebPlatformAdapter({
canvasElement: env.canvas as unknown as WebPlatformAdapterOptions["canvasElement"],
windowTarget: env.windowTarget as unknown as WebPlatformAdapterOptions["windowTarget"],
storage: env.storage,
endpoints: { score_submit: "/api/runtime/score" },
fetcher: async () => new Promise(() => undefined),
telemetry: createTelemetryBuffer({ gameVersionId: "version-web-adapter-timeout", sender: async () => undefined })
}).createSdk();
const request = sdk.Network.request({ endpointId: "score_submit", method: "POST", timeoutMs: 50 });
expect(timeoutHandlers).toHaveLength(1);
const triggerTimeout = timeoutHandlers[0];
if (triggerTimeout === undefined) throw new Error("missing timeout handler");
triggerTimeout();
await expect(request).resolves.toEqual({ ok: false, status: 0, errorCode: "WEB_PLATFORM_NETWORK_TIMEOUT" });
expect(clearTimeoutSpy).toHaveBeenCalledWith(77);
});
it("retries after timeout or thrown attempts and can still succeed", async () => {
const env = createDomEnvironment();
const timeoutHandlers: Array<() => void> = [];
env.windowTarget.setTimeout = vi.fn((handler: () => void) => {
timeoutHandlers.push(handler);
return timeoutHandlers.length;
});
env.windowTarget.clearTimeout = vi.fn();
const fetcher = vi
.fn()
.mockImplementationOnce(() => new Promise(() => undefined))
.mockRejectedValueOnce(new Error("offline"))
.mockResolvedValueOnce({ ok: true, status: 202, json: async () => ({ accepted: true }) });
const sdk = new WebPlatformAdapter({
canvasElement: env.canvas as unknown as WebPlatformAdapterOptions["canvasElement"],
windowTarget: env.windowTarget as unknown as WebPlatformAdapterOptions["windowTarget"],
storage: env.storage,
endpoints: { score_submit: "/api/runtime/score" },
fetcher,
retryDelayMs: 0,
telemetry: createTelemetryBuffer({ gameVersionId: "version-web-adapter-retry-timeout", sender: async () => undefined })
}).createSdk();
const request = sdk.Network.request({ endpointId: "score_submit", method: "POST", timeoutMs: 50, retry: { attempts: 3, backoffMs: 1 } });
timeoutHandlers[0]?.();
await expect(request).resolves.toEqual({ ok: true, status: 202, body: { accepted: true } });
expect(fetcher).toHaveBeenCalledTimes(3);
});
it("passes AbortSignal to each fetch attempt", async () => {
const env = createDomEnvironment();
const signals: unknown[] = [];
const sdk = new WebPlatformAdapter({
canvasElement: env.canvas as unknown as WebPlatformAdapterOptions["canvasElement"],
windowTarget: env.windowTarget as unknown as WebPlatformAdapterOptions["windowTarget"],
storage: env.storage,
endpoints: { score_submit: "/api/runtime/score" },
fetcher: async (_url, init) => {
signals.push(init.signal);
return { ok: true, status: 200, json: async () => ({ ok: true }) };
},
telemetry: createTelemetryBuffer({ gameVersionId: "version-web-adapter-signal", sender: async () => undefined })
}).createSdk();
await sdk.Network.request({ endpointId: "score_submit", method: "POST", timeoutMs: 50 });
expect(signals).toHaveLength(1);
expect(signals[0]).toBeInstanceOf(AbortSignal);
});
it("bridges storage and lifecycle events", async () => {
const env = createDomEnvironment();
const sdk = new WebPlatformAdapter({
canvasElement: env.canvas as unknown as WebPlatformAdapterOptions["canvasElement"],
windowTarget: env.windowTarget as unknown as WebPlatformAdapterOptions["windowTarget"],
storage: env.storage,
fetcher: async () => ({ ok: true, status: 200, json: async () => ({ ok: true }) }),
telemetry: createTelemetryBuffer({ gameVersionId: "version-web-adapter-lifecycle", sender: async () => undefined })
}).createSdk();
const lifecycle: unknown[] = [];
await sdk.Storage.setItem("save", "slot1", "value");
expect(await sdk.Storage.getItem("save", "slot1")).toBe("value");
await sdk.Storage.removeItem("save", "slot1");
expect(await sdk.Storage.getItem("save", "slot1")).toBeNull();
sdk.Lifecycle.onPause((event) => lifecycle.push(event));
sdk.Lifecycle.onResume((event) => lifecycle.push(event));
env.windowTarget.dispatch("blur", {});
env.windowTarget.dispatch("focus", {});
expect(lifecycle).toEqual([
expect.objectContaining({ type: "pause", occurredAt: expect.any(String) }),
expect.objectContaining({ type: "resume", occurredAt: expect.any(String) })
]);
});
});
function createDomEnvironment() {
const context = {
calls: [] as unknown[][],
fillStyle: "",
font: "",
clearRect: vi.fn((...args: unknown[]) => context.calls.push(["clearRect", ...args])),
fillRect: vi.fn((...args: unknown[]) => context.calls.push(["fillRect", ...args])),
fillText: vi.fn((...args: unknown[]) => context.calls.push(["fillText", ...args])),
drawImage: vi.fn((...args: unknown[]) => context.calls.push(["drawImage", ...args]))
};
const listeners = new Map<string, Set<(event: Record<string, unknown>) => void>>();
const windowListeners = new Map<string, Set<(event: Record<string, unknown>) => void>>();
const createTarget = (listenerMap: Map<string, Set<(event: Record<string, unknown>) => void>>) => ({
addEventListener: (type: string, handler: (event: Record<string, unknown>) => void) => {
const handlers = listenerMap.get(type) ?? new Set();
handlers.add(handler);
listenerMap.set(type, handlers);
},
removeEventListener: (type: string, handler: (event: Record<string, unknown>) => void) => {
listenerMap.get(type)?.delete(handler);
},
dispatch: (type: string, event: Record<string, unknown>) => {
listenerMap.get(type)?.forEach((handler) => handler(event));
}
});
const canvas = {
width: 320,
height: 180,
clientWidth: 320,
clientHeight: 180,
getBoundingClientRect: () => ({ left: 10, top: 20, width: 320, height: 180 }),
getContext: (type: string) => (type === "2d" ? context : null),
...createTarget(listeners)
};
const storageValues = new Map<string, string>();
const storage = {
getItem: (key: string) => storageValues.get(key) ?? null,
setItem: (key: string, value: string) => {
storageValues.set(key, value);
},
removeItem: (key: string) => {
storageValues.delete(key);
}
};
const windowTarget: ReturnType<typeof createTarget> & {
devicePixelRatio: number;
setTimeout?: (handler: () => void, timeout: number) => number;
clearTimeout?: (handle: number) => void;
} = {
devicePixelRatio: 1,
...createTarget(windowListeners)
};
return {
canvas,
context,
storage,
windowTarget
};
}