Files
homepage/tests/test-render-target-resize.js
2026-08-18 09:57:55 +08:00

338 lines
9.8 KiB
JavaScript

const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const mainPath = process.argv[2]
? path.resolve(process.argv[2])
: path.join(__dirname, "midctrl-homepage-optimized", "assets", "js", "main.js");
const source = fs.readFileSync(mainPath, "utf8");
const events = [];
const consoleErrors = [];
const windowListeners = new Map();
const documentListeners = new Map();
const canvasListeners = new Map();
const timers = new Map();
let nextTimerId = 1;
let rafQueue = [];
let rafTime = 0;
let nextObjectId = 1;
let reducedMotionChangeListener = null;
let documentHidden = true;
let documentReadyState = "loading";
function addListener(store, type, callback) {
if (!store.has(type)) store.set(type, []);
store.get(type).push(callback);
}
function emit(store, type, event = {}) {
for (const callback of store.get(type) || []) callback(event);
}
function flushTimers() {
const pending = [...timers.entries()];
timers.clear();
for (const [, callback] of pending) callback();
}
function flushAnimationFrames() {
const pending = rafQueue;
rafQueue = [];
rafTime += 16.667;
for (const callback of pending) callback(rafTime);
}
function makeObject(type) {
return { type, id: nextObjectId++ };
}
const constants = {
NO_ERROR: 0,
FRAMEBUFFER_COMPLETE: 0x8cd5,
MAX_TEXTURE_SIZE: 0x0d33,
MAX_RENDERBUFFER_SIZE: 0x84e8,
ALIASED_POINT_SIZE_RANGE: 0x846d,
COMPILE_STATUS: 0x8b81,
LINK_STATUS: 0x8b82,
VERTEX_SHADER: 0x8b31,
FRAGMENT_SHADER: 0x8b30,
HIGH_FLOAT: 0x8df2,
TEXTURE0: 0x84c0,
TEXTURE1: 0x84c1,
};
const glTarget = {
...constants,
failTexturePixelsAbove: Infinity,
createFramebuffer() {
events.push("createFramebuffer");
return makeObject("framebuffer");
},
deleteFramebuffer() {
events.push("deleteFramebuffer");
},
createTexture() {
return makeObject("texture");
},
deleteTexture() {},
createRenderbuffer() {
return makeObject("renderbuffer");
},
deleteRenderbuffer() {},
createShader() {
return makeObject("shader");
},
createProgram() {
return makeObject("program");
},
createBuffer() {
return makeObject("buffer");
},
texImage2D(target, level, internalFormat, width, height) {
if (width * height > this.failTexturePixelsAbove) {
events.push("textureAllocationFailed");
throw new Error("Synthetic texture allocation failure.");
}
},
getParameter(parameter) {
if (parameter === this.MAX_TEXTURE_SIZE || parameter === this.MAX_RENDERBUFFER_SIZE) return 16384;
if (parameter === this.ALIASED_POINT_SIZE_RANGE) return [1, 64];
return null;
},
getShaderPrecisionFormat() {
return { precision: 23 };
},
getShaderParameter() {
return true;
},
getProgramParameter() {
return true;
},
getShaderInfoLog() {
return "";
},
getProgramInfoLog() {
return "";
},
getUniformLocation() {
return makeObject("uniform");
},
getAttribLocation() {
return 0;
},
checkFramebufferStatus() {
return this.FRAMEBUFFER_COMPLETE;
},
getError() {
return this.NO_ERROR;
},
isContextLost() {
return false;
},
finish() {
events.push("finish");
},
};
let generatedConstant = 0x10000;
const gl = new Proxy(glTarget, {
get(target, property) {
if (property in target) return target[property];
if (typeof property === "string" && /^[A-Z0-9_]+$/.test(property)) {
target[property] = generatedConstant++;
return target[property];
}
if (typeof property === "string") {
target[property] = function noop() {};
return target[property];
}
return undefined;
},
});
const canvas = {
width: 300,
height: 150,
hidden: false,
getContext() {
return gl;
},
addEventListener(type, callback) {
addListener(canvasListeners, type, callback);
},
};
gl.canvas = canvas;
const year = { textContent: "" };
const mediaQuery = {
matches: false,
addEventListener(type, callback) {
if (type === "change") reducedMotionChangeListener = callback;
},
addListener(callback) {
reducedMotionChangeListener = callback;
},
};
const context = {
console: {
log() {},
warn() {},
error(...args) {
consoleErrors.push(args.map(String).join(" "));
},
},
document: {
get hidden() {
return documentHidden;
},
get visibilityState() {
return documentHidden ? "hidden" : "visible";
},
get readyState() {
return documentReadyState;
},
documentElement: { clientWidth: 1280, clientHeight: 720 },
querySelector(selector) {
if (selector === "#year") return year;
return null;
},
getElementById(id) {
return id === "sakura" ? canvas : null;
},
addEventListener(type, callback) {
addListener(documentListeners, type, callback);
},
},
navigator: { deviceMemory: 8 },
innerWidth: 1280,
innerHeight: 720,
devicePixelRatio: 1,
performance: { now: () => rafTime },
matchMedia() {
return mediaQuery;
},
addEventListener(type, callback) {
addListener(windowListeners, type, callback);
},
setTimeout(callback) {
const id = nextTimerId++;
timers.set(id, callback);
return id;
},
clearTimeout(id) {
timers.delete(id);
},
requestAnimationFrame(callback) {
rafQueue.push(callback);
return rafQueue.length;
},
cancelAnimationFrame() {
// Deliberately leave the callback queued to verify generation guards in
// browsers where cancellation races with a frame already being dispatched.
events.push("cancelAnimationFrame");
},
Date,
Math,
Number,
Object,
Array,
Float32Array,
Uint8Array,
isFinite,
};
context.window = context;
context.globalThis = context;
vm.runInNewContext(source, context, { filename: "main.js" });
assert.equal(events.filter((event) => event === "createFramebuffer").length, 0, "WebGL must wait only for the DOM");
documentHidden = false;
documentReadyState = "interactive";
emit(documentListeners, "DOMContentLoaded");
assert.equal(consoleErrors.length, 0, "initialization must not log errors");
assert.deepEqual([canvas.width, canvas.height], [1280, 720]);
assert.equal(events.filter((event) => event === "createFramebuffer").length, 3);
assert.equal(rafQueue.length, 1, "a missed pre-DOM visibility transition must still start animation");
function resize(width, height, dpr, burstCount = 1) {
context.innerWidth = width;
context.innerHeight = height;
context.devicePixelRatio = dpr;
context.document.documentElement.clientWidth = width;
context.document.documentElement.clientHeight = height;
for (let i = 0; i < burstCount; i++) emit(windowListeners, "resize");
assert.equal(timers.size, 1, "resize bursts must collapse to one timer");
flushTimers();
flushAnimationFrames();
}
events.length = 0;
resize(1366, 768, 1, 25);
assert.equal(events.filter((event) => event === "createFramebuffer").length, 3);
assert.equal(events.filter((event) => event === "deleteFramebuffer").length, 3);
assert.ok(events.indexOf("createFramebuffer") < events.indexOf("deleteFramebuffer"));
events.length = 0;
resize(3000, 1800, 2);
assert.ok(canvas.width * canvas.height <= 10000000, "normal pixel budget must be enforced");
events.length = 0;
resize(3200, 1800, 2);
assert.equal(events.filter((event) => event === "finish").length, 1);
assert.equal(events.filter((event) => event === "deleteFramebuffer").length, 3);
assert.equal(events.filter((event) => event === "createFramebuffer").length, 3);
assert.ok(events.indexOf("deleteFramebuffer") < events.indexOf("createFramebuffer"));
events.length = 0;
gl.failTexturePixelsAbove = 5000000;
resize(3600, 2000, 2);
assert.ok(events.filter((event) => event === "textureAllocationFailed").length >= 2);
assert.ok(canvas.width * canvas.height <= 5000000, "failed allocations must fall back to a smaller target");
assert.equal(consoleErrors.length, 0, "successful fallback must not log a terminal error");
events.length = 0;
gl.failTexturePixelsAbove = Infinity;
let contextLossPrevented = false;
documentHidden = true;
emit(windowListeners, "pagehide", { persisted: true });
assert.equal(events.filter((event) => event === "cancelAnimationFrame").length, 1);
assert.equal(rafQueue.length, 1, "the mock retains the stale frame after pagehide");
documentHidden = false;
emit(windowListeners, "pageshow", { persisted: true });
assert.equal(rafQueue.length, 2, "pageshow must queue a replacement frame");
emit(windowListeners, "pageshow", { persisted: true });
assert.equal(rafQueue.length, 3, "a repeated pageshow replaces the pending frame in the mock queue");
flushAnimationFrames();
assert.equal(rafQueue.length, 1, "only the newest pageshow loop may continue");
events.length = 0;
mediaQuery.matches = true;
reducedMotionChangeListener({ matches: true });
assert.equal(events.filter((event) => event === "cancelAnimationFrame").length, 1);
flushAnimationFrames();
assert.equal(rafQueue.length, 0, "reduced motion must leave no live animation callback");
mediaQuery.matches = false;
reducedMotionChangeListener({ matches: false });
assert.equal(rafQueue.length, 1, "animation must have one pending frame before context loss");
events.length = 0;
emit(canvasListeners, "webglcontextlost", {
preventDefault() {
contextLossPrevented = true;
},
});
emit(canvasListeners, "webglcontextrestored");
assert.equal(contextLossPrevented, true);
assert.equal(events.filter((event) => event === "createFramebuffer").length, 3);
assert.equal(events.filter((event) => event === "cancelAnimationFrame").length, 1);
assert.equal(rafQueue.length, 2, "the mock keeps both the stale and restored callbacks queued");
flushAnimationFrames();
assert.equal(rafQueue.length, 1, "only the restored animation loop may schedule the next frame");
assert.equal(consoleErrors.length, 0, "context restoration must not log errors");
console.log("PASS: cold start, page recovery, Render Target resize, fallback, and context restoration");