miaoda-game-devkit 0.1.0 → 0.2.0

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.
@@ -0,0 +1,32 @@
1
+ // src/lint/phaser-text-assertions.test.ts
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ // src/phaser-text-assertions.ts
5
+ import * as Phaser from "phaser";
6
+ function collectMissingBitmapGlyphs(text, chars) {
7
+ const missing = [];
8
+ const seen = /* @__PURE__ */ new Set();
9
+ for (let index = 0; index < text.length; index += 1) {
10
+ const character = text[index];
11
+ if (/\s/u.test(character)) continue;
12
+ const code = text.charCodeAt(index);
13
+ if (seen.has(code) || chars[code] !== void 0) continue;
14
+ seen.add(code);
15
+ missing.push(
16
+ `${JSON.stringify(character)} (U+${code.toString(16).toUpperCase().padStart(4, "0")})`
17
+ );
18
+ }
19
+ return missing;
20
+ }
21
+
22
+ // src/lint/phaser-text-assertions.test.ts
23
+ describe("Phaser text health rules", () => {
24
+ it("ignores whitespace-only BitmapText content", () => {
25
+ expect(collectMissingBitmapGlyphs(" \n ", {})).toEqual([]);
26
+ });
27
+ it("accepts covered Unicode code units and reports missing ones once", () => {
28
+ expect(collectMissingBitmapGlyphs("\u4E2D\u6587\u4E2D", { ["\u4E2D".charCodeAt(0)]: {} })).toEqual([
29
+ '"\u6587" (U+6587)'
30
+ ]);
31
+ });
32
+ });
@@ -0,0 +1,97 @@
1
+ // src/lint/resource-import-plugin.test.ts
2
+ import { createRequire } from "module";
3
+ import { execFileSync } from "child_process";
4
+ import {
5
+ mkdtempSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ rmSync,
9
+ writeFileSync
10
+ } from "fs";
11
+ import { tmpdir } from "os";
12
+ import { dirname, join, resolve } from "path";
13
+ import { describe, expect, it } from "vitest";
14
+ var packageRoot = resolve(import.meta.dirname, "../..");
15
+ var packageRequire = createRequire(join(packageRoot, "package.json"));
16
+ function resolveBinary(packageName, binName) {
17
+ const manifestPath = packageRequire.resolve(`${packageName}/package.json`);
18
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
19
+ const relativeBin = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.[binName];
20
+ if (!relativeBin) throw new Error(`${packageName} does not expose ${binName}`);
21
+ return resolve(dirname(manifestPath), relativeBin);
22
+ }
23
+ function createFixture(source, resources) {
24
+ const root = mkdtempSync(join(tmpdir(), "miaoda-game-plugin-test-"));
25
+ mkdirSync(join(root, "src", "assets"), { recursive: true });
26
+ writeFileSync(join(root, "package.json"), JSON.stringify({ type: "module" }));
27
+ writeFileSync(join(root, "tsconfig.json"), JSON.stringify({
28
+ compilerOptions: { baseUrl: ".", paths: { "@/*": ["src/*"] } }
29
+ }));
30
+ writeFileSync(join(root, "src", "entry.ts"), source);
31
+ for (const resource of resources) {
32
+ const path = join(root, "src", "assets", resource);
33
+ mkdirSync(dirname(path), { recursive: true });
34
+ writeFileSync(path, "fixture");
35
+ }
36
+ return root;
37
+ }
38
+ function runOxlint(root) {
39
+ const configPath = join(root, "oxlint.json");
40
+ writeFileSync(configPath, JSON.stringify({
41
+ jsPlugins: [
42
+ resolve(import.meta.dirname, "../rules/check-image-import-plugin.js"),
43
+ resolve(import.meta.dirname, "../rules/check-style-import-plugin.js")
44
+ ],
45
+ rules: {
46
+ "check-image-exists/no-missing-image": "error",
47
+ "check-style-exists/no-missing-style": "error"
48
+ }
49
+ }));
50
+ const result = (() => {
51
+ try {
52
+ execFileSync(process.execPath, [resolveBinary("oxlint", "oxlint"), "-c", configPath, "src"], {
53
+ cwd: root,
54
+ encoding: "utf8",
55
+ stdio: ["ignore", "pipe", "pipe"]
56
+ });
57
+ return { status: 0, output: "" };
58
+ } catch (error) {
59
+ const failure = error;
60
+ return {
61
+ status: failure.status ?? null,
62
+ output: `${failure.stdout ?? ""}
63
+ ${failure.stderr ?? ""}`
64
+ };
65
+ }
66
+ })();
67
+ return result;
68
+ }
69
+ describe("Oxlint resource import plugins", () => {
70
+ it("accept existing relative and tsconfig-alias image/style imports", () => {
71
+ const root = createFixture(
72
+ [
73
+ 'import "./assets/icon.png"; import "@/assets/theme.css";'
74
+ ].join("\n"),
75
+ ["icon.png", "theme.css"]
76
+ );
77
+ try {
78
+ expect(runOxlint(root)).toMatchObject({ status: 0 });
79
+ } finally {
80
+ rmSync(root, { recursive: true, force: true });
81
+ }
82
+ });
83
+ it("reports missing image and style imports through the real Oxlint CLI", () => {
84
+ const root = createFixture(
85
+ 'import "./assets/missing.png"; import "@/assets/missing.css";\n',
86
+ []
87
+ );
88
+ try {
89
+ const result = runOxlint(root);
90
+ expect(result.status).toBe(1);
91
+ expect(result.output).toContain("missing.png");
92
+ expect(result.output).toContain("missing.css");
93
+ } finally {
94
+ rmSync(root, { recursive: true, force: true });
95
+ }
96
+ });
97
+ });
@@ -12,43 +12,40 @@ function createCanvasContext(canvas) {
12
12
  canvas,
13
13
  font: "16px sans-serif"
14
14
  };
