miaoda-game-devkit 0.10.4 → 0.10.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/phaser-lint.js +114 -9
- package/dist/cli/react-lint.js +114 -9
- package/dist/lint/setup.mjs +28 -2
- package/dist/react/index.d.mts +26 -2
- package/dist/react/index.d.ts +26 -2
- package/dist/react/index.js +51 -0
- package/dist/react/index.mjs +50 -0
- package/dist/react/testing.d.mts +4 -1
- package/dist/react/testing.d.ts +4 -1
- package/dist/react/testing.js +14 -0
- package/dist/react/testing.mjs +13 -0
- package/dist/react/vitest-config.js +107 -3
- package/dist/react/vitest-config.mjs +107 -3
- package/dist/react/vitest-setup.js +28 -2
- package/dist/react/vitest-setup.mjs +28 -2
- package/package.json +2 -40
package/dist/cli/phaser-lint.js
CHANGED
|
@@ -23,6 +23,22 @@ var CONTROLLER_IMPORTS = /* @__PURE__ */ new Set([
|
|
|
23
23
|
"useOwnedGameController"
|
|
24
24
|
]);
|
|
25
25
|
var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
|
|
26
|
+
var KNOWN_PER_FRAME_CANVAS_DEPENDENCIES = /* @__PURE__ */ new Set([
|
|
27
|
+
"ball",
|
|
28
|
+
"bricks",
|
|
29
|
+
"enemies",
|
|
30
|
+
"entities",
|
|
31
|
+
"entity",
|
|
32
|
+
"elapsedMs",
|
|
33
|
+
"elapsedTimeMs",
|
|
34
|
+
"hoveredCell",
|
|
35
|
+
"paddle",
|
|
36
|
+
"particles",
|
|
37
|
+
"player",
|
|
38
|
+
"projectiles",
|
|
39
|
+
"snapshot",
|
|
40
|
+
"state"
|
|
41
|
+
]);
|
|
26
42
|
function runnableTestFiles(root, directory = (0, import_node_path.join)(root, "tests")) {
|
|
27
43
|
if (!(0, import_node_fs.existsSync)(directory)) return [];
|
|
28
44
|
const files = [];
|
|
@@ -116,6 +132,88 @@ function containsAny(values, expected) {
|
|
|
116
132
|
function isRecord(value) {
|
|
117
133
|
return typeof value === "object" && value !== null;
|
|
118
134
|
}
|
|
135
|
+
function visitAst(value, visitor) {
|
|
136
|
+
if (Array.isArray(value)) {
|
|
137
|
+
for (const item of value) visitAst(item, visitor);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (!isRecord(value)) return;
|
|
141
|
+
if (typeof value.type === "string") visitor(value);
|
|
142
|
+
for (const child of Object.values(value)) visitAst(child, visitor);
|
|
143
|
+
}
|
|
144
|
+
function identifierName(value) {
|
|
145
|
+
return isRecord(value) && value.type === "Identifier" && typeof value.name === "string" ? value.name : void 0;
|
|
146
|
+
}
|
|
147
|
+
function memberPath(value) {
|
|
148
|
+
if (!isRecord(value)) return [];
|
|
149
|
+
const identifier = identifierName(value);
|
|
150
|
+
if (identifier) return [identifier];
|
|
151
|
+
if (value.type !== "MemberExpression") return [];
|
|
152
|
+
return [...memberPath(value.object), ...identifierName(value.property) ? [identifierName(value.property)] : []];
|
|
153
|
+
}
|
|
154
|
+
function hasRepeatedAnimationFrameCallback(value) {
|
|
155
|
+
const callbackCounts = /* @__PURE__ */ new Map();
|
|
156
|
+
visitAst(value, (node) => {
|
|
157
|
+
if (node.type !== "CallExpression") return;
|
|
158
|
+
if (identifierName(node.callee) !== "requestAnimationFrame") return;
|
|
159
|
+
const arguments_ = Array.isArray(node.arguments) ? node.arguments : [];
|
|
160
|
+
const callback = identifierName(arguments_[0]);
|
|
161
|
+
if (!callback) return;
|
|
162
|
+
callbackCounts.set(callback, (callbackCounts.get(callback) ?? 0) + 1);
|
|
163
|
+
});
|
|
164
|
+
return [...callbackCounts.values()].some((count) => count >= 2);
|
|
165
|
+
}
|
|
166
|
+
function reactEffectNames(file, source) {
|
|
167
|
+
const names = /* @__PURE__ */ new Set(["useEffect"]);
|
|
168
|
+
for (const declaration of staticImports(file, source)) {
|
|
169
|
+
if (declaration.moduleRequest.value !== "react") continue;
|
|
170
|
+
for (const entry of declaration.entries) {
|
|
171
|
+
if (entry.importName.kind === "Name" && entry.importName.name === "useEffect") {
|
|
172
|
+
names.add(entry.localName.value);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return names;
|
|
177
|
+
}
|
|
178
|
+
function isReactEffectCall(node, effectNames) {
|
|
179
|
+
if (node.type !== "CallExpression") return false;
|
|
180
|
+
const directName = identifierName(node.callee);
|
|
181
|
+
if (directName && effectNames.has(directName)) return true;
|
|
182
|
+
const path = memberPath(node.callee);
|
|
183
|
+
return path.length === 2 && path[1] === "useEffect";
|
|
184
|
+
}
|
|
185
|
+
function canvasRafDependencyIssues(file, source, projectRoot2) {
|
|
186
|
+
const ownsCanvas = /<canvas\b|createElement\s*\(\s*["']canvas["']|getContext\s*\(\s*["']2d["']/.test(
|
|
187
|
+
source
|
|
188
|
+
);
|
|
189
|
+
if (!ownsCanvas) return [];
|
|
190
|
+
const issues = [];
|
|
191
|
+
const effectNames = reactEffectNames(file, source);
|
|
192
|
+
visitAst(parseSource(file, source).program, (node) => {
|
|
193
|
+
if (!isReactEffectCall(node, effectNames)) return;
|
|
194
|
+
const arguments_ = Array.isArray(node.arguments) ? node.arguments : [];
|
|
195
|
+
if (!hasRepeatedAnimationFrameCallback(arguments_[0])) return;
|
|
196
|
+
const dependencies = arguments_[1];
|
|
197
|
+
if (!isRecord(dependencies) || dependencies.type !== "ArrayExpression") return;
|
|
198
|
+
const elements = Array.isArray(dependencies.elements) ? dependencies.elements : [];
|
|
199
|
+
const unsafe = elements.filter(
|
|
200
|
+
(element) => memberPath(element).some((name) => KNOWN_PER_FRAME_CANVAS_DEPENDENCIES.has(name))
|
|
201
|
+
);
|
|
202
|
+
if (unsafe.length === 0) return;
|
|
203
|
+
const labels = unsafe.map((element) => {
|
|
204
|
+
if (!isRecord(element) || typeof element.start !== "number" || typeof element.end !== "number") {
|
|
205
|
+
return "dynamic state";
|
|
206
|
+
}
|
|
207
|
+
return source.slice(element.start, element.end);
|
|
208
|
+
});
|
|
209
|
+
const line = typeof node.start === "number" ? source.slice(0, node.start).split("\n").length : 1;
|
|
210
|
+
const path = (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/");
|
|
211
|
+
issues.push(
|
|
212
|
+
`CANVAS_RAF_DYNAMIC_DEPENDENCY ${path}:${line}: a recurring frame loop depends on ${labels.join(", ")}. React cleanup cancels and recreates the pending frame whenever these values change, which can freeze Canvas while HUD state keeps updating. Keep the loop effect stable and read changing state through a ref; a plain app-owned 2D Canvas may optionally use useGameCanvas from miaoda-game-devkit/react.`
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
return issues;
|
|
216
|
+
}
|
|
119
217
|
function containsObserveProperty(value) {
|
|
120
218
|
if (Array.isArray(value)) return value.some(containsObserveProperty);
|
|
121
219
|
if (!isRecord(value)) return false;
|
|
@@ -131,17 +229,20 @@ function declaresObserve(file, source) {
|
|
|
131
229
|
function auditReactAuthoritativePlaythrough(projectRoot2) {
|
|
132
230
|
const clockFiles = [];
|
|
133
231
|
const controllerFiles = [];
|
|
232
|
+
const canvasRafIssues = [];
|
|
134
233
|
const productionFiles = sourceFiles(projectRoot2);
|
|
135
234
|
for (const file of productionFiles) {
|
|
235
|
+
const source = (0, import_node_fs.readFileSync)(file, "utf8");
|
|
136
236
|
const imports = namedImports(
|
|
137
237
|
file,
|
|
138
|
-
|
|
238
|
+
source,
|
|
139
239
|
REACT_RUNTIME_ENTRY
|
|
140
240
|
);
|
|
141
241
|
const projectPath = (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/");
|
|
142
242
|
if (containsAny(imports, CLOCK_IMPORTS)) clockFiles.push(projectPath);
|
|
143
243
|
if (containsAny(imports, CONTROLLER_IMPORTS))
|
|
144
244
|
controllerFiles.push(projectPath);
|
|
245
|
+
canvasRafIssues.push(...canvasRafDependencyIssues(file, source, projectRoot2));
|
|
145
246
|
}
|
|
146
247
|
const appPath = (0, import_node_path.join)(projectRoot2, PRODUCTION_APP);
|
|
147
248
|
const productionEntryExists = (0, import_node_fs.existsSync)(appPath);
|
|
@@ -152,6 +253,7 @@ function auditReactAuthoritativePlaythrough(projectRoot2) {
|
|
|
152
253
|
(file) => importsExampleAlias(file, (0, import_node_fs.readFileSync)(file, "utf8"))
|
|
153
254
|
).map((file) => (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/"));
|
|
154
255
|
const issues = [];
|
|
256
|
+
issues.push(...canvasRafIssues);
|
|
155
257
|
if (staleExampleTestFiles.length > 0) {
|
|
156
258
|
issues.push(
|
|
157
259
|
`Production ${PRODUCTION_APP} no longer imports ${EXAMPLE_IMPORT_PREFIX}*, but runnable product tests still do: ${staleExampleTestFiles.join(", ")}. Replace those CollectGame tests with tests for the production game or delete genuinely inapplicable slots. Teaching examples under tests/examples/**/*.example.* remain allowed.`
|
|
@@ -165,7 +267,8 @@ function auditReactAuthoritativePlaythrough(projectRoot2) {
|
|
|
165
267
|
controllerFiles,
|
|
166
268
|
productionEntryExists,
|
|
167
269
|
productionUsesExample,
|
|
168
|
-
staleExampleTestFiles
|
|
270
|
+
staleExampleTestFiles,
|
|
271
|
+
canvasRafIssues
|
|
169
272
|
};
|
|
170
273
|
}
|
|
171
274
|
const testPath = (0, import_node_path.join)(projectRoot2, PRODUCTION_PLAYTHROUGH);
|
|
@@ -193,7 +296,8 @@ function auditReactAuthoritativePlaythrough(projectRoot2) {
|
|
|
193
296
|
controllerFiles,
|
|
194
297
|
productionEntryExists,
|
|
195
298
|
productionUsesExample,
|
|
196
|
-
staleExampleTestFiles
|
|
299
|
+
staleExampleTestFiles,
|
|
300
|
+
canvasRafIssues
|
|
197
301
|
};
|
|
198
302
|
}
|
|
199
303
|
|
|
@@ -304,12 +408,13 @@ function checkVitestConfig(target) {
|
|
|
304
408
|
}
|
|
305
409
|
return { name, ok: true };
|
|
306
410
|
}
|
|
307
|
-
function
|
|
308
|
-
const name = "react-
|
|
411
|
+
function checkReactSourceSafety() {
|
|
412
|
+
const name = "react-source-safety";
|
|
309
413
|
const audit = auditReactAuthoritativePlaythrough(projectRoot);
|
|
310
|
-
|
|
311
|
-
console.error(`[${name}] ${
|
|
312
|
-
|
|
414
|
+
const blockingIssues = (audit.staleExampleTestFiles.length > 0 ? [audit.issues.find((issue) => issue.includes("runnable product tests still do"))] : []).filter((issue) => Boolean(issue));
|
|
415
|
+
for (const issue of blockingIssues) console.error(`[${name}] ${issue}`);
|
|
416
|
+
for (const issue of audit.canvasRafIssues) console.warn(`[${name}] WARNING ${issue}`);
|
|
417
|
+
return { name, ok: blockingIssues.length === 0 };
|
|
313
418
|
}
|
|
314
419
|
function styleLintRoots(target) {
|
|
315
420
|
const sourceRoots = (0, import_node_fs2.readdirSync)((0, import_node_path2.join)(projectRoot, "src"), {
|
|
@@ -318,7 +423,7 @@ function styleLintRoots(target) {
|
|
|
318
423
|
return target === "react" && (0, import_node_fs2.existsSync)((0, import_node_path2.join)(projectRoot, "tests")) ? [...sourceRoots, "tests"] : sourceRoots;
|
|
319
424
|
}
|
|
320
425
|
async function runAllChecks(target) {
|
|
321
|
-
const targetChecks = target === "react" ? [checkVitestConfig("react"),
|
|
426
|
+
const targetChecks = target === "react" ? [checkVitestConfig("react"), checkReactSourceSafety()] : [checkPhaser4Dependency(), checkPhaserViteConfig(), checkVitestConfig("phaser")];
|
|
322
427
|
const lintRoots = styleLintRoots(target);
|
|
323
428
|
const [tsgo, biome] = await Promise.all([
|
|
324
429
|
run("tsgo", "@typescript/native-preview", "tsgo", ["-p", "tsconfig.json"]),
|
package/dist/cli/react-lint.js
CHANGED
|
@@ -23,6 +23,22 @@ var CONTROLLER_IMPORTS = /* @__PURE__ */ new Set([
|
|
|
23
23
|
"useOwnedGameController"
|
|
24
24
|
]);
|
|
25
25
|
var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
|
|
26
|
+
var KNOWN_PER_FRAME_CANVAS_DEPENDENCIES = /* @__PURE__ */ new Set([
|
|
27
|
+
"ball",
|
|
28
|
+
"bricks",
|
|
29
|
+
"enemies",
|
|
30
|
+
"entities",
|
|
31
|
+
"entity",
|
|
32
|
+
"elapsedMs",
|
|
33
|
+
"elapsedTimeMs",
|
|
34
|
+
"hoveredCell",
|
|
35
|
+
"paddle",
|
|
36
|
+
"particles",
|
|
37
|
+
"player",
|
|
38
|
+
"projectiles",
|
|
39
|
+
"snapshot",
|
|
40
|
+
"state"
|
|
41
|
+
]);
|
|
26
42
|
function runnableTestFiles(root, directory = (0, import_node_path.join)(root, "tests")) {
|
|
27
43
|
if (!(0, import_node_fs.existsSync)(directory)) return [];
|
|
28
44
|
const files = [];
|
|
@@ -116,6 +132,88 @@ function containsAny(values, expected) {
|
|
|
116
132
|
function isRecord(value) {
|
|
117
133
|
return typeof value === "object" && value !== null;
|
|
118
134
|
}
|
|
135
|
+
function visitAst(value, visitor) {
|
|
136
|
+
if (Array.isArray(value)) {
|
|
137
|
+
for (const item of value) visitAst(item, visitor);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (!isRecord(value)) return;
|
|
141
|
+
if (typeof value.type === "string") visitor(value);
|
|
142
|
+
for (const child of Object.values(value)) visitAst(child, visitor);
|
|
143
|
+
}
|
|
144
|
+
function identifierName(value) {
|
|
145
|
+
return isRecord(value) && value.type === "Identifier" && typeof value.name === "string" ? value.name : void 0;
|
|
146
|
+
}
|
|
147
|
+
function memberPath(value) {
|
|
148
|
+
if (!isRecord(value)) return [];
|
|
149
|
+
const identifier = identifierName(value);
|
|
150
|
+
if (identifier) return [identifier];
|
|
151
|
+
if (value.type !== "MemberExpression") return [];
|
|
152
|
+
return [...memberPath(value.object), ...identifierName(value.property) ? [identifierName(value.property)] : []];
|
|
153
|
+
}
|
|
154
|
+
function hasRepeatedAnimationFrameCallback(value) {
|
|
155
|
+
const callbackCounts = /* @__PURE__ */ new Map();
|
|
156
|
+
visitAst(value, (node) => {
|
|
157
|
+
if (node.type !== "CallExpression") return;
|
|
158
|
+
if (identifierName(node.callee) !== "requestAnimationFrame") return;
|
|
159
|
+
const arguments_ = Array.isArray(node.arguments) ? node.arguments : [];
|
|
160
|
+
const callback = identifierName(arguments_[0]);
|
|
161
|
+
if (!callback) return;
|
|
162
|
+
callbackCounts.set(callback, (callbackCounts.get(callback) ?? 0) + 1);
|
|
163
|
+
});
|
|
164
|
+
return [...callbackCounts.values()].some((count) => count >= 2);
|
|
165
|
+
}
|
|
166
|
+
function reactEffectNames(file, source) {
|
|
167
|
+
const names = /* @__PURE__ */ new Set(["useEffect"]);
|
|
168
|
+
for (const declaration of staticImports(file, source)) {
|
|
169
|
+
if (declaration.moduleRequest.value !== "react") continue;
|
|
170
|
+
for (const entry of declaration.entries) {
|
|
171
|
+
if (entry.importName.kind === "Name" && entry.importName.name === "useEffect") {
|
|
172
|
+
names.add(entry.localName.value);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return names;
|
|
177
|
+
}
|
|
178
|
+
function isReactEffectCall(node, effectNames) {
|
|
179
|
+
if (node.type !== "CallExpression") return false;
|
|
180
|
+
const directName = identifierName(node.callee);
|
|
181
|
+
if (directName && effectNames.has(directName)) return true;
|
|
182
|
+
const path = memberPath(node.callee);
|
|
183
|
+
return path.length === 2 && path[1] === "useEffect";
|
|
184
|
+
}
|
|
185
|
+
function canvasRafDependencyIssues(file, source, projectRoot2) {
|
|
186
|
+
const ownsCanvas = /<canvas\b|createElement\s*\(\s*["']canvas["']|getContext\s*\(\s*["']2d["']/.test(
|
|
187
|
+
source
|
|
188
|
+
);
|
|
189
|
+
if (!ownsCanvas) return [];
|
|
190
|
+
const issues = [];
|
|
191
|
+
const effectNames = reactEffectNames(file, source);
|
|
192
|
+
visitAst(parseSource(file, source).program, (node) => {
|
|
193
|
+
if (!isReactEffectCall(node, effectNames)) return;
|
|
194
|
+
const arguments_ = Array.isArray(node.arguments) ? node.arguments : [];
|
|
195
|
+
if (!hasRepeatedAnimationFrameCallback(arguments_[0])) return;
|
|
196
|
+
const dependencies = arguments_[1];
|
|
197
|
+
if (!isRecord(dependencies) || dependencies.type !== "ArrayExpression") return;
|
|
198
|
+
const elements = Array.isArray(dependencies.elements) ? dependencies.elements : [];
|
|
199
|
+
const unsafe = elements.filter(
|
|
200
|
+
(element) => memberPath(element).some((name) => KNOWN_PER_FRAME_CANVAS_DEPENDENCIES.has(name))
|
|
201
|
+
);
|
|
202
|
+
if (unsafe.length === 0) return;
|
|
203
|
+
const labels = unsafe.map((element) => {
|
|
204
|
+
if (!isRecord(element) || typeof element.start !== "number" || typeof element.end !== "number") {
|
|
205
|
+
return "dynamic state";
|
|
206
|
+
}
|
|
207
|
+
return source.slice(element.start, element.end);
|
|
208
|
+
});
|
|
209
|
+
const line = typeof node.start === "number" ? source.slice(0, node.start).split("\n").length : 1;
|
|
210
|
+
const path = (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/");
|
|
211
|
+
issues.push(
|
|
212
|
+
`CANVAS_RAF_DYNAMIC_DEPENDENCY ${path}:${line}: a recurring frame loop depends on ${labels.join(", ")}. React cleanup cancels and recreates the pending frame whenever these values change, which can freeze Canvas while HUD state keeps updating. Keep the loop effect stable and read changing state through a ref; a plain app-owned 2D Canvas may optionally use useGameCanvas from miaoda-game-devkit/react.`
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
return issues;
|
|
216
|
+
}
|
|
119
217
|
function containsObserveProperty(value) {
|
|
120
218
|
if (Array.isArray(value)) return value.some(containsObserveProperty);
|
|
121
219
|
if (!isRecord(value)) return false;
|
|
@@ -131,17 +229,20 @@ function declaresObserve(file, source) {
|
|
|
131
229
|
function auditReactAuthoritativePlaythrough(projectRoot2) {
|
|
132
230
|
const clockFiles = [];
|
|
133
231
|
const controllerFiles = [];
|
|
232
|
+
const canvasRafIssues = [];
|
|
134
233
|
const productionFiles = sourceFiles(projectRoot2);
|
|
135
234
|
for (const file of productionFiles) {
|
|
235
|
+
const source = (0, import_node_fs.readFileSync)(file, "utf8");
|
|
136
236
|
const imports = namedImports(
|
|
137
237
|
file,
|
|
138
|
-
|
|
238
|
+
source,
|
|
139
239
|
REACT_RUNTIME_ENTRY
|
|
140
240
|
);
|
|
141
241
|
const projectPath = (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/");
|
|
142
242
|
if (containsAny(imports, CLOCK_IMPORTS)) clockFiles.push(projectPath);
|
|
143
243
|
if (containsAny(imports, CONTROLLER_IMPORTS))
|
|
144
244
|
controllerFiles.push(projectPath);
|
|
245
|
+
canvasRafIssues.push(...canvasRafDependencyIssues(file, source, projectRoot2));
|
|
145
246
|
}
|
|
146
247
|
const appPath = (0, import_node_path.join)(projectRoot2, PRODUCTION_APP);
|
|
147
248
|
const productionEntryExists = (0, import_node_fs.existsSync)(appPath);
|
|
@@ -152,6 +253,7 @@ function auditReactAuthoritativePlaythrough(projectRoot2) {
|
|
|
152
253
|
(file) => importsExampleAlias(file, (0, import_node_fs.readFileSync)(file, "utf8"))
|
|
153
254
|
).map((file) => (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/"));
|
|
154
255
|
const issues = [];
|
|
256
|
+
issues.push(...canvasRafIssues);
|
|
155
257
|
if (staleExampleTestFiles.length > 0) {
|
|
156
258
|
issues.push(
|
|
157
259
|
`Production ${PRODUCTION_APP} no longer imports ${EXAMPLE_IMPORT_PREFIX}*, but runnable product tests still do: ${staleExampleTestFiles.join(", ")}. Replace those CollectGame tests with tests for the production game or delete genuinely inapplicable slots. Teaching examples under tests/examples/**/*.example.* remain allowed.`
|
|
@@ -165,7 +267,8 @@ function auditReactAuthoritativePlaythrough(projectRoot2) {
|
|
|
165
267
|
controllerFiles,
|
|
166
268
|
productionEntryExists,
|
|
167
269
|
productionUsesExample,
|
|
168
|
-
staleExampleTestFiles
|
|
270
|
+
staleExampleTestFiles,
|
|
271
|
+
canvasRafIssues
|
|
169
272
|
};
|
|
170
273
|
}
|
|
171
274
|
const testPath = (0, import_node_path.join)(projectRoot2, PRODUCTION_PLAYTHROUGH);
|
|
@@ -193,7 +296,8 @@ function auditReactAuthoritativePlaythrough(projectRoot2) {
|
|
|
193
296
|
controllerFiles,
|
|
194
297
|
productionEntryExists,
|
|
195
298
|
productionUsesExample,
|
|
196
|
-
staleExampleTestFiles
|
|
299
|
+
staleExampleTestFiles,
|
|
300
|
+
canvasRafIssues
|
|
197
301
|
};
|
|
198
302
|
}
|
|
199
303
|
|
|
@@ -304,12 +408,13 @@ function checkVitestConfig(target) {
|
|
|
304
408
|
}
|
|
305
409
|
return { name, ok: true };
|
|
306
410
|
}
|
|
307
|
-
function
|
|
308
|
-
const name = "react-
|
|
411
|
+
function checkReactSourceSafety() {
|
|
412
|
+
const name = "react-source-safety";
|
|
309
413
|
const audit = auditReactAuthoritativePlaythrough(projectRoot);
|
|
310
|
-
|
|
311
|
-
console.error(`[${name}] ${
|
|
312
|
-
|
|
414
|
+
const blockingIssues = (audit.staleExampleTestFiles.length > 0 ? [audit.issues.find((issue) => issue.includes("runnable product tests still do"))] : []).filter((issue) => Boolean(issue));
|
|
415
|
+
for (const issue of blockingIssues) console.error(`[${name}] ${issue}`);
|
|
416
|
+
for (const issue of audit.canvasRafIssues) console.warn(`[${name}] WARNING ${issue}`);
|
|
417
|
+
return { name, ok: blockingIssues.length === 0 };
|
|
313
418
|
}
|
|
314
419
|
function styleLintRoots(target) {
|
|
315
420
|
const sourceRoots = (0, import_node_fs2.readdirSync)((0, import_node_path2.join)(projectRoot, "src"), {
|
|
@@ -318,7 +423,7 @@ function styleLintRoots(target) {
|
|
|
318
423
|
return target === "react" && (0, import_node_fs2.existsSync)((0, import_node_path2.join)(projectRoot, "tests")) ? [...sourceRoots, "tests"] : sourceRoots;
|
|
319
424
|
}
|
|
320
425
|
async function runAllChecks(target) {
|
|
321
|
-
const targetChecks = target === "react" ? [checkVitestConfig("react"),
|
|
426
|
+
const targetChecks = target === "react" ? [checkVitestConfig("react"), checkReactSourceSafety()] : [checkPhaser4Dependency(), checkPhaserViteConfig(), checkVitestConfig("phaser")];
|
|
322
427
|
const lintRoots = styleLintRoots(target);
|
|
323
428
|
const [tsgo, biome] = await Promise.all([
|
|
324
429
|
run("tsgo", "@typescript/native-preview", "tsgo", ["-p", "tsconfig.json"]),
|
package/dist/lint/setup.mjs
CHANGED
|
@@ -86,7 +86,27 @@ function getJSDOMWebGLContext(canvas, options) {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
// src/testing/jsdom-canvas.ts
|
|
89
|
-
var
|
|
89
|
+
var registryKey = /* @__PURE__ */ Symbol.for("miaoda-game-devkit:jsdom-canvas-registry");
|
|
90
|
+
var registryScope = globalThis;
|
|
91
|
+
var registry = registryScope[registryKey] ??= {
|
|
92
|
+
contexts: /* @__PURE__ */ new WeakMap(),
|
|
93
|
+
drawCallCounts: /* @__PURE__ */ new WeakMap()
|
|
94
|
+
};
|
|
95
|
+
var { contexts: contexts2, drawCallCounts } = registry;
|
|
96
|
+
var DRAW_METHODS = /* @__PURE__ */ new Set([
|
|
97
|
+
"clearRect",
|
|
98
|
+
"drawImage",
|
|
99
|
+
"fill",
|
|
100
|
+
"fillRect",
|
|
101
|
+
"fillText",
|
|
102
|
+
"putImageData",
|
|
103
|
+
"stroke",
|
|
104
|
+
"strokeRect",
|
|
105
|
+
"strokeText"
|
|
106
|
+
]);
|
|
107
|
+
function getJSDOMCanvasDrawCallCount(canvas) {
|
|
108
|
+
return drawCallCounts.get(canvas) ?? 0;
|
|
109
|
+
}
|
|
90
110
|
function createCanvasContext(canvas) {
|
|
91
111
|
const imageData = (width = 1, height = 1) => ({
|
|
92
112
|
data: new Uint8ClampedArray(width * height * 4),
|
|
@@ -127,7 +147,12 @@ function createCanvasContext(canvas) {
|
|
|
127
147
|
return () => ({ addColorStop() {
|
|
128
148
|
} });
|
|
129
149
|
}
|
|
130
|
-
return () =>
|
|
150
|
+
return () => {
|
|
151
|
+
if (typeof property === "string" && DRAW_METHODS.has(property)) {
|
|
152
|
+
drawCallCounts.set(canvas, getJSDOMCanvasDrawCallCount(canvas) + 1);
|
|
153
|
+
}
|
|
154
|
+
return void 0;
|
|
155
|
+
};
|
|
131
156
|
}
|
|
132
157
|
});
|
|
133
158
|
return context;
|
|
@@ -142,6 +167,7 @@ function installJSDOMCanvasContext() {
|
|
|
142
167
|
if (existing) return existing;
|
|
143
168
|
const context = createCanvasContext(this);
|
|
144
169
|
contexts2.set(this, context);
|
|
170
|
+
drawCallCounts.set(this, 0);
|
|
145
171
|
return context;
|
|
146
172
|
};
|
|
147
173
|
}
|
package/dist/react/index.d.mts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
import { G as GameClock } from '../game-clock-suUidZdT.mjs';
|
|
2
|
+
export { a as GameFrameCallback, b as browserGameClock } from '../game-clock-suUidZdT.mjs';
|
|
3
|
+
import { RefObject } from 'react';
|
|
2
4
|
|
|
3
5
|
interface DisposableGameController {
|
|
4
6
|
destroy(): void;
|
|
@@ -31,4 +33,26 @@ declare function useGameController<TController extends SubscribableGameControlle
|
|
|
31
33
|
state: ReturnType<TController["snapshot"]>;
|
|
32
34
|
};
|
|
33
35
|
|
|
34
|
-
|
|
36
|
+
interface GameCanvasFrame<TState> {
|
|
37
|
+
canvas: HTMLCanvasElement;
|
|
38
|
+
context: CanvasRenderingContext2D;
|
|
39
|
+
state: TState;
|
|
40
|
+
timestamp: number;
|
|
41
|
+
deltaMs: number;
|
|
42
|
+
}
|
|
43
|
+
interface UseGameCanvasOptions<TState> {
|
|
44
|
+
canvasRef: RefObject<HTMLCanvasElement | null>;
|
|
45
|
+
read: () => TState;
|
|
46
|
+
draw: (frame: GameCanvasFrame<TState>) => void;
|
|
47
|
+
clock?: GameClock;
|
|
48
|
+
maxDeltaMs?: number;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Convenience lifecycle for a plain HTML 2D Canvas. This is not the Devkit's renderer
|
|
52
|
+
* abstraction: WebGL/R3F/Pixi or another tool should use its own lifecycle instead.
|
|
53
|
+
* The component using this hook must render the attached canvas on the same mount.
|
|
54
|
+
* Pass the same injected clock used by the game Controller in deterministic tests.
|
|
55
|
+
*/
|
|
56
|
+
declare function useGameCanvas<TState>({ canvasRef, read, draw, clock, maxDeltaMs, }: UseGameCanvasOptions<TState>): void;
|
|
57
|
+
|
|
58
|
+
export { type DisposableGameController, type GameCanvasFrame, GameClock, type SubscribableGameController, type UseGameCanvasOptions, useGameCanvas, useGameController, useOwnedGameController };
|
package/dist/react/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
import { G as GameClock } from '../game-clock-suUidZdT.js';
|
|
2
|
+
export { a as GameFrameCallback, b as browserGameClock } from '../game-clock-suUidZdT.js';
|
|
3
|
+
import { RefObject } from 'react';
|
|
2
4
|
|
|
3
5
|
interface DisposableGameController {
|
|
4
6
|
destroy(): void;
|
|
@@ -31,4 +33,26 @@ declare function useGameController<TController extends SubscribableGameControlle
|
|
|
31
33
|
state: ReturnType<TController["snapshot"]>;
|
|
32
34
|
};
|
|
33
35
|
|
|
34
|
-
|
|
36
|
+
interface GameCanvasFrame<TState> {
|
|
37
|
+
canvas: HTMLCanvasElement;
|
|
38
|
+
context: CanvasRenderingContext2D;
|
|
39
|
+
state: TState;
|
|
40
|
+
timestamp: number;
|
|
41
|
+
deltaMs: number;
|
|
42
|
+
}
|
|
43
|
+
interface UseGameCanvasOptions<TState> {
|
|
44
|
+
canvasRef: RefObject<HTMLCanvasElement | null>;
|
|
45
|
+
read: () => TState;
|
|
46
|
+
draw: (frame: GameCanvasFrame<TState>) => void;
|
|
47
|
+
clock?: GameClock;
|
|
48
|
+
maxDeltaMs?: number;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Convenience lifecycle for a plain HTML 2D Canvas. This is not the Devkit's renderer
|
|
52
|
+
* abstraction: WebGL/R3F/Pixi or another tool should use its own lifecycle instead.
|
|
53
|
+
* The component using this hook must render the attached canvas on the same mount.
|
|
54
|
+
* Pass the same injected clock used by the game Controller in deterministic tests.
|
|
55
|
+
*/
|
|
56
|
+
declare function useGameCanvas<TState>({ canvasRef, read, draw, clock, maxDeltaMs, }: UseGameCanvasOptions<TState>): void;
|
|
57
|
+
|
|
58
|
+
export { type DisposableGameController, type GameCanvasFrame, GameClock, type SubscribableGameController, type UseGameCanvasOptions, useGameCanvas, useGameController, useOwnedGameController };
|
package/dist/react/index.js
CHANGED
|
@@ -21,6 +21,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
21
21
|
var react_exports = {};
|
|
22
22
|
__export(react_exports, {
|
|
23
23
|
browserGameClock: () => browserGameClock,
|
|
24
|
+
useGameCanvas: () => useGameCanvas,
|
|
24
25
|
useGameController: () => useGameController,
|
|
25
26
|
useOwnedGameController: () => useOwnedGameController
|
|
26
27
|
});
|
|
@@ -109,9 +110,59 @@ function useSnapshotIntegrityCheck(game) {
|
|
|
109
110
|
});
|
|
110
111
|
}, [game]);
|
|
111
112
|
}
|
|
113
|
+
|
|
114
|
+
// src/react/use-game-canvas.ts
|
|
115
|
+
var import_react3 = require("react");
|
|
116
|
+
function useGameCanvas({
|
|
117
|
+
canvasRef,
|
|
118
|
+
read,
|
|
119
|
+
draw,
|
|
120
|
+
clock = browserGameClock,
|
|
121
|
+
maxDeltaMs = 50
|
|
122
|
+
}) {
|
|
123
|
+
const readRef = (0, import_react3.useRef)(read);
|
|
124
|
+
const drawRef = (0, import_react3.useRef)(draw);
|
|
125
|
+
readRef.current = read;
|
|
126
|
+
drawRef.current = draw;
|
|
127
|
+
(0, import_react3.useEffect)(() => {
|
|
128
|
+
const canvas = canvasRef.current;
|
|
129
|
+
const context = canvas?.getContext("2d");
|
|
130
|
+
if (!canvas || !context) {
|
|
131
|
+
throw codedError(
|
|
132
|
+
"CANVAS_2D_CONTEXT_UNAVAILABLE",
|
|
133
|
+
"useGameCanvas found no 2D context: the attached <canvas> must be rendered on the same mount as the hook (never conditionally or one tick later). Mount the hook and its <canvas> together in one child component."
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
let active = true;
|
|
137
|
+
let frameId = 0;
|
|
138
|
+
let previousTimestamp = clock.now();
|
|
139
|
+
const renderFrame = (timestamp) => {
|
|
140
|
+
if (!active) return;
|
|
141
|
+
const deltaMs = Math.max(
|
|
142
|
+
0,
|
|
143
|
+
Math.min(maxDeltaMs, timestamp - previousTimestamp)
|
|
144
|
+
);
|
|
145
|
+
previousTimestamp = timestamp;
|
|
146
|
+
drawRef.current({
|
|
147
|
+
canvas,
|
|
148
|
+
context,
|
|
149
|
+
state: readRef.current(),
|
|
150
|
+
timestamp,
|
|
151
|
+
deltaMs
|
|
152
|
+
});
|
|
153
|
+
frameId = clock.requestFrame(renderFrame);
|
|
154
|
+
};
|
|
155
|
+
frameId = clock.requestFrame(renderFrame);
|
|
156
|
+
return () => {
|
|
157
|
+
active = false;
|
|
158
|
+
clock.cancelFrame(frameId);
|
|
159
|
+
};
|
|
160
|
+
}, [canvasRef, clock, maxDeltaMs]);
|
|
161
|
+
}
|
|
112
162
|
// Annotate the CommonJS export names for ESM import in node:
|
|
113
163
|
0 && (module.exports = {
|
|
114
164
|
browserGameClock,
|
|
165
|
+
useGameCanvas,
|
|
115
166
|
useGameController,
|
|
116
167
|
useOwnedGameController
|
|
117
168
|
});
|
package/dist/react/index.mjs
CHANGED
|
@@ -81,8 +81,58 @@ function useSnapshotIntegrityCheck(game) {
|
|
|
81
81
|
});
|
|
82
82
|
}, [game]);
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
// src/react/use-game-canvas.ts
|
|
86
|
+
import { useEffect as useEffect3, useRef as useRef3 } from "react";
|
|
87
|
+
function useGameCanvas({
|
|
88
|
+
canvasRef,
|
|
89
|
+
read,
|
|
90
|
+
draw,
|
|
91
|
+
clock = browserGameClock,
|
|
92
|
+
maxDeltaMs = 50
|
|
93
|
+
}) {
|
|
94
|
+
const readRef = useRef3(read);
|
|
95
|
+
const drawRef = useRef3(draw);
|
|
96
|
+
readRef.current = read;
|
|
97
|
+
drawRef.current = draw;
|
|
98
|
+
useEffect3(() => {
|
|
99
|
+
const canvas = canvasRef.current;
|
|
100
|
+
const context = canvas?.getContext("2d");
|
|
101
|
+
if (!canvas || !context) {
|
|
102
|
+
throw codedError(
|
|
103
|
+
"CANVAS_2D_CONTEXT_UNAVAILABLE",
|
|
104
|
+
"useGameCanvas found no 2D context: the attached <canvas> must be rendered on the same mount as the hook (never conditionally or one tick later). Mount the hook and its <canvas> together in one child component."
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
let active = true;
|
|
108
|
+
let frameId = 0;
|
|
109
|
+
let previousTimestamp = clock.now();
|
|
110
|
+
const renderFrame = (timestamp) => {
|
|
111
|
+
if (!active) return;
|
|
112
|
+
const deltaMs = Math.max(
|
|
113
|
+
0,
|
|
114
|
+
Math.min(maxDeltaMs, timestamp - previousTimestamp)
|
|
115
|
+
);
|
|
116
|
+
previousTimestamp = timestamp;
|
|
117
|
+
drawRef.current({
|
|
118
|
+
canvas,
|
|
119
|
+
context,
|
|
120
|
+
state: readRef.current(),
|
|
121
|
+
timestamp,
|
|
122
|
+
deltaMs
|
|
123
|
+
});
|
|
124
|
+
frameId = clock.requestFrame(renderFrame);
|
|
125
|
+
};
|
|
126
|
+
frameId = clock.requestFrame(renderFrame);
|
|
127
|
+
return () => {
|
|
128
|
+
active = false;
|
|
129
|
+
clock.cancelFrame(frameId);
|
|
130
|
+
};
|
|
131
|
+
}, [canvasRef, clock, maxDeltaMs]);
|
|
132
|
+
}
|
|
84
133
|
export {
|
|
85
134
|
browserGameClock,
|
|
135
|
+
useGameCanvas,
|
|
86
136
|
useGameController,
|
|
87
137
|
useOwnedGameController
|
|
88
138
|
};
|
package/dist/react/testing.d.mts
CHANGED
|
@@ -22,6 +22,9 @@ declare class ManualGameClock implements GameClock {
|
|
|
22
22
|
pendingTimerCount(): number;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** Return recorded 2D drawing calls in JSDOM; real browser canvases return zero. */
|
|
26
|
+
declare function getJSDOMCanvasDrawCallCount(canvas: HTMLCanvasElement): number;
|
|
27
|
+
|
|
25
28
|
interface ReactFailureEntry {
|
|
26
29
|
code: string;
|
|
27
30
|
message: string;
|
|
@@ -166,4 +169,4 @@ declare module "vitest" {
|
|
|
166
169
|
}
|
|
167
170
|
}
|
|
168
171
|
|
|
169
|
-
export { ManualGameClock, type ReactPlaythroughArguments, type ReactPlaythroughAssertContext, type ReactPlaythroughAuditInput, type ReactPlaythroughAuditResult, type ReactPlaythroughAutonomousStage, type ReactPlaythroughEnterStage, type ReactPlaythroughEvidence, type ReactPlaythroughFinishStage, type ReactPlaythroughInteractiveStage, type ReactPlaythroughMetadata, type ReactPlaythroughOptions, type ReactPlaythroughStage, type ReactPlaythroughStageEvidence, type ReactPlaythroughStageKind, type ReactPlaythroughTest, type StepUntilOptions, auditReactPlaythroughRun, playthroughTest };
|
|
172
|
+
export { ManualGameClock, type ReactPlaythroughArguments, type ReactPlaythroughAssertContext, type ReactPlaythroughAuditInput, type ReactPlaythroughAuditResult, type ReactPlaythroughAutonomousStage, type ReactPlaythroughEnterStage, type ReactPlaythroughEvidence, type ReactPlaythroughFinishStage, type ReactPlaythroughInteractiveStage, type ReactPlaythroughMetadata, type ReactPlaythroughOptions, type ReactPlaythroughStage, type ReactPlaythroughStageEvidence, type ReactPlaythroughStageKind, type ReactPlaythroughTest, type StepUntilOptions, auditReactPlaythroughRun, getJSDOMCanvasDrawCallCount, playthroughTest };
|
package/dist/react/testing.d.ts
CHANGED
|
@@ -22,6 +22,9 @@ declare class ManualGameClock implements GameClock {
|
|
|
22
22
|
pendingTimerCount(): number;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** Return recorded 2D drawing calls in JSDOM; real browser canvases return zero. */
|
|
26
|
+
declare function getJSDOMCanvasDrawCallCount(canvas: HTMLCanvasElement): number;
|
|
27
|
+
|
|
25
28
|
interface ReactFailureEntry {
|
|
26
29
|
code: string;
|
|
27
30
|
message: string;
|
|
@@ -166,4 +169,4 @@ declare module "vitest" {
|
|
|
166
169
|
}
|
|
167
170
|
}
|
|
168
171
|
|
|
169
|
-
export { ManualGameClock, type ReactPlaythroughArguments, type ReactPlaythroughAssertContext, type ReactPlaythroughAuditInput, type ReactPlaythroughAuditResult, type ReactPlaythroughAutonomousStage, type ReactPlaythroughEnterStage, type ReactPlaythroughEvidence, type ReactPlaythroughFinishStage, type ReactPlaythroughInteractiveStage, type ReactPlaythroughMetadata, type ReactPlaythroughOptions, type ReactPlaythroughStage, type ReactPlaythroughStageEvidence, type ReactPlaythroughStageKind, type ReactPlaythroughTest, type StepUntilOptions, auditReactPlaythroughRun, playthroughTest };
|
|
172
|
+
export { ManualGameClock, type ReactPlaythroughArguments, type ReactPlaythroughAssertContext, type ReactPlaythroughAuditInput, type ReactPlaythroughAuditResult, type ReactPlaythroughAutonomousStage, type ReactPlaythroughEnterStage, type ReactPlaythroughEvidence, type ReactPlaythroughFinishStage, type ReactPlaythroughInteractiveStage, type ReactPlaythroughMetadata, type ReactPlaythroughOptions, type ReactPlaythroughStage, type ReactPlaythroughStageEvidence, type ReactPlaythroughStageKind, type ReactPlaythroughTest, type StepUntilOptions, auditReactPlaythroughRun, getJSDOMCanvasDrawCallCount, playthroughTest };
|
package/dist/react/testing.js
CHANGED
|
@@ -32,6 +32,7 @@ var testing_exports = {};
|
|
|
32
32
|
__export(testing_exports, {
|
|
33
33
|
ManualGameClock: () => ManualGameClock,
|
|
34
34
|
auditReactPlaythroughRun: () => auditReactPlaythroughRun,
|
|
35
|
+
getJSDOMCanvasDrawCallCount: () => getJSDOMCanvasDrawCallCount,
|
|
35
36
|
playthroughTest: () => playthroughTest
|
|
36
37
|
});
|
|
37
38
|
module.exports = __toCommonJS(testing_exports);
|
|
@@ -91,6 +92,18 @@ var ManualGameClock = class {
|
|
|
91
92
|
}
|
|
92
93
|
};
|
|
93
94
|
|
|
95
|
+
// src/testing/jsdom-canvas.ts
|
|
96
|
+
var registryKey = /* @__PURE__ */ Symbol.for("miaoda-game-devkit:jsdom-canvas-registry");
|
|
97
|
+
var registryScope = globalThis;
|
|
98
|
+
var registry = registryScope[registryKey] ??= {
|
|
99
|
+
contexts: /* @__PURE__ */ new WeakMap(),
|
|
100
|
+
drawCallCounts: /* @__PURE__ */ new WeakMap()
|
|
101
|
+
};
|
|
102
|
+
var { contexts, drawCallCounts } = registry;
|
|
103
|
+
function getJSDOMCanvasDrawCallCount(canvas) {
|
|
104
|
+
return drawCallCounts.get(canvas) ?? 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
94
107
|
// src/react/react-playthrough.ts
|
|
95
108
|
var import_react2 = require("@testing-library/react");
|
|
96
109
|
var import_user_event = __toESM(require("@testing-library/user-event"));
|
|
@@ -775,5 +788,6 @@ function auditReactPlaythroughRun(tests) {
|
|
|
775
788
|
0 && (module.exports = {
|
|
776
789
|
ManualGameClock,
|
|
777
790
|
auditReactPlaythroughRun,
|
|
791
|
+
getJSDOMCanvasDrawCallCount,
|
|
778
792
|
playthroughTest
|
|
779
793
|
});
|
package/dist/react/testing.mjs
CHANGED
|
@@ -53,6 +53,18 @@ var ManualGameClock = class {
|
|
|
53
53
|
}
|
|
54
54
|
};
|
|
55
55
|
|
|
56
|
+
// src/testing/jsdom-canvas.ts
|
|
57
|
+
var registryKey = /* @__PURE__ */ Symbol.for("miaoda-game-devkit:jsdom-canvas-registry");
|
|
58
|
+
var registryScope = globalThis;
|
|
59
|
+
var registry = registryScope[registryKey] ??= {
|
|
60
|
+
contexts: /* @__PURE__ */ new WeakMap(),
|
|
61
|
+
drawCallCounts: /* @__PURE__ */ new WeakMap()
|
|
62
|
+
};
|
|
63
|
+
var { contexts, drawCallCounts } = registry;
|
|
64
|
+
function getJSDOMCanvasDrawCallCount(canvas) {
|
|
65
|
+
return drawCallCounts.get(canvas) ?? 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
56
68
|
// src/react/react-playthrough.ts
|
|
57
69
|
import { render } from "@testing-library/react";
|
|
58
70
|
import userEvent from "@testing-library/user-event";
|
|
@@ -736,5 +748,6 @@ function auditReactPlaythroughRun(tests) {
|
|
|
736
748
|
export {
|
|
737
749
|
ManualGameClock,
|
|
738
750
|
auditReactPlaythroughRun,
|
|
751
|
+
getJSDOMCanvasDrawCallCount,
|
|
739
752
|
playthroughTest
|
|
740
753
|
};
|
|
@@ -1059,6 +1059,22 @@ var CONTROLLER_IMPORTS = /* @__PURE__ */ new Set([
|
|
|
1059
1059
|
"useOwnedGameController"
|
|
1060
1060
|
]);
|
|
1061
1061
|
var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
|
|
1062
|
+
var KNOWN_PER_FRAME_CANVAS_DEPENDENCIES = /* @__PURE__ */ new Set([
|
|
1063
|
+
"ball",
|
|
1064
|
+
"bricks",
|
|
1065
|
+
"enemies",
|
|
1066
|
+
"entities",
|
|
1067
|
+
"entity",
|
|
1068
|
+
"elapsedMs",
|
|
1069
|
+
"elapsedTimeMs",
|
|
1070
|
+
"hoveredCell",
|
|
1071
|
+
"paddle",
|
|
1072
|
+
"particles",
|
|
1073
|
+
"player",
|
|
1074
|
+
"projectiles",
|
|
1075
|
+
"snapshot",
|
|
1076
|
+
"state"
|
|
1077
|
+
]);
|
|
1062
1078
|
function runnableTestFiles(root, directory = (0, import_node_path2.join)(root, "tests")) {
|
|
1063
1079
|
if (!(0, import_node_fs.existsSync)(directory)) return [];
|
|
1064
1080
|
const files = [];
|
|
@@ -1152,6 +1168,88 @@ function containsAny(values, expected) {
|
|
|
1152
1168
|
function isRecord(value) {
|
|
1153
1169
|
return typeof value === "object" && value !== null;
|
|
1154
1170
|
}
|
|
1171
|
+
function visitAst(value, visitor) {
|
|
1172
|
+
if (Array.isArray(value)) {
|
|
1173
|
+
for (const item of value) visitAst(item, visitor);
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
if (!isRecord(value)) return;
|
|
1177
|
+
if (typeof value.type === "string") visitor(value);
|
|
1178
|
+
for (const child of Object.values(value)) visitAst(child, visitor);
|
|
1179
|
+
}
|
|
1180
|
+
function identifierName(value) {
|
|
1181
|
+
return isRecord(value) && value.type === "Identifier" && typeof value.name === "string" ? value.name : void 0;
|
|
1182
|
+
}
|
|
1183
|
+
function memberPath(value) {
|
|
1184
|
+
if (!isRecord(value)) return [];
|
|
1185
|
+
const identifier = identifierName(value);
|
|
1186
|
+
if (identifier) return [identifier];
|
|
1187
|
+
if (value.type !== "MemberExpression") return [];
|
|
1188
|
+
return [...memberPath(value.object), ...identifierName(value.property) ? [identifierName(value.property)] : []];
|
|
1189
|
+
}
|
|
1190
|
+
function hasRepeatedAnimationFrameCallback(value) {
|
|
1191
|
+
const callbackCounts = /* @__PURE__ */ new Map();
|
|
1192
|
+
visitAst(value, (node) => {
|
|
1193
|
+
if (node.type !== "CallExpression") return;
|
|
1194
|
+
if (identifierName(node.callee) !== "requestAnimationFrame") return;
|
|
1195
|
+
const arguments_ = Array.isArray(node.arguments) ? node.arguments : [];
|
|
1196
|
+
const callback = identifierName(arguments_[0]);
|
|
1197
|
+
if (!callback) return;
|
|
1198
|
+
callbackCounts.set(callback, (callbackCounts.get(callback) ?? 0) + 1);
|
|
1199
|
+
});
|
|
1200
|
+
return [...callbackCounts.values()].some((count) => count >= 2);
|
|
1201
|
+
}
|
|
1202
|
+
function reactEffectNames(file, source) {
|
|
1203
|
+
const names = /* @__PURE__ */ new Set(["useEffect"]);
|
|
1204
|
+
for (const declaration of staticImports(file, source)) {
|
|
1205
|
+
if (declaration.moduleRequest.value !== "react") continue;
|
|
1206
|
+
for (const entry of declaration.entries) {
|
|
1207
|
+
if (entry.importName.kind === "Name" && entry.importName.name === "useEffect") {
|
|
1208
|
+
names.add(entry.localName.value);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
return names;
|
|
1213
|
+
}
|
|
1214
|
+
function isReactEffectCall(node, effectNames) {
|
|
1215
|
+
if (node.type !== "CallExpression") return false;
|
|
1216
|
+
const directName = identifierName(node.callee);
|
|
1217
|
+
if (directName && effectNames.has(directName)) return true;
|
|
1218
|
+
const path = memberPath(node.callee);
|
|
1219
|
+
return path.length === 2 && path[1] === "useEffect";
|
|
1220
|
+
}
|
|
1221
|
+
function canvasRafDependencyIssues(file, source, projectRoot) {
|
|
1222
|
+
const ownsCanvas = /<canvas\b|createElement\s*\(\s*["']canvas["']|getContext\s*\(\s*["']2d["']/.test(
|
|
1223
|
+
source
|
|
1224
|
+
);
|
|
1225
|
+
if (!ownsCanvas) return [];
|
|
1226
|
+
const issues = [];
|
|
1227
|
+
const effectNames = reactEffectNames(file, source);
|
|
1228
|
+
visitAst(parseSource(file, source).program, (node) => {
|
|
1229
|
+
if (!isReactEffectCall(node, effectNames)) return;
|
|
1230
|
+
const arguments_ = Array.isArray(node.arguments) ? node.arguments : [];
|
|
1231
|
+
if (!hasRepeatedAnimationFrameCallback(arguments_[0])) return;
|
|
1232
|
+
const dependencies = arguments_[1];
|
|
1233
|
+
if (!isRecord(dependencies) || dependencies.type !== "ArrayExpression") return;
|
|
1234
|
+
const elements = Array.isArray(dependencies.elements) ? dependencies.elements : [];
|
|
1235
|
+
const unsafe = elements.filter(
|
|
1236
|
+
(element) => memberPath(element).some((name) => KNOWN_PER_FRAME_CANVAS_DEPENDENCIES.has(name))
|
|
1237
|
+
);
|
|
1238
|
+
if (unsafe.length === 0) return;
|
|
1239
|
+
const labels = unsafe.map((element) => {
|
|
1240
|
+
if (!isRecord(element) || typeof element.start !== "number" || typeof element.end !== "number") {
|
|
1241
|
+
return "dynamic state";
|
|
1242
|
+
}
|
|
1243
|
+
return source.slice(element.start, element.end);
|
|
1244
|
+
});
|
|
1245
|
+
const line = typeof node.start === "number" ? source.slice(0, node.start).split("\n").length : 1;
|
|
1246
|
+
const path = (0, import_node_path2.relative)(projectRoot, file).replaceAll("\\", "/");
|
|
1247
|
+
issues.push(
|
|
1248
|
+
`CANVAS_RAF_DYNAMIC_DEPENDENCY ${path}:${line}: a recurring frame loop depends on ${labels.join(", ")}. React cleanup cancels and recreates the pending frame whenever these values change, which can freeze Canvas while HUD state keeps updating. Keep the loop effect stable and read changing state through a ref; a plain app-owned 2D Canvas may optionally use useGameCanvas from miaoda-game-devkit/react.`
|
|
1249
|
+
);
|
|
1250
|
+
});
|
|
1251
|
+
return issues;
|
|
1252
|
+
}
|
|
1155
1253
|
function containsObserveProperty(value) {
|
|
1156
1254
|
if (Array.isArray(value)) return value.some(containsObserveProperty);
|
|
1157
1255
|
if (!isRecord(value)) return false;
|
|
@@ -1167,17 +1265,20 @@ function declaresObserve(file, source) {
|
|
|
1167
1265
|
function auditReactAuthoritativePlaythrough(projectRoot) {
|
|
1168
1266
|
const clockFiles = [];
|
|
1169
1267
|
const controllerFiles = [];
|
|
1268
|
+
const canvasRafIssues = [];
|
|
1170
1269
|
const productionFiles = sourceFiles(projectRoot);
|
|
1171
1270
|
for (const file of productionFiles) {
|
|
1271
|
+
const source = (0, import_node_fs.readFileSync)(file, "utf8");
|
|
1172
1272
|
const imports = namedImports(
|
|
1173
1273
|
file,
|
|
1174
|
-
|
|
1274
|
+
source,
|
|
1175
1275
|
REACT_RUNTIME_ENTRY
|
|
1176
1276
|
);
|
|
1177
1277
|
const projectPath = (0, import_node_path2.relative)(projectRoot, file).replaceAll("\\", "/");
|
|
1178
1278
|
if (containsAny(imports, CLOCK_IMPORTS)) clockFiles.push(projectPath);
|
|
1179
1279
|
if (containsAny(imports, CONTROLLER_IMPORTS))
|
|
1180
1280
|
controllerFiles.push(projectPath);
|
|
1281
|
+
canvasRafIssues.push(...canvasRafDependencyIssues(file, source, projectRoot));
|
|
1181
1282
|
}
|
|
1182
1283
|
const appPath = (0, import_node_path2.join)(projectRoot, PRODUCTION_APP);
|
|
1183
1284
|
const productionEntryExists = (0, import_node_fs.existsSync)(appPath);
|
|
@@ -1188,6 +1289,7 @@ function auditReactAuthoritativePlaythrough(projectRoot) {
|
|
|
1188
1289
|
(file) => importsExampleAlias(file, (0, import_node_fs.readFileSync)(file, "utf8"))
|
|
1189
1290
|
).map((file) => (0, import_node_path2.relative)(projectRoot, file).replaceAll("\\", "/"));
|
|
1190
1291
|
const issues = [];
|
|
1292
|
+
issues.push(...canvasRafIssues);
|
|
1191
1293
|
if (staleExampleTestFiles.length > 0) {
|
|
1192
1294
|
issues.push(
|
|
1193
1295
|
`Production ${PRODUCTION_APP} no longer imports ${EXAMPLE_IMPORT_PREFIX}*, but runnable product tests still do: ${staleExampleTestFiles.join(", ")}. Replace those CollectGame tests with tests for the production game or delete genuinely inapplicable slots. Teaching examples under tests/examples/**/*.example.* remain allowed.`
|
|
@@ -1201,7 +1303,8 @@ function auditReactAuthoritativePlaythrough(projectRoot) {
|
|
|
1201
1303
|
controllerFiles,
|
|
1202
1304
|
productionEntryExists,
|
|
1203
1305
|
productionUsesExample,
|
|
1204
|
-
staleExampleTestFiles
|
|
1306
|
+
staleExampleTestFiles,
|
|
1307
|
+
canvasRafIssues
|
|
1205
1308
|
};
|
|
1206
1309
|
}
|
|
1207
1310
|
const testPath = (0, import_node_path2.join)(projectRoot, PRODUCTION_PLAYTHROUGH);
|
|
@@ -1229,7 +1332,8 @@ function auditReactAuthoritativePlaythrough(projectRoot) {
|
|
|
1229
1332
|
controllerFiles,
|
|
1230
1333
|
productionEntryExists,
|
|
1231
1334
|
productionUsesExample,
|
|
1232
|
-
staleExampleTestFiles
|
|
1335
|
+
staleExampleTestFiles,
|
|
1336
|
+
canvasRafIssues
|
|
1233
1337
|
};
|
|
1234
1338
|
}
|
|
1235
1339
|
|
|
@@ -1025,6 +1025,22 @@ var CONTROLLER_IMPORTS = /* @__PURE__ */ new Set([
|
|
|
1025
1025
|
"useOwnedGameController"
|
|
1026
1026
|
]);
|
|
1027
1027
|
var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
|
|
1028
|
+
var KNOWN_PER_FRAME_CANVAS_DEPENDENCIES = /* @__PURE__ */ new Set([
|
|
1029
|
+
"ball",
|
|
1030
|
+
"bricks",
|
|
1031
|
+
"enemies",
|
|
1032
|
+
"entities",
|
|
1033
|
+
"entity",
|
|
1034
|
+
"elapsedMs",
|
|
1035
|
+
"elapsedTimeMs",
|
|
1036
|
+
"hoveredCell",
|
|
1037
|
+
"paddle",
|
|
1038
|
+
"particles",
|
|
1039
|
+
"player",
|
|
1040
|
+
"projectiles",
|
|
1041
|
+
"snapshot",
|
|
1042
|
+
"state"
|
|
1043
|
+
]);
|
|
1028
1044
|
function runnableTestFiles(root, directory = join(root, "tests")) {
|
|
1029
1045
|
if (!existsSync(directory)) return [];
|
|
1030
1046
|
const files = [];
|
|
@@ -1118,6 +1134,88 @@ function containsAny(values, expected) {
|
|
|
1118
1134
|
function isRecord(value) {
|
|
1119
1135
|
return typeof value === "object" && value !== null;
|
|
1120
1136
|
}
|
|
1137
|
+
function visitAst(value, visitor) {
|
|
1138
|
+
if (Array.isArray(value)) {
|
|
1139
|
+
for (const item of value) visitAst(item, visitor);
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
if (!isRecord(value)) return;
|
|
1143
|
+
if (typeof value.type === "string") visitor(value);
|
|
1144
|
+
for (const child of Object.values(value)) visitAst(child, visitor);
|
|
1145
|
+
}
|
|
1146
|
+
function identifierName(value) {
|
|
1147
|
+
return isRecord(value) && value.type === "Identifier" && typeof value.name === "string" ? value.name : void 0;
|
|
1148
|
+
}
|
|
1149
|
+
function memberPath(value) {
|
|
1150
|
+
if (!isRecord(value)) return [];
|
|
1151
|
+
const identifier = identifierName(value);
|
|
1152
|
+
if (identifier) return [identifier];
|
|
1153
|
+
if (value.type !== "MemberExpression") return [];
|
|
1154
|
+
return [...memberPath(value.object), ...identifierName(value.property) ? [identifierName(value.property)] : []];
|
|
1155
|
+
}
|
|
1156
|
+
function hasRepeatedAnimationFrameCallback(value) {
|
|
1157
|
+
const callbackCounts = /* @__PURE__ */ new Map();
|
|
1158
|
+
visitAst(value, (node) => {
|
|
1159
|
+
if (node.type !== "CallExpression") return;
|
|
1160
|
+
if (identifierName(node.callee) !== "requestAnimationFrame") return;
|
|
1161
|
+
const arguments_ = Array.isArray(node.arguments) ? node.arguments : [];
|
|
1162
|
+
const callback = identifierName(arguments_[0]);
|
|
1163
|
+
if (!callback) return;
|
|
1164
|
+
callbackCounts.set(callback, (callbackCounts.get(callback) ?? 0) + 1);
|
|
1165
|
+
});
|
|
1166
|
+
return [...callbackCounts.values()].some((count) => count >= 2);
|
|
1167
|
+
}
|
|
1168
|
+
function reactEffectNames(file, source) {
|
|
1169
|
+
const names = /* @__PURE__ */ new Set(["useEffect"]);
|
|
1170
|
+
for (const declaration of staticImports(file, source)) {
|
|
1171
|
+
if (declaration.moduleRequest.value !== "react") continue;
|
|
1172
|
+
for (const entry of declaration.entries) {
|
|
1173
|
+
if (entry.importName.kind === "Name" && entry.importName.name === "useEffect") {
|
|
1174
|
+
names.add(entry.localName.value);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
return names;
|
|
1179
|
+
}
|
|
1180
|
+
function isReactEffectCall(node, effectNames) {
|
|
1181
|
+
if (node.type !== "CallExpression") return false;
|
|
1182
|
+
const directName = identifierName(node.callee);
|
|
1183
|
+
if (directName && effectNames.has(directName)) return true;
|
|
1184
|
+
const path = memberPath(node.callee);
|
|
1185
|
+
return path.length === 2 && path[1] === "useEffect";
|
|
1186
|
+
}
|
|
1187
|
+
function canvasRafDependencyIssues(file, source, projectRoot) {
|
|
1188
|
+
const ownsCanvas = /<canvas\b|createElement\s*\(\s*["']canvas["']|getContext\s*\(\s*["']2d["']/.test(
|
|
1189
|
+
source
|
|
1190
|
+
);
|
|
1191
|
+
if (!ownsCanvas) return [];
|
|
1192
|
+
const issues = [];
|
|
1193
|
+
const effectNames = reactEffectNames(file, source);
|
|
1194
|
+
visitAst(parseSource(file, source).program, (node) => {
|
|
1195
|
+
if (!isReactEffectCall(node, effectNames)) return;
|
|
1196
|
+
const arguments_ = Array.isArray(node.arguments) ? node.arguments : [];
|
|
1197
|
+
if (!hasRepeatedAnimationFrameCallback(arguments_[0])) return;
|
|
1198
|
+
const dependencies = arguments_[1];
|
|
1199
|
+
if (!isRecord(dependencies) || dependencies.type !== "ArrayExpression") return;
|
|
1200
|
+
const elements = Array.isArray(dependencies.elements) ? dependencies.elements : [];
|
|
1201
|
+
const unsafe = elements.filter(
|
|
1202
|
+
(element) => memberPath(element).some((name) => KNOWN_PER_FRAME_CANVAS_DEPENDENCIES.has(name))
|
|
1203
|
+
);
|
|
1204
|
+
if (unsafe.length === 0) return;
|
|
1205
|
+
const labels = unsafe.map((element) => {
|
|
1206
|
+
if (!isRecord(element) || typeof element.start !== "number" || typeof element.end !== "number") {
|
|
1207
|
+
return "dynamic state";
|
|
1208
|
+
}
|
|
1209
|
+
return source.slice(element.start, element.end);
|
|
1210
|
+
});
|
|
1211
|
+
const line = typeof node.start === "number" ? source.slice(0, node.start).split("\n").length : 1;
|
|
1212
|
+
const path = relative2(projectRoot, file).replaceAll("\\", "/");
|
|
1213
|
+
issues.push(
|
|
1214
|
+
`CANVAS_RAF_DYNAMIC_DEPENDENCY ${path}:${line}: a recurring frame loop depends on ${labels.join(", ")}. React cleanup cancels and recreates the pending frame whenever these values change, which can freeze Canvas while HUD state keeps updating. Keep the loop effect stable and read changing state through a ref; a plain app-owned 2D Canvas may optionally use useGameCanvas from miaoda-game-devkit/react.`
|
|
1215
|
+
);
|
|
1216
|
+
});
|
|
1217
|
+
return issues;
|
|
1218
|
+
}
|
|
1121
1219
|
function containsObserveProperty(value) {
|
|
1122
1220
|
if (Array.isArray(value)) return value.some(containsObserveProperty);
|
|
1123
1221
|
if (!isRecord(value)) return false;
|
|
@@ -1133,17 +1231,20 @@ function declaresObserve(file, source) {
|
|
|
1133
1231
|
function auditReactAuthoritativePlaythrough(projectRoot) {
|
|
1134
1232
|
const clockFiles = [];
|
|
1135
1233
|
const controllerFiles = [];
|
|
1234
|
+
const canvasRafIssues = [];
|
|
1136
1235
|
const productionFiles = sourceFiles(projectRoot);
|
|
1137
1236
|
for (const file of productionFiles) {
|
|
1237
|
+
const source = readFileSync(file, "utf8");
|
|
1138
1238
|
const imports = namedImports(
|
|
1139
1239
|
file,
|
|
1140
|
-
|
|
1240
|
+
source,
|
|
1141
1241
|
REACT_RUNTIME_ENTRY
|
|
1142
1242
|
);
|
|
1143
1243
|
const projectPath = relative2(projectRoot, file).replaceAll("\\", "/");
|
|
1144
1244
|
if (containsAny(imports, CLOCK_IMPORTS)) clockFiles.push(projectPath);
|
|
1145
1245
|
if (containsAny(imports, CONTROLLER_IMPORTS))
|
|
1146
1246
|
controllerFiles.push(projectPath);
|
|
1247
|
+
canvasRafIssues.push(...canvasRafDependencyIssues(file, source, projectRoot));
|
|
1147
1248
|
}
|
|
1148
1249
|
const appPath = join(projectRoot, PRODUCTION_APP);
|
|
1149
1250
|
const productionEntryExists = existsSync(appPath);
|
|
@@ -1154,6 +1255,7 @@ function auditReactAuthoritativePlaythrough(projectRoot) {
|
|
|
1154
1255
|
(file) => importsExampleAlias(file, readFileSync(file, "utf8"))
|
|
1155
1256
|
).map((file) => relative2(projectRoot, file).replaceAll("\\", "/"));
|
|
1156
1257
|
const issues = [];
|
|
1258
|
+
issues.push(...canvasRafIssues);
|
|
1157
1259
|
if (staleExampleTestFiles.length > 0) {
|
|
1158
1260
|
issues.push(
|
|
1159
1261
|
`Production ${PRODUCTION_APP} no longer imports ${EXAMPLE_IMPORT_PREFIX}*, but runnable product tests still do: ${staleExampleTestFiles.join(", ")}. Replace those CollectGame tests with tests for the production game or delete genuinely inapplicable slots. Teaching examples under tests/examples/**/*.example.* remain allowed.`
|
|
@@ -1167,7 +1269,8 @@ function auditReactAuthoritativePlaythrough(projectRoot) {
|
|
|
1167
1269
|
controllerFiles,
|
|
1168
1270
|
productionEntryExists,
|
|
1169
1271
|
productionUsesExample,
|
|
1170
|
-
staleExampleTestFiles
|
|
1272
|
+
staleExampleTestFiles,
|
|
1273
|
+
canvasRafIssues
|
|
1171
1274
|
};
|
|
1172
1275
|
}
|
|
1173
1276
|
const testPath = join(projectRoot, PRODUCTION_PLAYTHROUGH);
|
|
@@ -1195,7 +1298,8 @@ function auditReactAuthoritativePlaythrough(projectRoot) {
|
|
|
1195
1298
|
controllerFiles,
|
|
1196
1299
|
productionEntryExists,
|
|
1197
1300
|
productionUsesExample,
|
|
1198
|
-
staleExampleTestFiles
|
|
1301
|
+
staleExampleTestFiles,
|
|
1302
|
+
canvasRafIssues
|
|
1199
1303
|
};
|
|
1200
1304
|
}
|
|
1201
1305
|
|
|
@@ -269,7 +269,27 @@ function getJSDOMWebGLContext(canvas, options) {
|
|
|
269
269
|
}
|
|
270
270
|
|
|
271
271
|
// src/testing/jsdom-canvas.ts
|
|
272
|
-
var
|
|
272
|
+
var registryKey = /* @__PURE__ */ Symbol.for("miaoda-game-devkit:jsdom-canvas-registry");
|
|
273
|
+
var registryScope = globalThis;
|
|
274
|
+
var registry = registryScope[registryKey] ??= {
|
|
275
|
+
contexts: /* @__PURE__ */ new WeakMap(),
|
|
276
|
+
drawCallCounts: /* @__PURE__ */ new WeakMap()
|
|
277
|
+
};
|
|
278
|
+
var { contexts: contexts2, drawCallCounts } = registry;
|
|
279
|
+
var DRAW_METHODS = /* @__PURE__ */ new Set([
|
|
280
|
+
"clearRect",
|
|
281
|
+
"drawImage",
|
|
282
|
+
"fill",
|
|
283
|
+
"fillRect",
|
|
284
|
+
"fillText",
|
|
285
|
+
"putImageData",
|
|
286
|
+
"stroke",
|
|
287
|
+
"strokeRect",
|
|
288
|
+
"strokeText"
|
|
289
|
+
]);
|
|
290
|
+
function getJSDOMCanvasDrawCallCount(canvas) {
|
|
291
|
+
return drawCallCounts.get(canvas) ?? 0;
|
|
292
|
+
}
|
|
273
293
|
function createCanvasContext(canvas) {
|
|
274
294
|
const imageData = (width = 1, height = 1) => ({
|
|
275
295
|
data: new Uint8ClampedArray(width * height * 4),
|
|
@@ -310,7 +330,12 @@ function createCanvasContext(canvas) {
|
|
|
310
330
|
return () => ({ addColorStop() {
|
|
311
331
|
} });
|
|
312
332
|
}
|
|
313
|
-
return () =>
|
|
333
|
+
return () => {
|
|
334
|
+
if (typeof property === "string" && DRAW_METHODS.has(property)) {
|
|
335
|
+
drawCallCounts.set(canvas, getJSDOMCanvasDrawCallCount(canvas) + 1);
|
|
336
|
+
}
|
|
337
|
+
return void 0;
|
|
338
|
+
};
|
|
314
339
|
}
|
|
315
340
|
});
|
|
316
341
|
return context;
|
|
@@ -325,6 +350,7 @@ function installJSDOMCanvasContext() {
|
|
|
325
350
|
if (existing) return existing;
|
|
326
351
|
const context = createCanvasContext(this);
|
|
327
352
|
contexts2.set(this, context);
|
|
353
|
+
drawCallCounts.set(this, 0);
|
|
328
354
|
return context;
|
|
329
355
|
};
|
|
330
356
|
}
|
|
@@ -267,7 +267,27 @@ function getJSDOMWebGLContext(canvas, options) {
|
|
|
267
267
|
}
|
|
268
268
|
|
|
269
269
|
// src/testing/jsdom-canvas.ts
|
|
270
|
-
var
|
|
270
|
+
var registryKey = /* @__PURE__ */ Symbol.for("miaoda-game-devkit:jsdom-canvas-registry");
|
|
271
|
+
var registryScope = globalThis;
|
|
272
|
+
var registry = registryScope[registryKey] ??= {
|
|
273
|
+
contexts: /* @__PURE__ */ new WeakMap(),
|
|
274
|
+
drawCallCounts: /* @__PURE__ */ new WeakMap()
|
|
275
|
+
};
|
|
276
|
+
var { contexts: contexts2, drawCallCounts } = registry;
|
|
277
|
+
var DRAW_METHODS = /* @__PURE__ */ new Set([
|
|
278
|
+
"clearRect",
|
|
279
|
+
"drawImage",
|
|
280
|
+
"fill",
|
|
281
|
+
"fillRect",
|
|
282
|
+
"fillText",
|
|
283
|
+
"putImageData",
|
|
284
|
+
"stroke",
|
|
285
|
+
"strokeRect",
|
|
286
|
+
"strokeText"
|
|
287
|
+
]);
|
|
288
|
+
function getJSDOMCanvasDrawCallCount(canvas) {
|
|
289
|
+
return drawCallCounts.get(canvas) ?? 0;
|
|
290
|
+
}
|
|
271
291
|
function createCanvasContext(canvas) {
|
|
272
292
|
const imageData = (width = 1, height = 1) => ({
|
|
273
293
|
data: new Uint8ClampedArray(width * height * 4),
|
|
@@ -308,7 +328,12 @@ function createCanvasContext(canvas) {
|
|
|
308
328
|
return () => ({ addColorStop() {
|
|
309
329
|
} });
|
|
310
330
|
}
|
|
311
|
-
return () =>
|
|
331
|
+
return () => {
|
|
332
|
+
if (typeof property === "string" && DRAW_METHODS.has(property)) {
|
|
333
|
+
drawCallCounts.set(canvas, getJSDOMCanvasDrawCallCount(canvas) + 1);
|
|
334
|
+
}
|
|
335
|
+
return void 0;
|
|
336
|
+
};
|
|
312
337
|
}
|
|
313
338
|
});
|
|
314
339
|
return context;
|
|
@@ -323,6 +348,7 @@ function installJSDOMCanvasContext() {
|
|
|
323
348
|
if (existing) return existing;
|
|
324
349
|
const context = createCanvasContext(this);
|
|
325
350
|
contexts2.set(this, context);
|
|
351
|
+
drawCallCounts.set(this, 0);
|
|
326
352
|
return context;
|
|
327
353
|
};
|
|
328
354
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "miaoda-game-devkit",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.5",
|
|
4
4
|
"description": "Shared React and Phaser game lint plus deterministic testing tools for Miaoda games",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -164,46 +164,8 @@
|
|
|
164
164
|
"optional": true
|
|
165
165
|
}
|
|
166
166
|
},
|
|
167
|
-
"devDependencies": {
|
|
168
|
-
"@testing-library/jest-dom": "7.0.0",
|
|
169
|
-
"@testing-library/react": "16.3.2",
|
|
170
|
-
"@testing-library/user-event": "14.6.1",
|
|
171
|
-
"@types/eslint": "^9.6.1",
|
|
172
|
-
"@types/estree": "^1.0.9",
|
|
173
|
-
"@types/node": "^24.13.3",
|
|
174
|
-
"@types/react": "19.2.10",
|
|
175
|
-
"clean-publish": "^6.0.5",
|
|
176
|
-
"phaser": "4.2.1",
|
|
177
|
-
"phaser4-rex-plugins": "4.2.0",
|
|
178
|
-
"react": "19.2.4",
|
|
179
|
-
"react-dom": "19.2.4",
|
|
180
|
-
"tsup": "^8.5.1",
|
|
181
|
-
"typescript": "^5.9.3",
|
|
182
|
-
"vite": "8.2.0"
|
|
183
|
-
},
|
|
184
167
|
"publishConfig": {
|
|
185
168
|
"registry": "https://registry.npmjs.org/",
|
|
186
169
|
"access": "public"
|
|
187
|
-
},
|
|
188
|
-
"clean-publish": {
|
|
189
|
-
"fields": [
|
|
190
|
-
"devDependencies",
|
|
191
|
-
"scripts"
|
|
192
|
-
]
|
|
193
|
-
},
|
|
194
|
-
"scripts": {
|
|
195
|
-
"build": "tsup",
|
|
196
|
-
"release": "pnpm run test && clean-publish",
|
|
197
|
-
"release:dry-run": "pnpm run test && clean-publish --dry-run",
|
|
198
|
-
"release:beta": "pnpm run test && npm version prerelease --preid=beta && clean-publish --tag beta",
|
|
199
|
-
"release:patch": "pnpm run test && npm version patch && clean-publish",
|
|
200
|
-
"release:minor": "pnpm run test && npm version minor && clean-publish",
|
|
201
|
-
"release:major": "pnpm run test && npm version major && clean-publish",
|
|
202
|
-
"lint": "biome lint --config-path biome-config.json src && node scripts/check-line-length.mjs",
|
|
203
|
-
"typecheck": "tsc --noEmit",
|
|
204
|
-
"test": "pnpm run build && pnpm run test:contracts && pnpm run test:mechanics && pnpm run test:published",
|
|
205
|
-
"test:contracts": "vitest run --config dist/lint/contracts.config.mjs",
|
|
206
|
-
"test:mechanics": "node --test mechanics/materialize-game-mechanics-source.test.mjs",
|
|
207
|
-
"test:published": "node scripts/test-published-package.mjs"
|
|
208
170
|
}
|
|
209
|
-
}
|
|
171
|
+
}
|