15
- const context = new Proxy(
16
- state,
17
- {
18
- get(target, property) {
19
- if (property in target) {
20
- return target[property];
21
- }
22
- if (property === "createImageData" || property === "getImageData") {
23
- return (...args) => imageData(args.at(-2), args.at(-1));
24
- }
25
- if (property === "measureText") {
26
- return (value) => {
27
- const font = String(target.font ?? "16px sans-serif");
28
- const size = Number.parseFloat(font.match(/([\d.]+)px/)?.[1] ?? "16");
29
- const width = [...String(value)].reduce((total, character) => {
30
- if (/\s/.test(character)) return total + size * 0.33;
31
- if (/[^\u0000-\u00ff]/.test(character)) return total + size;
32
- if (/[MW@#%&]/.test(character)) return total + size * 0.9;
33
- if (/[ilI1.,'`]/.test(character)) return total + size * 0.32;
34
- if (/[A-Z0-9]/.test(character)) return total + size * 0.64;
35
- return total + size * 0.56;
36
- }, 0);
37
- return {
38
- width,
39
- actualBoundingBoxAscent: size * 0.8,
40
- actualBoundingBoxDescent: size * 0.2
41
- };
15
+ const context = new Proxy(state, {
16
+ get(target, property) {
17
+ if (property in target) {
18
+ return target[property];
19
+ }
20
+ if (property === "createImageData" || property === "getImageData") {
21
+ return (...args) => imageData(args.at(-2), args.at(-1));
22
+ }
23
+ if (property === "measureText") {
24
+ return (value) => {
25
+ const font = String(target.font ?? "16px sans-serif");
26
+ const size = Number.parseFloat(font.match(/([\d.]+)px/)?.[1] ?? "16");
27
+ const width = [...String(value)].reduce((total, character) => {
28
+ if (/\s/.test(character)) return total + size * 0.33;
29
+ if (/[^\u0000-\u00ff]/.test(character)) return total + size;
30
+ if (/[MW@#%&]/.test(character)) return total + size * 0.9;
31
+ if (/[ilI1.,'`]/.test(character)) return total + size * 0.32;
32
+ if (/[A-Z0-9]/.test(character)) return total + size * 0.64;
33
+ return total + size * 0.56;
34
+ }, 0);
35
+ return {
36
+ width,
37
+ actualBoundingBoxAscent: size * 0.8,
38
+ actualBoundingBoxDescent: size * 0.2
42
39
  };
43
- }
44
- if (property === "createLinearGradient" || property === "createRadialGradient") {
45
- return () => ({ addColorStop() {
46
- } });
47
- }
48
- return () => void 0;
40
+ };
49
41
  }
42
+ if (property === "createLinearGradient" || property === "createRadialGradient") {
43
+ return () => ({ addColorStop() {
44
+ } });
45
+ }
46
+ return () => void 0;
50
47
  }
51
- );
48
+ });
52
49
  return context;
53
50
  }
54
51
  HTMLCanvasElement.prototype.getContext = function getContext(contextId) {
@@ -0,0 +1,471 @@
1
+ // src/lint/vitest-config.test.ts
2
+ import { resolve as resolve2 } from "path";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ // src/vitest-config.ts
6
+ import { createRequire } from "module";
7
+ import { dirname, resolve } from "path";
8
+ import { defineConfig } from "vitest/config";
9
+
10
+ // src/gameplay-audit.ts
11
+ function normalizeList(value) {
12
+ if (value === void 0) return [];
13
+ const values = typeof value === "string" ? [value] : value;
14
+ return [...new Set(values.map((item) => item.trim()).filter(Boolean))];
15
+ }
16
+ function normalizeSet(values) {
17
+ return [
18
+ ...new Set(values.map((value) => value.trim()).filter(Boolean))
19
+ ].sort();
20
+ }
21
+ function sameSet(left, right) {
22
+ return JSON.stringify(normalizeSet(left)) === JSON.stringify(normalizeSet(right));
23
+ }
24
+ function normalizeGameplayRequirements(requirements, scenes) {
25
+ const requiredScenes = normalizeSet([
26
+ ...scenes,
27
+ ...normalizeList(requirements.requireScene)
28
+ ]);
29
+ return {
30
+ requireInput: requirements.requireInput || void 0,
31
+ requireFrameAdvance: requirements.requireFrameAdvance || void 0,
32
+ requirePhysicsStep: requirements.requirePhysicsStep || void 0,
33
+ requireTransition: normalizeList(requirements.requireTransition),
34
+ requireScene: requiredScenes,
35
+ requireRestart: normalizeList(requirements.requireRestart),
36
+ requireCheckpoint: normalizeList(requirements.requireCheckpoint),
37
+ requireDestroy: true
38
+ };
39
+ }
40
+ function createGameplayContractMetadata(contract) {
41
+ const scenes = normalizeSet(contract.scenes);
42
+ return {
43
+ id: contract.id.trim(),
44
+ scenes,
45
+ requirements: normalizeGameplayRequirements(contract, scenes),
46
+ verified: false
47
+ };
48
+ }
49
+ function validateGameplayAuditOptions(options) {
50
+ const issues = [];
51
+ const scenes = normalizeSet(options.scenes);
52
+ if (scenes.length === 0) issues.push("gameplayAudit.scenes \u4E0D\u80FD\u4E3A\u7A7A\u3002");
53
+ if (scenes.length !== options.scenes.length) {
54
+ issues.push("gameplayAudit.scenes \u4E0D\u80FD\u5305\u542B\u7A7A\u503C\u6216\u91CD\u590D Scene key\u3002");
55
+ }
56
+ const contractIds = /* @__PURE__ */ new Set();
57
+ const coveredScenes = /* @__PURE__ */ new Set();
58
+ for (const contract of options.contracts) {
59
+ const id = contract.id.trim();
60
+ if (!id) {
61
+ issues.push("gameplayAudit.contracts \u4E2D\u5B58\u5728\u7A7A contract id\u3002");
62
+ } else if (id !== contract.id) {
63
+ issues.push(`contract id "${contract.id}" \u4E0D\u80FD\u5305\u542B\u9996\u5C3E\u7A7A\u767D\u3002`);
64
+ } else if (contractIds.has(id)) {
65
+ issues.push(`gameplayAudit.contracts \u5305\u542B\u91CD\u590D contract id\uFF1A${id}\u3002`);
66
+ } else {
67
+ contractIds.add(id);
68
+ }
69
+ const testFile = contract.testFile.replaceAll("\\", "/");
70
+ if (!/^tests\/.+\.test\.ts$/.test(testFile) || testFile.split("/").includes("..")) {
71
+ issues.push(
72
+ `contract "${id || "<empty>"}" \u7684 testFile \u5FC5\u987B\u5339\u914D tests/**/*.test.ts\u3002`
73
+ );
74
+ }
75
+ const contractScenes = normalizeSet(contract.scenes);
76
+ if (contractScenes.length === 0) {
77
+ issues.push(`contract "${id || "<empty>"}" \u5FC5\u987B\u58F0\u660E\u81F3\u5C11\u4E00\u4E2A Scene\u3002`);
78
+ }
79
+ for (const scene of contractScenes) {
80
+ coveredScenes.add(scene);
81
+ if (!scenes.includes(scene)) {
82
+ issues.push(
83
+ `contract "${id}" \u5F15\u7528\u4E86\u672A\u5217\u5165 gameplayAudit.scenes \u7684 Scene\uFF1A${scene}\u3002`
84
+ );
85
+ }
86
+ }
87
+ }
88
+ for (const scene of scenes) {
89
+ if (!coveredScenes.has(scene)) {
90
+ issues.push(`Scene "${scene}" \u6CA1\u6709\u4EFB\u4F55 gameplay contract \u8D1F\u8D23\u9A8C\u8BC1\u3002`);
91
+ }
92
+ }
93
+ if (options.contracts.length === 0)
94
+ issues.push("gameplayAudit.contracts \u4E0D\u80FD\u4E3A\u7A7A\u3002");
95
+ return issues;
96
+ }
97
+ function getDefinitionIssues(expected, actual) {
98
+ const issues = [];
99
+ const expectedMetadata = createGameplayContractMetadata(expected);
100
+ if (!sameSet(expectedMetadata.scenes, actual.scenes)) {
101
+ issues.push(
102
+ `contract "${expected.id}" \u58F0\u660E\u7684 Scene \u4E0E gameplayAudit \u6E05\u5355\u4E0D\u4E00\u81F4\u3002`
103
+ );
104
+ }
105
+ if (JSON.stringify(expectedMetadata.requirements) !== JSON.stringify(actual.requirements)) {
106
+ issues.push(
107
+ `contract "${expected.id}" \u7684 evidence requirements \u4E0E gameplayAudit \u6E05\u5355\u4E0D\u4E00\u81F4\u3002`
108
+ );
109
+ }
110
+ return issues;
111
+ }
112
+ function auditGameplayCollection(options, file, tests) {
113
+ const normalizedFile = file.replaceAll("\\", "/");
114
+ const expected = options.contracts.filter(
115
+ (contract) => contract.testFile.replaceAll("\\", "/") === normalizedFile
116
+ );
117
+ const actual = tests.filter((test) => test.metadata);
118
+ const issues = [];
119
+ for (const contract of expected) {
120
+ const matches = actual.filter((test) => test.metadata?.id === contract.id);
121
+ if (matches.length === 0) {
122
+ issues.push(
123
+ `\u6D4B\u8BD5\u6267\u884C\u524D\u672A\u6536\u96C6\u5230 contract "${contract.id}"\uFF08${normalizedFile}\uFF09\u3002`
124
+ );
125
+ } else if (matches.length > 1) {
126
+ issues.push(
127
+ `contract "${contract.id}" \u5728 ${normalizedFile} \u4E2D\u91CD\u590D\u5B9A\u4E49\u3002`
128
+ );
129
+ } else if (matches[0]?.metadata) {
130
+ issues.push(...getDefinitionIssues(contract, matches[0].metadata));
131
+ }
132
+ }
133
+ for (const test of actual) {
134
+ const id = test.metadata?.id;
135
+ if (!id) continue;
136
+ const expectedContract = options.contracts.find(
137
+ (contract) => contract.id === id
138
+ );
139
+ if (!expectedContract) {
140
+ issues.push(
141
+ `\u6D4B\u8BD5 "${test.name}" \u58F0\u660E\u4E86\u672A\u5217\u5165 gameplayAudit \u6E05\u5355\u7684 contract "${id}"\u3002`
142
+ );
143
+ } else if (expectedContract.testFile.replaceAll("\\", "/") !== normalizedFile) {
144
+ issues.push(
145
+ `contract "${id}" \u5FC5\u987B\u4F4D\u4E8E ${expectedContract.testFile}\uFF0C\u5B9E\u9645\u4F4D\u4E8E ${normalizedFile}\u3002`
146
+ );
147
+ }
148
+ }
149
+ return { passed: issues.length === 0, issues };
150
+ }
151
+ function getGameplayEvidenceIssues(id, requirements, evidence) {
152
+ const issues = [];
153
+ const inputEvents = evidence.mouseEvents + evidence.keyboardEvents + evidence.touchEvents;
154
+ if (requirements.requireInput && inputEvents === 0)
155
+ issues.push(`contract "${id}" \u672A\u8BB0\u5F55\u771F\u5B9E DOM \u8F93\u5165\u3002`);
156
+ if (requirements.requireFrameAdvance && evidence.frames === 0)
157
+ issues.push(`contract "${id}" \u672A\u63A8\u8FDB\u5B8C\u6574 Phaser \u5E27\u3002`);
158
+ if (requirements.requirePhysicsStep && evidence.physicsSteps === 0)
159
+ issues.push(`contract "${id}" \u672A\u63A8\u8FDB Arcade Physics\u3002`);
160
+ for (const target of normalizeList(requirements.requireTransition)) {
161
+ if (!evidence.transitions.some((transition) => transition.to === target)) {
162
+ issues.push(`contract "${id}" \u672A\u8BB0\u5F55\u5230 ${target} \u7684 Scene \u8F6C\u573A\u3002`);
163
+ }
164
+ }
165
+ for (const scene of normalizeList(requirements.requireScene)) {
166
+ if (!evidence.visitedScenes.includes(scene))
167
+ issues.push(`contract "${id}" \u672A\u8BBF\u95EE Scene "${scene}"\u3002`);
168
+ }
169
+ for (const scene of normalizeList(requirements.requireRestart)) {
170
+ if (!evidence.restartedScenes.includes(scene))
171
+ issues.push(`contract "${id}" \u672A\u91CD\u542F Scene "${scene}"\u3002`);
172
+ }
173
+ for (const checkpoint of normalizeList(requirements.requireCheckpoint)) {
174
+ if (!evidence.checkpoints.includes(checkpoint))
175
+ issues.push(`contract "${id}" \u7F3A\u5C11 checkpoint "${checkpoint}"\u3002`);
176
+ }
177
+ if (requirements.requireDestroy && !evidence.destroyed)
178
+ issues.push(`contract "${id}" \u672A\u5B8C\u6210 HEADLESS host \u9500\u6BC1\u3002`);
179
+ return issues;
180
+ }
181
+ function auditGameplayRun(options, tests) {
182
+ const issues = [];
183
+ const actualContracts = tests.filter((test) => test.metadata);
184
+ const acceptedEvidence = [];
185
+ for (const expected of options.contracts) {
186
+ const matches = actualContracts.filter(
187
+ (test2) => test2.metadata?.id === expected.id
188
+ );
189
+ if (matches.length === 0) {
190
+ issues.push(`\u7F3A\u5C11 gameplay contract "${expected.id}"\u3002`);
191
+ continue;
192
+ }
193
+ if (matches.length > 1) {
194
+ issues.push(`gameplay contract "${expected.id}" \u88AB\u591A\u4E2A\u6D4B\u8BD5\u91CD\u590D\u58F0\u660E\u3002`);
195
+ continue;
196
+ }
197
+ const test = matches[0];
198
+ const metadata = test?.metadata;
199
+ if (!test || !metadata) continue;
200
+ if (test.file.replaceAll("\\", "/") !== expected.testFile.replaceAll("\\", "/")) {
201
+ issues.push(
202
+ `contract "${expected.id}" \u5FC5\u987B\u4F4D\u4E8E ${expected.testFile}\uFF0C\u5B9E\u9645\u4F4D\u4E8E ${test.file}\u3002`
203
+ );
204
+ }
205
+ issues.push(...getDefinitionIssues(expected, metadata));
206
+ if (test.state !== "passed") {
207
+ issues.push(`contract "${expected.id}" \u7684\u6D4B\u8BD5\u72B6\u6001\u4E3A ${test.state}\u3002`);
208
+ continue;
209
+ }
210
+ if (!metadata.verified || !metadata.evidence) {
211
+ issues.push(`contract "${expected.id}" \u672A\u5B8C\u6210 onTestFinished \u8BC1\u636E\u6821\u9A8C\u3002`);
212
+ continue;
213
+ }
214
+ const evidenceIssues = getGameplayEvidenceIssues(
215
+ expected.id,
216
+ metadata.requirements,
217
+ metadata.evidence
218
+ );
219
+ issues.push(...evidenceIssues);
220
+ if (evidenceIssues.length === 0) acceptedEvidence.push(metadata.evidence);
221
+ }
222
+ for (const test of actualContracts) {
223
+ const id = test.metadata?.id;
224
+ if (id && !options.contracts.some((contract) => contract.id === id)) {
225
+ issues.push(
226
+ `\u53D1\u73B0\u672A\u5217\u5165 gameplayAudit \u6E05\u5355\u7684 contract "${id}"\uFF08${test.file}\uFF09\u3002`
227
+ );
228
+ }
229
+ }
230
+ const registeredScenes = normalizeSet(
231
+ acceptedEvidence.flatMap((evidence) => evidence.registeredScenes)
232
+ );
233
+ const expectedScenes = normalizeSet(options.scenes);
234
+ for (const scene of expectedScenes) {
235
+ if (!registeredScenes.includes(scene))
236
+ issues.push(
237
+ `\u751F\u4EA7 Scene "${scene}" \u672A\u51FA\u73B0\u5728\u4EFB\u4F55\u901A\u8FC7\u5BA1\u8BA1\u7684 host registry \u4E2D\u3002`
238
+ );
239
+ }
240
+ for (const scene of registeredScenes) {
241
+ if (!expectedScenes.includes(scene))
242
+ issues.push(
243
+ `host registry \u51FA\u73B0\u672A\u5217\u5165 gameplayAudit.scenes \u7684\u751F\u4EA7 Scene "${scene}"\u3002`
244
+ );
245
+ }
246
+ return { passed: issues.length === 0, issues: [...new Set(issues)] };
247
+ }
248
+
249
+ // src/gameplay-audit-reporter.ts
250
+ import { relative } from "path";
251
+ function normalizePath(path) {
252
+ return path.replaceAll("\\", "/").replace(/^\.\//, "");
253
+ }
254
+ function isGameplayContractMetadata(value) {
255
+ if (!value || typeof value !== "object") return false;
256
+ const record = value;
257
+ return typeof record.id === "string" && Array.isArray(record.scenes) && Boolean(record.requirements) && typeof record.requirements === "object";
258
+ }
259
+ function toAuditTest(test) {
260
+ const metadata = test.meta().gameplayContract;
261
+ return {
262
+ file: normalizePath(test.module.relativeModuleId),
263
+ name: test.fullName,
264
+ state: test.result().state,
265
+ metadata: isGameplayContractMetadata(metadata) ? metadata : void 0
266
+ };
267
+ }
268
+ function printIssues(stage, issues) {
269
+ if (issues.length === 0) return;
270
+ console.error(`
271
+ GAMEPLAY_AUDIT: ${stage} FAILED`);
272
+ for (const issue of issues) console.error(`- ${issue}`);
273
+ }
274
+ var GameplayAuditReporter = class {
275
+ projectRoot;
276
+ options;
277
+ preflightIssues = /* @__PURE__ */ new Set();
278
+ /** 创建绑定到单个游戏项目根目录和权威清单的 reporter。 */
279
+ constructor(projectRoot2, options) {
280
+ this.projectRoot = projectRoot2;
281
+ this.options = options;
282
+ }
283
+ /** 每次 Vitest 运行开始时重置预检状态,并检查清单中的测试文件是否会执行。 */
284
+ onTestRunStart(specifications) {
285
+ this.preflightIssues.clear();
286
+ const scheduledFiles = new Set(
287
+ specifications.map(
288
+ (specification) => normalizePath(relative(this.projectRoot, specification.moduleId))
289
+ )
290
+ );
291
+ for (const contract of this.options.contracts) {
292
+ const testFile = normalizePath(contract.testFile);
293
+ if (!scheduledFiles.has(testFile)) {
294
+ this.reportPreflightIssue(
295
+ `contract "${contract.id}" \u7684\u6D4B\u8BD5\u6587\u4EF6\u672A\u8FDB\u5165\u672C\u6B21 Vitest \u8FD0\u884C\uFF1A${testFile}\u3002`
296
+ );
297
+ }
298
+ }
299
+ }
300
+ /** 在测试文件执行前检查静态 metadata 是否声明了该文件负责的全部 contract。 */
301
+ onTestModuleCollected(testModule) {
302
+ const file = normalizePath(testModule.relativeModuleId);
303
+ const tests = [...testModule.children.allTests()].map(toAuditTest);
304
+ const result = auditGameplayCollection(this.options, file, tests);
305
+ for (const issue of result.issues) this.reportPreflightIssue(issue);
306
+ }
307
+ /** 在测试运行结束后汇总全部 contract、Ledger 和 Scene registry,并决定命令退出码。 */
308
+ onTestRunEnd(testModules) {
309
+ const tests = testModules.flatMap(
310
+ (testModule) => [...testModule.children.allTests()].map(toAuditTest)
311
+ );
312
+ const result = auditGameplayRun(this.options, tests);
313
+ if (!result.passed) {
314
+ printIssues("FINAL", result.issues);
315
+ process.exitCode = 1;
316
+ } else {
317
+ console.log(
318
+ `
319
+ GAMEPLAY_AUDIT: ALL CONTRACTS PASSED (${this.options.contracts.length} contracts, ${this.options.scenes.length} scenes)`
320
+ );
321
+ }
322
+ }
323
+ /** 立即输出一个此前未报告过的预检缺口,并让本次测试命令失败。 */
324
+ reportPreflightIssue(issue) {
325
+ if (this.preflightIssues.has(issue)) return;
326
+ this.preflightIssues.add(issue);
327
+ printIssues("PREFLIGHT", [issue]);
328
+ process.exitCode = 1;
329
+ }
330
+ };
331
+
332
+ // src/vitest-config.ts
333
+ function defineGameVitestConfig(options) {
334
+ const auditIssues = validateGameplayAuditOptions(options.gameplayAudit);
335
+ if (auditIssues.length > 0) {
336
+ throw new Error(
337
+ `\u65E0\u6548\u7684 gameplayAudit \u914D\u7F6E\uFF1A
338
+ - ${auditIssues.join("\n- ")}`
339
+ );
340
+ }
341
+ const projectRequire = createRequire(
342
+ resolve(options.projectRoot, "package.json")
343
+ );
344
+ const phaserPackageRoot = dirname(
345
+ projectRequire.resolve("phaser/package.json")
346
+ );
347
+ const phaserBrowserBuild = resolve(phaserPackageRoot, "dist/phaser.js");
348
+ return defineConfig({
349
+ resolve: {
350
+ alias: {
351
+ ...options.aliases,
352
+ "@": resolve(options.projectRoot, "src"),
353
+ phaser: phaserBrowserBuild
354
+ }
355
+ },
356
+ test: {
357
+ include: ["tests/**/*.test.ts"],
358
+ testTimeout: options.testTimeout,
359
+ hookTimeout: options.hookTimeout,
360
+ server: {
361
+ deps: {
362
+ inline: [/miaoda-game-/]
363
+ }
364
+ },
365
+ environment: "jsdom",
366
+ environmentOptions: {
367
+ jsdom: {
368
+ url: "http://localhost/"
369
+ }
370
+ },
371
+ setupFiles: [
372
+ "miaoda-game-devkit/vitest-setup",
373
+ ...options.additionalSetupFiles ?? []
374
+ ],
375
+ reporters: [
376
+ "default",
377
+ new GameplayAuditReporter(options.projectRoot, options.gameplayAudit)
378
+ ],
379
+ restoreMocks: true,
380
+ clearMocks: true,
381
+ coverage: {
382
+ provider: "v8",
383
+ include: ["src/scenes/**/*.{ts,tsx}"],
384
+ exclude: [
385
+ "src/scenes/**/*.d.ts",
386
+ "src/scenes/**/*.{spec,test}.{ts,tsx}"
387
+ ],
388
+ reporter: ["text"],
389
+ thresholds: {
390
+ perFile: true,
391
+ lines: 1,
392
+ functions: 1,
393
+ statements: 1
394
+ }
395
+ }
396
+ }
397
+ });
398
+ }
399
+
400
+ // src/lint/vitest-config.test.ts
401
+ var projectRoot = resolve2(import.meta.dirname, "../..");
402
+ var gameplayAudit = {
403
+ scenes: ["InputScene"],
404
+ contracts: [
405
+ {
406
+ id: "InputScene.boot",
407
+ testFile: "tests/input.test.ts",
408
+ scenes: ["InputScene"],
409
+ requireFrameAdvance: true
410
+ }
411
+ ]
412
+ };
413
+ describe("defineGameVitestConfig", () => {
414
+ it("\u63D0\u4F9B Phaser \u8FD0\u884C\u65F6\u6D4B\u8BD5\u9700\u8981\u7684\u56FA\u5B9A\u57FA\u7EBF", () => {
415
+ const config = defineGameVitestConfig({ projectRoot, gameplayAudit });
416
+ expect(config.resolve?.alias).toMatchObject({
417
+ "@": resolve2(projectRoot, "src"),
418
+ phaser: expect.stringContaining("phaser/dist/phaser.js")
419
+ });
420
+ expect(config.test).toMatchObject({
421
+ include: ["tests/**/*.test.ts"],
422
+ environment: "jsdom",
423
+ environmentOptions: { jsdom: { url: "http://localhost/" } },
424
+ setupFiles: ["miaoda-game-devkit/vitest-setup"],
425
+ reporters: ["default", expect.any(Object)],
426
+ restoreMocks: true,
427
+ clearMocks: true,
428
+ coverage: {
429
+ provider: "v8",
430
+ include: ["src/scenes/**/*.{ts,tsx}"],
431
+ exclude: [
432
+ "src/scenes/**/*.d.ts",
433
+ "src/scenes/**/*.{spec,test}.{ts,tsx}"
434
+ ],
435
+ reporter: ["text"],
436
+ thresholds: {
437
+ perFile: true,
438
+ lines: 1,
439
+ functions: 1,
440
+ statements: 1
441
+ }
442
+ }
443
+ });
444
+ });
445
+ it("\u4FDD\u7559\u5B89\u5168\u6269\u5C55\u5E76\u5728\u57FA\u7840 setup \u4E4B\u540E\u8FFD\u52A0\u9879\u76EE setup", () => {
446
+ const config = defineGameVitestConfig({
447
+ projectRoot,
448
+ gameplayAudit,
449
+ additionalSetupFiles: ["tests/custom-setup.ts"],
450
+ testTimeout: 5e3,
451
+ hookTimeout: 2e3
452
+ });
453
+ expect(config.test).toMatchObject({
454
+ include: ["tests/**/*.test.ts"],
455
+ setupFiles: ["miaoda-game-devkit/vitest-setup", "tests/custom-setup.ts"],
456
+ testTimeout: 5e3,
457
+ hookTimeout: 2e3
458
+ });
459
+ });
460
+ it("\u62D2\u7EDD\u7F3A\u5C11 Scene contract \u7684\u5BA1\u8BA1\u6E05\u5355", () => {
461
+ expect(
462
+ () => defineGameVitestConfig({
463
+ projectRoot,
464
+ gameplayAudit: {
465
+ scenes: ["InputScene", "ResultScene"],
466
+ contracts: gameplayAudit.contracts
467
+ }
468
+ })
469
+ ).toThrow(/ResultScene.*没有任何 gameplay contract/s);
470
+ });
471
+ });