miaoda-game-devkit 0.6.3 → 0.6.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
CHANGED
|
@@ -12,6 +12,8 @@ var import_node_path2 = require("path");
|
|
|
12
12
|
var import_node_fs = require("fs");
|
|
13
13
|
var import_node_path = require("path");
|
|
14
14
|
var PRODUCTION_PLAYTHROUGH = "tests/production-playthrough.test.tsx";
|
|
15
|
+
var PRODUCTION_APP = "src/App.tsx";
|
|
16
|
+
var EXAMPLE_IMPORT_PREFIX = "@/game/example/";
|
|
15
17
|
var REACT_RUNTIME_ENTRY = "miaoda-game-devkit/react";
|
|
16
18
|
var REACT_TESTING_ENTRY = "miaoda-game-devkit/react/testing";
|
|
17
19
|
var CLOCK_IMPORTS = /* @__PURE__ */ new Set(["browserGameClock"]);
|
|
@@ -20,6 +22,19 @@ var CONTROLLER_IMPORTS = /* @__PURE__ */ new Set([
|
|
|
20
22
|
"useOwnedGameController"
|
|
21
23
|
]);
|
|
22
24
|
var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
|
|
25
|
+
function runnableTestFiles(root, directory = (0, import_node_path.join)(root, "tests")) {
|
|
26
|
+
if (!(0, import_node_fs.existsSync)(directory)) return [];
|
|
27
|
+
const files = [];
|
|
28
|
+
for (const entry of (0, import_node_fs.readdirSync)(directory, { withFileTypes: true })) {
|
|
29
|
+
const path = (0, import_node_path.join)(directory, entry.name);
|
|
30
|
+
if (entry.isDirectory()) {
|
|
31
|
+
files.push(...runnableTestFiles(root, path));
|
|
32
|
+
} else if (entry.isFile() && /\.test\.[cm]?[jt]sx?$/.test(entry.name)) {
|
|
33
|
+
files.push(path);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return files;
|
|
37
|
+
}
|
|
23
38
|
function extension(path) {
|
|
24
39
|
const index = path.lastIndexOf(".");
|
|
25
40
|
return index < 0 ? "" : path.slice(index);
|
|
@@ -109,6 +124,32 @@ function codePositions(source) {
|
|
|
109
124
|
}
|
|
110
125
|
return positions;
|
|
111
126
|
}
|
|
127
|
+
function importedModuleSpecifiers(source) {
|
|
128
|
+
const clean = withoutComments(source);
|
|
129
|
+
const positions = codePositions(clean);
|
|
130
|
+
const modules = [];
|
|
131
|
+
const pattern = /\b(?:from\s+|import\s*(?:\(\s*)?)["']([^"']+)["']\s*\)?/g;
|
|
132
|
+
for (const match of clean.matchAll(pattern)) {
|
|
133
|
+
if (positions[match.index]) modules.push(match[1]);
|
|
134
|
+
}
|
|
135
|
+
return modules;
|
|
136
|
+
}
|
|
137
|
+
function productionFileImportsExample(file, projectRoot2) {
|
|
138
|
+
const exampleRoot = (0, import_node_path.join)(projectRoot2, "src/game/example");
|
|
139
|
+
return importedModuleSpecifiers((0, import_node_fs.readFileSync)(file, "utf8")).some(
|
|
140
|
+
(moduleName) => {
|
|
141
|
+
if (moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)) return true;
|
|
142
|
+
if (!moduleName.startsWith(".")) return false;
|
|
143
|
+
const target = (0, import_node_path.resolve)((0, import_node_path.dirname)(file), moduleName);
|
|
144
|
+
return target === exampleRoot || target.startsWith(`${exampleRoot}${import_node_path.sep}`);
|
|
145
|
+
}
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
function importsExampleAlias(source) {
|
|
149
|
+
return importedModuleSpecifiers(source).some(
|
|
150
|
+
(moduleName) => moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)
|
|
151
|
+
);
|
|
152
|
+
}
|
|
112
153
|
function namedImports(source, moduleName) {
|
|
113
154
|
const names = /* @__PURE__ */ new Set();
|
|
114
155
|
const clean = withoutComments(source);
|
|
@@ -141,15 +182,39 @@ function declaresObserve(source) {
|
|
|
141
182
|
function auditReactAuthoritativePlaythrough(projectRoot2) {
|
|
142
183
|
const clockFiles = [];
|
|
143
184
|
const controllerFiles = [];
|
|
144
|
-
|
|
145
|
-
|
|
185
|
+
const productionFiles = sourceFiles(projectRoot2);
|
|
186
|
+
for (const file of productionFiles) {
|
|
187
|
+
const imports = namedImports(
|
|
188
|
+
(0, import_node_fs.readFileSync)(file, "utf8"),
|
|
189
|
+
REACT_RUNTIME_ENTRY
|
|
190
|
+
);
|
|
146
191
|
const projectPath = (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/");
|
|
147
192
|
if (containsAny(imports, CLOCK_IMPORTS)) clockFiles.push(projectPath);
|
|
148
|
-
if (containsAny(imports, CONTROLLER_IMPORTS))
|
|
193
|
+
if (containsAny(imports, CONTROLLER_IMPORTS))
|
|
194
|
+
controllerFiles.push(projectPath);
|
|
149
195
|
}
|
|
196
|
+
const appPath = (0, import_node_path.join)(projectRoot2, PRODUCTION_APP);
|
|
197
|
+
const productionEntryExists = (0, import_node_fs.existsSync)(appPath);
|
|
198
|
+
const productionUsesExample = productionFiles.some(
|
|
199
|
+
(file) => productionFileImportsExample(file, projectRoot2)
|
|
200
|
+
);
|
|
201
|
+
const staleExampleTestFiles = productionUsesExample ? [] : runnableTestFiles(projectRoot2).filter((file) => importsExampleAlias((0, import_node_fs.readFileSync)(file, "utf8"))).map((file) => (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/"));
|
|
150
202
|
const issues = [];
|
|
203
|
+
if (staleExampleTestFiles.length > 0) {
|
|
204
|
+
issues.push(
|
|
205
|
+
`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.`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
151
208
|
if (clockFiles.length === 0 && controllerFiles.length === 0) {
|
|
152
|
-
return {
|
|
209
|
+
return {
|
|
210
|
+
ok: issues.length === 0,
|
|
211
|
+
issues,
|
|
212
|
+
clockFiles,
|
|
213
|
+
controllerFiles,
|
|
214
|
+
productionEntryExists,
|
|
215
|
+
productionUsesExample,
|
|
216
|
+
staleExampleTestFiles
|
|
217
|
+
};
|
|
153
218
|
}
|
|
154
219
|
const testPath = (0, import_node_path.join)(projectRoot2, PRODUCTION_PLAYTHROUGH);
|
|
155
220
|
const testSource = (0, import_node_fs.existsSync)(testPath) ? (0, import_node_fs.readFileSync)(testPath, "utf8") : "";
|
|
@@ -157,15 +222,23 @@ function auditReactAuthoritativePlaythrough(projectRoot2) {
|
|
|
157
222
|
const hasObserve = declaresObserve(testSource);
|
|
158
223
|
if (!hasObserve) {
|
|
159
224
|
issues.push(
|
|
160
|
-
`${PRODUCTION_PLAYTHROUGH} must declare observe because production uses the devkit clock or Controller ownership hook.
|
|
225
|
+
`${PRODUCTION_PLAYTHROUGH} must declare observe because production uses the devkit clock or Controller ownership hook. Pass { observe: () => telemetry.read.session() } to playthroughTest, using Telemetry backed by the same production Controller rendered by <App />; DOM labels are not an authoritative gameplay boundary.`
|
|
161
226
|
);
|
|
162
227
|
}
|
|
163
228
|
if (clockFiles.length > 0 && !testingImports.has("ManualGameClock")) {
|
|
164
229
|
issues.push(
|
|
165
|
-
`${PRODUCTION_PLAYTHROUGH} must import ManualGameClock because production owns gameplay time in ${clockFiles.join(", ")}.
|
|
230
|
+
`${PRODUCTION_PLAYTHROUGH} must import ManualGameClock because production owns gameplay time in ${clockFiles.join(", ")}. Add this import to the test: import { ManualGameClock } from "miaoda-game-devkit/react/testing"; Then create one, inject it through the production <App /> factory, and advance it with step: () => clock.stepFrame().`
|
|
166
231
|
);
|
|
167
232
|
}
|
|
168
|
-
return {
|
|
233
|
+
return {
|
|
234
|
+
ok: issues.length === 0,
|
|
235
|
+
issues,
|
|
236
|
+
clockFiles,
|
|
237
|
+
controllerFiles,
|
|
238
|
+
productionEntryExists,
|
|
239
|
+
productionUsesExample,
|
|
240
|
+
staleExampleTestFiles
|
|
241
|
+
};
|
|
169
242
|
}
|
|
170
243
|
|
|
171
244
|
// src/cli/lint.ts
|
package/dist/cli/react-lint.js
CHANGED
|
@@ -12,6 +12,8 @@ var import_node_path2 = require("path");
|
|
|
12
12
|
var import_node_fs = require("fs");
|
|
13
13
|
var import_node_path = require("path");
|
|
14
14
|
var PRODUCTION_PLAYTHROUGH = "tests/production-playthrough.test.tsx";
|
|
15
|
+
var PRODUCTION_APP = "src/App.tsx";
|
|
16
|
+
var EXAMPLE_IMPORT_PREFIX = "@/game/example/";
|
|
15
17
|
var REACT_RUNTIME_ENTRY = "miaoda-game-devkit/react";
|
|
16
18
|
var REACT_TESTING_ENTRY = "miaoda-game-devkit/react/testing";
|
|
17
19
|
var CLOCK_IMPORTS = /* @__PURE__ */ new Set(["browserGameClock"]);
|
|
@@ -20,6 +22,19 @@ var CONTROLLER_IMPORTS = /* @__PURE__ */ new Set([
|
|
|
20
22
|
"useOwnedGameController"
|
|
21
23
|
]);
|
|
22
24
|
var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
|
|
25
|
+
function runnableTestFiles(root, directory = (0, import_node_path.join)(root, "tests")) {
|
|
26
|
+
if (!(0, import_node_fs.existsSync)(directory)) return [];
|
|
27
|
+
const files = [];
|
|
28
|
+
for (const entry of (0, import_node_fs.readdirSync)(directory, { withFileTypes: true })) {
|
|
29
|
+
const path = (0, import_node_path.join)(directory, entry.name);
|
|
30
|
+
if (entry.isDirectory()) {
|
|
31
|
+
files.push(...runnableTestFiles(root, path));
|
|
32
|
+
} else if (entry.isFile() && /\.test\.[cm]?[jt]sx?$/.test(entry.name)) {
|
|
33
|
+
files.push(path);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return files;
|
|
37
|
+
}
|
|
23
38
|
function extension(path) {
|
|
24
39
|
const index = path.lastIndexOf(".");
|
|
25
40
|
return index < 0 ? "" : path.slice(index);
|
|
@@ -109,6 +124,32 @@ function codePositions(source) {
|
|
|
109
124
|
}
|
|
110
125
|
return positions;
|
|
111
126
|
}
|
|
127
|
+
function importedModuleSpecifiers(source) {
|
|
128
|
+
const clean = withoutComments(source);
|
|
129
|
+
const positions = codePositions(clean);
|
|
130
|
+
const modules = [];
|
|
131
|
+
const pattern = /\b(?:from\s+|import\s*(?:\(\s*)?)["']([^"']+)["']\s*\)?/g;
|
|
132
|
+
for (const match of clean.matchAll(pattern)) {
|
|
133
|
+
if (positions[match.index]) modules.push(match[1]);
|
|
134
|
+
}
|
|
135
|
+
return modules;
|
|
136
|
+
}
|
|
137
|
+
function productionFileImportsExample(file, projectRoot2) {
|
|
138
|
+
const exampleRoot = (0, import_node_path.join)(projectRoot2, "src/game/example");
|
|
139
|
+
return importedModuleSpecifiers((0, import_node_fs.readFileSync)(file, "utf8")).some(
|
|
140
|
+
(moduleName) => {
|
|
141
|
+
if (moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)) return true;
|
|
142
|
+
if (!moduleName.startsWith(".")) return false;
|
|
143
|
+
const target = (0, import_node_path.resolve)((0, import_node_path.dirname)(file), moduleName);
|
|
144
|
+
return target === exampleRoot || target.startsWith(`${exampleRoot}${import_node_path.sep}`);
|
|
145
|
+
}
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
function importsExampleAlias(source) {
|
|
149
|
+
return importedModuleSpecifiers(source).some(
|
|
150
|
+
(moduleName) => moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)
|
|
151
|
+
);
|
|
152
|
+
}
|
|
112
153
|
function namedImports(source, moduleName) {
|
|
113
154
|
const names = /* @__PURE__ */ new Set();
|
|
114
155
|
const clean = withoutComments(source);
|
|
@@ -141,15 +182,39 @@ function declaresObserve(source) {
|
|
|
141
182
|
function auditReactAuthoritativePlaythrough(projectRoot2) {
|
|
142
183
|
const clockFiles = [];
|
|
143
184
|
const controllerFiles = [];
|
|
144
|
-
|
|
145
|
-
|
|
185
|
+
const productionFiles = sourceFiles(projectRoot2);
|
|
186
|
+
for (const file of productionFiles) {
|
|
187
|
+
const imports = namedImports(
|
|
188
|
+
(0, import_node_fs.readFileSync)(file, "utf8"),
|
|
189
|
+
REACT_RUNTIME_ENTRY
|
|
190
|
+
);
|
|
146
191
|
const projectPath = (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/");
|
|
147
192
|
if (containsAny(imports, CLOCK_IMPORTS)) clockFiles.push(projectPath);
|
|
148
|
-
if (containsAny(imports, CONTROLLER_IMPORTS))
|
|
193
|
+
if (containsAny(imports, CONTROLLER_IMPORTS))
|
|
194
|
+
controllerFiles.push(projectPath);
|
|
149
195
|
}
|
|
196
|
+
const appPath = (0, import_node_path.join)(projectRoot2, PRODUCTION_APP);
|
|
197
|
+
const productionEntryExists = (0, import_node_fs.existsSync)(appPath);
|
|
198
|
+
const productionUsesExample = productionFiles.some(
|
|
199
|
+
(file) => productionFileImportsExample(file, projectRoot2)
|
|
200
|
+
);
|
|
201
|
+
const staleExampleTestFiles = productionUsesExample ? [] : runnableTestFiles(projectRoot2).filter((file) => importsExampleAlias((0, import_node_fs.readFileSync)(file, "utf8"))).map((file) => (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/"));
|
|
150
202
|
const issues = [];
|
|
203
|
+
if (staleExampleTestFiles.length > 0) {
|
|
204
|
+
issues.push(
|
|
205
|
+
`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.`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
151
208
|
if (clockFiles.length === 0 && controllerFiles.length === 0) {
|
|
152
|
-
return {
|
|
209
|
+
return {
|
|
210
|
+
ok: issues.length === 0,
|
|
211
|
+
issues,
|
|
212
|
+
clockFiles,
|
|
213
|
+
controllerFiles,
|
|
214
|
+
productionEntryExists,
|
|
215
|
+
productionUsesExample,
|
|
216
|
+
staleExampleTestFiles
|
|
217
|
+
};
|
|
153
218
|
}
|
|
154
219
|
const testPath = (0, import_node_path.join)(projectRoot2, PRODUCTION_PLAYTHROUGH);
|
|
155
220
|
const testSource = (0, import_node_fs.existsSync)(testPath) ? (0, import_node_fs.readFileSync)(testPath, "utf8") : "";
|
|
@@ -157,15 +222,23 @@ function auditReactAuthoritativePlaythrough(projectRoot2) {
|
|
|
157
222
|
const hasObserve = declaresObserve(testSource);
|
|
158
223
|
if (!hasObserve) {
|
|
159
224
|
issues.push(
|
|
160
|
-
`${PRODUCTION_PLAYTHROUGH} must declare observe because production uses the devkit clock or Controller ownership hook.
|
|
225
|
+
`${PRODUCTION_PLAYTHROUGH} must declare observe because production uses the devkit clock or Controller ownership hook. Pass { observe: () => telemetry.read.session() } to playthroughTest, using Telemetry backed by the same production Controller rendered by <App />; DOM labels are not an authoritative gameplay boundary.`
|
|
161
226
|
);
|
|
162
227
|
}
|
|
163
228
|
if (clockFiles.length > 0 && !testingImports.has("ManualGameClock")) {
|
|
164
229
|
issues.push(
|
|
165
|
-
`${PRODUCTION_PLAYTHROUGH} must import ManualGameClock because production owns gameplay time in ${clockFiles.join(", ")}.
|
|
230
|
+
`${PRODUCTION_PLAYTHROUGH} must import ManualGameClock because production owns gameplay time in ${clockFiles.join(", ")}. Add this import to the test: import { ManualGameClock } from "miaoda-game-devkit/react/testing"; Then create one, inject it through the production <App /> factory, and advance it with step: () => clock.stepFrame().`
|
|
166
231
|
);
|
|
167
232
|
}
|
|
168
|
-
return {
|
|
233
|
+
return {
|
|
234
|
+
ok: issues.length === 0,
|
|
235
|
+
issues,
|
|
236
|
+
clockFiles,
|
|
237
|
+
controllerFiles,
|
|
238
|
+
productionEntryExists,
|
|
239
|
+
productionUsesExample,
|
|
240
|
+
staleExampleTestFiles
|
|
241
|
+
};
|
|
169
242
|
}
|
|
170
243
|
|
|
171
244
|
// src/cli/lint.ts
|
|
@@ -33,14 +33,247 @@ __export(react_vitest_config_exports, {
|
|
|
33
33
|
defineReactGameVitestConfig: () => defineReactGameVitestConfig
|
|
34
34
|
});
|
|
35
35
|
module.exports = __toCommonJS(react_vitest_config_exports);
|
|
36
|
-
var
|
|
37
|
-
var
|
|
36
|
+
var import_node_fs3 = require("fs");
|
|
37
|
+
var import_node_path3 = require("path");
|
|
38
38
|
var import_config = require("vitest/config");
|
|
39
39
|
|
|
40
40
|
// src/react/react-playthrough-reporter.ts
|
|
41
|
+
var import_node_fs2 = require("fs");
|
|
42
|
+
var import_node_path2 = require("path");
|
|
43
|
+
var import_node_util = require("util");
|
|
44
|
+
|
|
45
|
+
// src/cli/react-authoritative-playthrough.ts
|
|
41
46
|
var import_node_fs = require("fs");
|
|
42
47
|
var import_node_path = require("path");
|
|
43
|
-
var
|
|
48
|
+
var PRODUCTION_PLAYTHROUGH = "tests/production-playthrough.test.tsx";
|
|
49
|
+
var PRODUCTION_APP = "src/App.tsx";
|
|
50
|
+
var EXAMPLE_IMPORT_PREFIX = "@/game/example/";
|
|
51
|
+
var REACT_RUNTIME_ENTRY = "miaoda-game-devkit/react";
|
|
52
|
+
var REACT_TESTING_ENTRY = "miaoda-game-devkit/react/testing";
|
|
53
|
+
var CLOCK_IMPORTS = /* @__PURE__ */ new Set(["browserGameClock"]);
|
|
54
|
+
var CONTROLLER_IMPORTS = /* @__PURE__ */ new Set([
|
|
55
|
+
"useGameController",
|
|
56
|
+
"useOwnedGameController"
|
|
57
|
+
]);
|
|
58
|
+
var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
|
|
59
|
+
function runnableTestFiles(root, directory = (0, import_node_path.join)(root, "tests")) {
|
|
60
|
+
if (!(0, import_node_fs.existsSync)(directory)) return [];
|
|
61
|
+
const files = [];
|
|
62
|
+
for (const entry of (0, import_node_fs.readdirSync)(directory, { withFileTypes: true })) {
|
|
63
|
+
const path = (0, import_node_path.join)(directory, entry.name);
|
|
64
|
+
if (entry.isDirectory()) {
|
|
65
|
+
files.push(...runnableTestFiles(root, path));
|
|
66
|
+
} else if (entry.isFile() && /\.test\.[cm]?[jt]sx?$/.test(entry.name)) {
|
|
67
|
+
files.push(path);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return files;
|
|
71
|
+
}
|
|
72
|
+
function extension(path) {
|
|
73
|
+
const index = path.lastIndexOf(".");
|
|
74
|
+
return index < 0 ? "" : path.slice(index);
|
|
75
|
+
}
|
|
76
|
+
function sourceFiles(root, directory = (0, import_node_path.join)(root, "src")) {
|
|
77
|
+
if (!(0, import_node_fs.existsSync)(directory)) return [];
|
|
78
|
+
const files = [];
|
|
79
|
+
for (const entry of (0, import_node_fs.readdirSync)(directory, { withFileTypes: true })) {
|
|
80
|
+
const path = (0, import_node_path.join)(directory, entry.name);
|
|
81
|
+
const projectPath = (0, import_node_path.relative)(root, path).replaceAll("\\", "/");
|
|
82
|
+
if (entry.isDirectory()) {
|
|
83
|
+
if (projectPath === "src/game/example") continue;
|
|
84
|
+
files.push(...sourceFiles(root, path));
|
|
85
|
+
} else if (entry.isFile() && SOURCE_EXTENSIONS.has(extension(entry.name))) {
|
|
86
|
+
files.push(path);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return files;
|
|
90
|
+
}
|
|
91
|
+
function withoutComments(source) {
|
|
92
|
+
let output = "";
|
|
93
|
+
let state = "code";
|
|
94
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
95
|
+
const char = source[index];
|
|
96
|
+
const next = source[index + 1];
|
|
97
|
+
if (state === "line") {
|
|
98
|
+
if (char === "\n") {
|
|
99
|
+
state = "code";
|
|
100
|
+
output += char;
|
|
101
|
+
} else {
|
|
102
|
+
output += " ";
|
|
103
|
+
}
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (state === "block") {
|
|
107
|
+
if (char === "*" && next === "/") {
|
|
108
|
+
output += " ";
|
|
109
|
+
index += 1;
|
|
110
|
+
state = "code";
|
|
111
|
+
} else {
|
|
112
|
+
output += char === "\n" ? "\n" : " ";
|
|
113
|
+
}
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (state === "code" && char === "/" && next === "/") {
|
|
117
|
+
output += " ";
|
|
118
|
+
index += 1;
|
|
119
|
+
state = "line";
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (state === "code" && char === "/" && next === "*") {
|
|
123
|
+
output += " ";
|
|
124
|
+
index += 1;
|
|
125
|
+
state = "block";
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (state === "code" && char === "'") state = "single";
|
|
129
|
+
else if (state === "code" && char === '"') state = "double";
|
|
130
|
+
else if (state === "code" && char === "`") state = "template";
|
|
131
|
+
else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
|
|
132
|
+
state = "code";
|
|
133
|
+
} else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
|
|
134
|
+
state = "code";
|
|
135
|
+
} else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
|
|
136
|
+
state = "code";
|
|
137
|
+
}
|
|
138
|
+
output += char;
|
|
139
|
+
}
|
|
140
|
+
return output;
|
|
141
|
+
}
|
|
142
|
+
function codePositions(source) {
|
|
143
|
+
const positions = Array.from({ length: source.length }, () => false);
|
|
144
|
+
let state = "code";
|
|
145
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
146
|
+
const char = source[index];
|
|
147
|
+
if (state === "code") positions[index] = true;
|
|
148
|
+
if (state === "code" && char === "'") state = "single";
|
|
149
|
+
else if (state === "code" && char === '"') state = "double";
|
|
150
|
+
else if (state === "code" && char === "`") state = "template";
|
|
151
|
+
else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
|
|
152
|
+
state = "code";
|
|
153
|
+
} else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
|
|
154
|
+
state = "code";
|
|
155
|
+
} else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
|
|
156
|
+
state = "code";
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return positions;
|
|
160
|
+
}
|
|
161
|
+
function importedModuleSpecifiers(source) {
|
|
162
|
+
const clean = withoutComments(source);
|
|
163
|
+
const positions = codePositions(clean);
|
|
164
|
+
const modules = [];
|
|
165
|
+
const pattern = /\b(?:from\s+|import\s*(?:\(\s*)?)["']([^"']+)["']\s*\)?/g;
|
|
166
|
+
for (const match of clean.matchAll(pattern)) {
|
|
167
|
+
if (positions[match.index]) modules.push(match[1]);
|
|
168
|
+
}
|
|
169
|
+
return modules;
|
|
170
|
+
}
|
|
171
|
+
function productionFileImportsExample(file, projectRoot) {
|
|
172
|
+
const exampleRoot = (0, import_node_path.join)(projectRoot, "src/game/example");
|
|
173
|
+
return importedModuleSpecifiers((0, import_node_fs.readFileSync)(file, "utf8")).some(
|
|
174
|
+
(moduleName) => {
|
|
175
|
+
if (moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)) return true;
|
|
176
|
+
if (!moduleName.startsWith(".")) return false;
|
|
177
|
+
const target = (0, import_node_path.resolve)((0, import_node_path.dirname)(file), moduleName);
|
|
178
|
+
return target === exampleRoot || target.startsWith(`${exampleRoot}${import_node_path.sep}`);
|
|
179
|
+
}
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
function importsExampleAlias(source) {
|
|
183
|
+
return importedModuleSpecifiers(source).some(
|
|
184
|
+
(moduleName) => moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
function namedImports(source, moduleName) {
|
|
188
|
+
const names = /* @__PURE__ */ new Set();
|
|
189
|
+
const clean = withoutComments(source);
|
|
190
|
+
const positions = codePositions(clean);
|
|
191
|
+
const escapedModule = moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
192
|
+
const pattern = new RegExp(
|
|
193
|
+
`^\\s*import\\s+(?:type\\s+)?\\{([\\s\\S]*?)\\}\\s+from\\s+["']${escapedModule}["']`,
|
|
194
|
+
"gm"
|
|
195
|
+
);
|
|
196
|
+
for (const match of clean.matchAll(pattern)) {
|
|
197
|
+
if (!positions[match.index]) continue;
|
|
198
|
+
for (const specifier of match[1].split(",")) {
|
|
199
|
+
const imported = specifier.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim();
|
|
200
|
+
if (imported) names.add(imported);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return names;
|
|
204
|
+
}
|
|
205
|
+
function containsAny(values, expected) {
|
|
206
|
+
return [...values].some((value) => expected.has(value));
|
|
207
|
+
}
|
|
208
|
+
function declaresObserve(source) {
|
|
209
|
+
const clean = withoutComments(source);
|
|
210
|
+
const positions = codePositions(clean);
|
|
211
|
+
for (const match of clean.matchAll(/\bobserve\s*:/g)) {
|
|
212
|
+
if (positions[match.index]) return true;
|
|
213
|
+
}
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
function auditReactAuthoritativePlaythrough(projectRoot) {
|
|
217
|
+
const clockFiles = [];
|
|
218
|
+
const controllerFiles = [];
|
|
219
|
+
const productionFiles = sourceFiles(projectRoot);
|
|
220
|
+
for (const file of productionFiles) {
|
|
221
|
+
const imports = namedImports(
|
|
222
|
+
(0, import_node_fs.readFileSync)(file, "utf8"),
|
|
223
|
+
REACT_RUNTIME_ENTRY
|
|
224
|
+
);
|
|
225
|
+
const projectPath = (0, import_node_path.relative)(projectRoot, file).replaceAll("\\", "/");
|
|
226
|
+
if (containsAny(imports, CLOCK_IMPORTS)) clockFiles.push(projectPath);
|
|
227
|
+
if (containsAny(imports, CONTROLLER_IMPORTS))
|
|
228
|
+
controllerFiles.push(projectPath);
|
|
229
|
+
}
|
|
230
|
+
const appPath = (0, import_node_path.join)(projectRoot, PRODUCTION_APP);
|
|
231
|
+
const productionEntryExists = (0, import_node_fs.existsSync)(appPath);
|
|
232
|
+
const productionUsesExample = productionFiles.some(
|
|
233
|
+
(file) => productionFileImportsExample(file, projectRoot)
|
|
234
|
+
);
|
|
235
|
+
const staleExampleTestFiles = productionUsesExample ? [] : runnableTestFiles(projectRoot).filter((file) => importsExampleAlias((0, import_node_fs.readFileSync)(file, "utf8"))).map((file) => (0, import_node_path.relative)(projectRoot, file).replaceAll("\\", "/"));
|
|
236
|
+
const issues = [];
|
|
237
|
+
if (staleExampleTestFiles.length > 0) {
|
|
238
|
+
issues.push(
|
|
239
|
+
`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.`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
if (clockFiles.length === 0 && controllerFiles.length === 0) {
|
|
243
|
+
return {
|
|
244
|
+
ok: issues.length === 0,
|
|
245
|
+
issues,
|
|
246
|
+
clockFiles,
|
|
247
|
+
controllerFiles,
|
|
248
|
+
productionEntryExists,
|
|
249
|
+
productionUsesExample,
|
|
250
|
+
staleExampleTestFiles
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
const testPath = (0, import_node_path.join)(projectRoot, PRODUCTION_PLAYTHROUGH);
|
|
254
|
+
const testSource = (0, import_node_fs.existsSync)(testPath) ? (0, import_node_fs.readFileSync)(testPath, "utf8") : "";
|
|
255
|
+
const testingImports = namedImports(testSource, REACT_TESTING_ENTRY);
|
|
256
|
+
const hasObserve = declaresObserve(testSource);
|
|
257
|
+
if (!hasObserve) {
|
|
258
|
+
issues.push(
|
|
259
|
+
`${PRODUCTION_PLAYTHROUGH} must declare observe because production uses the devkit clock or Controller ownership hook. Pass { observe: () => telemetry.read.session() } to playthroughTest, using Telemetry backed by the same production Controller rendered by <App />; DOM labels are not an authoritative gameplay boundary.`
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
if (clockFiles.length > 0 && !testingImports.has("ManualGameClock")) {
|
|
263
|
+
issues.push(
|
|
264
|
+
`${PRODUCTION_PLAYTHROUGH} must import ManualGameClock because production owns gameplay time in ${clockFiles.join(", ")}. Add this import to the test: import { ManualGameClock } from "miaoda-game-devkit/react/testing"; Then create one, inject it through the production <App /> factory, and advance it with step: () => clock.stepFrame().`
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
ok: issues.length === 0,
|
|
269
|
+
issues,
|
|
270
|
+
clockFiles,
|
|
271
|
+
controllerFiles,
|
|
272
|
+
productionEntryExists,
|
|
273
|
+
productionUsesExample,
|
|
274
|
+
staleExampleTestFiles
|
|
275
|
+
};
|
|
276
|
+
}
|
|
44
277
|
|
|
45
278
|
// src/react/react-playthrough.ts
|
|
46
279
|
var import_react2 = require("@testing-library/react");
|
|
@@ -628,6 +861,25 @@ function auditReactPlaythroughRun(tests) {
|
|
|
628
861
|
// src/react/react-playthrough-reporter.ts
|
|
629
862
|
var PRODUCTION_PLAYTHROUGH_FILE = "tests/production-playthrough.test.tsx";
|
|
630
863
|
var REPAIR_CONSTRAINT = "Preserve the intended gameplay outcome. Fix the production mechanic, deterministic driver, or authoritative observation that prevents it. Do not make the test pass by weakening or deleting assertions, replacing the outcome with back/menu/exit navigation, observing arbitrary UI text only to change a fingerprint, using a no-op step, or treating an intermediate active/in-flight/running phase as meaningful progress.";
|
|
864
|
+
function assessProductTestAlignment(projectRoot) {
|
|
865
|
+
const audit = auditReactAuthoritativePlaythrough(projectRoot);
|
|
866
|
+
if (audit.staleExampleTestFiles.length > 0) {
|
|
867
|
+
return { status: "FAILED", cause: audit.issues[0] };
|
|
868
|
+
}
|
|
869
|
+
if (!audit.productionEntryExists) {
|
|
870
|
+
return {
|
|
871
|
+
status: "NOT_VERIFIED",
|
|
872
|
+
cause: "The production src/App.tsx entry does not exist, so product-test alignment could not be verified."
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
if (audit.productionUsesExample) {
|
|
876
|
+
return {
|
|
877
|
+
status: "NOT_VERIFIED",
|
|
878
|
+
cause: "The production App still uses the replaceable src/game/example teaching game, so replacement-game test alignment is not applicable yet."
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
return { status: "PASS" };
|
|
882
|
+
}
|
|
631
883
|
function isMetadata(value) {
|
|
632
884
|
if (!value || typeof value !== "object") return false;
|
|
633
885
|
const metadata = value;
|
|
@@ -655,7 +907,7 @@ function toAuditInput(test2) {
|
|
|
655
907
|
}
|
|
656
908
|
function findPendingProductTests(modules, projectRoot) {
|
|
657
909
|
return modules.flatMap((module2) => {
|
|
658
|
-
const file = (0,
|
|
910
|
+
const file = (0, import_node_path2.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/");
|
|
659
911
|
if (file.split("/").includes("examples")) return [];
|
|
660
912
|
const tests = [...module2.children.allTests()];
|
|
661
913
|
const pending = tests.filter((test2) => {
|
|
@@ -752,7 +1004,10 @@ function truncateReporterLine(value, limit) {
|
|
|
752
1004
|
}
|
|
753
1005
|
function toModuleResult(module2, projectRoot) {
|
|
754
1006
|
const tests = [...module2.children.allTests()];
|
|
755
|
-
const moduleFailure = selectReactFailure(
|
|
1007
|
+
const moduleFailure = selectReactFailure(
|
|
1008
|
+
module2.errors(),
|
|
1009
|
+
module2.errors().length
|
|
1010
|
+
);
|
|
756
1011
|
const moduleErrors = extractFailureEntries(module2.errors()).map(
|
|
757
1012
|
(entry) => firstLine(entry.message) ?? entry.message
|
|
758
1013
|
);
|
|
@@ -778,7 +1033,7 @@ function toModuleResult(module2, projectRoot) {
|
|
|
778
1033
|
causeCode: selected.code,
|
|
779
1034
|
cause: selected.cause,
|
|
780
1035
|
related: selected.related,
|
|
781
|
-
location: test2.location ? `${(0,
|
|
1036
|
+
location: test2.location ? `${(0, import_node_path2.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(selected.rawCause),
|
|
782
1037
|
hint: failureHint(selected.rawCause),
|
|
783
1038
|
trace: failureTrace(test2)
|
|
784
1039
|
};
|
|
@@ -794,7 +1049,7 @@ function toModuleResult(module2, projectRoot) {
|
|
|
794
1049
|
});
|
|
795
1050
|
}
|
|
796
1051
|
return {
|
|
797
|
-
file: (0,
|
|
1052
|
+
file: (0, import_node_path2.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/"),
|
|
798
1053
|
state: module2.state(),
|
|
799
1054
|
errors,
|
|
800
1055
|
primaryError: moduleErrors[0] ?? failures[0]?.cause,
|
|
@@ -932,8 +1187,13 @@ function assessReactPlaythroughReport(input) {
|
|
|
932
1187
|
}
|
|
933
1188
|
return { ...base, status: "PASS", failsRun: false };
|
|
934
1189
|
}
|
|
935
|
-
function formatReactPlaythroughReport(report) {
|
|
936
|
-
const lines = [
|
|
1190
|
+
function formatReactPlaythroughReport(report, alignment) {
|
|
1191
|
+
const lines = [
|
|
1192
|
+
`REACT_PLAYTHROUGH_STRUCTURE: ${report.status}`,
|
|
1193
|
+
`PRODUCT_TEST_ALIGNMENT: ${alignment?.status ?? "NOT_VERIFIED"}`,
|
|
1194
|
+
`FILE: ${report.file}`
|
|
1195
|
+
];
|
|
1196
|
+
if (alignment?.cause) lines.push(`ALIGNMENT_CAUSE: ${alignment.cause}`);
|
|
937
1197
|
if (report.causeCode) lines.push(`CAUSE_CODE: ${report.causeCode}`);
|
|
938
1198
|
if (report.cause) lines.push(`CAUSE: ${report.cause}`);
|
|
939
1199
|
for (const [index, related] of (report.related ?? []).entries()) {
|
|
@@ -956,7 +1216,7 @@ var ReactPlaythroughReporter = class {
|
|
|
956
1216
|
/** 绑定模板根目录,以稳定识别生产可玩性测试而不依赖测试名称。 */
|
|
957
1217
|
constructor(projectRoot) {
|
|
958
1218
|
this.projectRoot = projectRoot;
|
|
959
|
-
this.expectedModuleId = (0,
|
|
1219
|
+
this.expectedModuleId = (0, import_node_path2.resolve)(projectRoot, this.expectedFile);
|
|
960
1220
|
}
|
|
961
1221
|
projectRoot;
|
|
962
1222
|
expectedFile = PRODUCTION_PLAYTHROUGH_FILE;
|
|
@@ -966,7 +1226,7 @@ var ReactPlaythroughReporter = class {
|
|
|
966
1226
|
/** 记录本轮是否实际选择了生产流程文件,用于区分聚焦运行与门禁失败。 */
|
|
967
1227
|
onTestRunStart(specifications) {
|
|
968
1228
|
this.expectedFileScheduled = specifications.some(
|
|
969
|
-
(specification) => (0,
|
|
1229
|
+
(specification) => (0, import_node_path2.resolve)(specification.moduleId) === this.expectedModuleId
|
|
970
1230
|
);
|
|
971
1231
|
this.focusedSelection = specifications.some(
|
|
972
1232
|
(specification) => Boolean(specification.project.globalConfig.testNamePattern) || Boolean(specification.testNamePattern) || Boolean(specification.testLines?.length)
|
|
@@ -980,7 +1240,7 @@ var ReactPlaythroughReporter = class {
|
|
|
980
1240
|
);
|
|
981
1241
|
const report = assessReactPlaythroughReport({
|
|
982
1242
|
expectedFile: this.expectedFile,
|
|
983
|
-
expectedFileExists: (0,
|
|
1243
|
+
expectedFileExists: (0, import_node_fs2.existsSync)(this.expectedModuleId),
|
|
984
1244
|
expectedFileScheduled: this.expectedFileScheduled,
|
|
985
1245
|
focusedSelection: this.focusedSelection,
|
|
986
1246
|
modules: testModules.map(
|
|
@@ -990,8 +1250,9 @@ var ReactPlaythroughReporter = class {
|
|
|
990
1250
|
(entry) => firstLine(entry.message) ?? entry.message
|
|
991
1251
|
)
|
|
992
1252
|
});
|
|
993
|
-
const
|
|
994
|
-
|
|
1253
|
+
const alignment = assessProductTestAlignment(this.projectRoot);
|
|
1254
|
+
const output = formatReactPlaythroughReport(report, alignment);
|
|
1255
|
+
if (report.failsRun || alignment.status === "FAILED") {
|
|
995
1256
|
console.error(output);
|
|
996
1257
|
process.exitCode = 1;
|
|
997
1258
|
} else if (report.status === "WAIVED" || report.status === "NOT_CHECKED") {
|
|
@@ -1007,7 +1268,7 @@ var ReactPlaythroughReporter = class {
|
|
|
1007
1268
|
const summary = formatReactFailureSummary(
|
|
1008
1269
|
testModules.map((module2) => toModuleResult(module2, this.projectRoot))
|
|
1009
1270
|
);
|
|
1010
|
-
if ((report.failsRun || pendingProductTests.length > 0) && summary[0] === "TEST_RESULT: PASS") {
|
|
1271
|
+
if ((report.failsRun || alignment.status === "FAILED" || pendingProductTests.length > 0) && summary[0] === "TEST_RESULT: PASS") {
|
|
1011
1272
|
summary[0] = "TEST_RESULT: FAIL";
|
|
1012
1273
|
}
|
|
1013
1274
|
console.log(`
|
|
@@ -1023,16 +1284,16 @@ function getJSDOMWorkerExecArgv() {
|
|
|
1023
1284
|
|
|
1024
1285
|
// src/react-vitest-config.ts
|
|
1025
1286
|
function resolvePhaser3BrowserEntry(projectRoot) {
|
|
1026
|
-
const manifestPath = (0,
|
|
1027
|
-
if (!(0,
|
|
1287
|
+
const manifestPath = (0, import_node_path3.resolve)(projectRoot, "node_modules/phaser/package.json");
|
|
1288
|
+
if (!(0, import_node_fs3.existsSync)(manifestPath)) return void 0;
|
|
1028
1289
|
try {
|
|
1029
|
-
const manifest = JSON.parse((0,
|
|
1290
|
+
const manifest = JSON.parse((0, import_node_fs3.readFileSync)(manifestPath, "utf8"));
|
|
1030
1291
|
if (!manifest.version?.startsWith("3.")) return void 0;
|
|
1031
|
-
const browserEntry = (0,
|
|
1032
|
-
(0,
|
|
1292
|
+
const browserEntry = (0, import_node_path3.resolve)(
|
|
1293
|
+
(0, import_node_path3.dirname)(manifestPath),
|
|
1033
1294
|
manifest.browser ?? "dist/phaser.js"
|
|
1034
1295
|
);
|
|
1035
|
-
return (0,
|
|
1296
|
+
return (0, import_node_fs3.existsSync)(browserEntry) ? browserEntry : void 0;
|
|
1036
1297
|
} catch {
|
|
1037
1298
|
return void 0;
|
|
1038
1299
|
}
|
|
@@ -1047,7 +1308,7 @@ function defineReactGameVitestConfig(options) {
|
|
|
1047
1308
|
alias: {
|
|
1048
1309
|
...phaser3BrowserEntry ? { phaser: phaser3BrowserEntry } : {},
|
|
1049
1310
|
...options.aliases,
|
|
1050
|
-
"@": (0,
|
|
1311
|
+
"@": (0, import_node_path3.resolve)(options.projectRoot, "src")
|
|
1051
1312
|
}
|
|
1052
1313
|
},
|
|
1053
1314
|
test: {
|
|
@@ -1,13 +1,246 @@
|
|
|
1
1
|
// src/react-vitest-config.ts
|
|
2
|
-
import { existsSync as
|
|
3
|
-
import { dirname, resolve as
|
|
2
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
3
|
+
import { dirname as dirname2, resolve as resolve3 } from "path";
|
|
4
4
|
import { defineConfig } from "vitest/config";
|
|
5
5
|
|
|
6
6
|
// src/react/react-playthrough-reporter.ts
|
|
7
|
-
import { existsSync } from "fs";
|
|
8
|
-
import { relative, resolve } from "path";
|
|
7
|
+
import { existsSync as existsSync2 } from "fs";
|
|
8
|
+
import { relative as relative2, resolve as resolve2 } from "path";
|
|
9
9
|
import { stripVTControlCharacters } from "util";
|
|
10
10
|
|
|
11
|
+
// src/cli/react-authoritative-playthrough.ts
|
|
12
|
+
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
13
|
+
import { dirname, join, relative, resolve, sep } from "path";
|
|
14
|
+
var PRODUCTION_PLAYTHROUGH = "tests/production-playthrough.test.tsx";
|
|
15
|
+
var PRODUCTION_APP = "src/App.tsx";
|
|
16
|
+
var EXAMPLE_IMPORT_PREFIX = "@/game/example/";
|
|
17
|
+
var REACT_RUNTIME_ENTRY = "miaoda-game-devkit/react";
|
|
18
|
+
var REACT_TESTING_ENTRY = "miaoda-game-devkit/react/testing";
|
|
19
|
+
var CLOCK_IMPORTS = /* @__PURE__ */ new Set(["browserGameClock"]);
|
|
20
|
+
var CONTROLLER_IMPORTS = /* @__PURE__ */ new Set([
|
|
21
|
+
"useGameController",
|
|
22
|
+
"useOwnedGameController"
|
|
23
|
+
]);
|
|
24
|
+
var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
|
|
25
|
+
function runnableTestFiles(root, directory = join(root, "tests")) {
|
|
26
|
+
if (!existsSync(directory)) return [];
|
|
27
|
+
const files = [];
|
|
28
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
29
|
+
const path = join(directory, entry.name);
|
|
30
|
+
if (entry.isDirectory()) {
|
|
31
|
+
files.push(...runnableTestFiles(root, path));
|
|
32
|
+
} else if (entry.isFile() && /\.test\.[cm]?[jt]sx?$/.test(entry.name)) {
|
|
33
|
+
files.push(path);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return files;
|
|
37
|
+
}
|
|
38
|
+
function extension(path) {
|
|
39
|
+
const index = path.lastIndexOf(".");
|
|
40
|
+
return index < 0 ? "" : path.slice(index);
|
|
41
|
+
}
|
|
42
|
+
function sourceFiles(root, directory = join(root, "src")) {
|
|
43
|
+
if (!existsSync(directory)) return [];
|
|
44
|
+
const files = [];
|
|
45
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
46
|
+
const path = join(directory, entry.name);
|
|
47
|
+
const projectPath = relative(root, path).replaceAll("\\", "/");
|
|
48
|
+
if (entry.isDirectory()) {
|
|
49
|
+
if (projectPath === "src/game/example") continue;
|
|
50
|
+
files.push(...sourceFiles(root, path));
|
|
51
|
+
} else if (entry.isFile() && SOURCE_EXTENSIONS.has(extension(entry.name))) {
|
|
52
|
+
files.push(path);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return files;
|
|
56
|
+
}
|
|
57
|
+
function withoutComments(source) {
|
|
58
|
+
let output = "";
|
|
59
|
+
let state = "code";
|
|
60
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
61
|
+
const char = source[index];
|
|
62
|
+
const next = source[index + 1];
|
|
63
|
+
if (state === "line") {
|
|
64
|
+
if (char === "\n") {
|
|
65
|
+
state = "code";
|
|
66
|
+
output += char;
|
|
67
|
+
} else {
|
|
68
|
+
output += " ";
|
|
69
|
+
}
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (state === "block") {
|
|
73
|
+
if (char === "*" && next === "/") {
|
|
74
|
+
output += " ";
|
|
75
|
+
index += 1;
|
|
76
|
+
state = "code";
|
|
77
|
+
} else {
|
|
78
|
+
output += char === "\n" ? "\n" : " ";
|
|
79
|
+
}
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (state === "code" && char === "/" && next === "/") {
|
|
83
|
+
output += " ";
|
|
84
|
+
index += 1;
|
|
85
|
+
state = "line";
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (state === "code" && char === "/" && next === "*") {
|
|
89
|
+
output += " ";
|
|
90
|
+
index += 1;
|
|
91
|
+
state = "block";
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (state === "code" && char === "'") state = "single";
|
|
95
|
+
else if (state === "code" && char === '"') state = "double";
|
|
96
|
+
else if (state === "code" && char === "`") state = "template";
|
|
97
|
+
else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
|
|
98
|
+
state = "code";
|
|
99
|
+
} else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
|
|
100
|
+
state = "code";
|
|
101
|
+
} else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
|
|
102
|
+
state = "code";
|
|
103
|
+
}
|
|
104
|
+
output += char;
|
|
105
|
+
}
|
|
106
|
+
return output;
|
|
107
|
+
}
|
|
108
|
+
function codePositions(source) {
|
|
109
|
+
const positions = Array.from({ length: source.length }, () => false);
|
|
110
|
+
let state = "code";
|
|
111
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
112
|
+
const char = source[index];
|
|
113
|
+
if (state === "code") positions[index] = true;
|
|
114
|
+
if (state === "code" && char === "'") state = "single";
|
|
115
|
+
else if (state === "code" && char === '"') state = "double";
|
|
116
|
+
else if (state === "code" && char === "`") state = "template";
|
|
117
|
+
else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
|
|
118
|
+
state = "code";
|
|
119
|
+
} else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
|
|
120
|
+
state = "code";
|
|
121
|
+
} else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
|
|
122
|
+
state = "code";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return positions;
|
|
126
|
+
}
|
|
127
|
+
function importedModuleSpecifiers(source) {
|
|
128
|
+
const clean = withoutComments(source);
|
|
129
|
+
const positions = codePositions(clean);
|
|
130
|
+
const modules = [];
|
|
131
|
+
const pattern = /\b(?:from\s+|import\s*(?:\(\s*)?)["']([^"']+)["']\s*\)?/g;
|
|
132
|
+
for (const match of clean.matchAll(pattern)) {
|
|
133
|
+
if (positions[match.index]) modules.push(match[1]);
|
|
134
|
+
}
|
|
135
|
+
return modules;
|
|
136
|
+
}
|
|
137
|
+
function productionFileImportsExample(file, projectRoot) {
|
|
138
|
+
const exampleRoot = join(projectRoot, "src/game/example");
|
|
139
|
+
return importedModuleSpecifiers(readFileSync(file, "utf8")).some(
|
|
140
|
+
(moduleName) => {
|
|
141
|
+
if (moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)) return true;
|
|
142
|
+
if (!moduleName.startsWith(".")) return false;
|
|
143
|
+
const target = resolve(dirname(file), moduleName);
|
|
144
|
+
return target === exampleRoot || target.startsWith(`${exampleRoot}${sep}`);
|
|
145
|
+
}
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
function importsExampleAlias(source) {
|
|
149
|
+
return importedModuleSpecifiers(source).some(
|
|
150
|
+
(moduleName) => moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
function namedImports(source, moduleName) {
|
|
154
|
+
const names = /* @__PURE__ */ new Set();
|
|
155
|
+
const clean = withoutComments(source);
|
|
156
|
+
const positions = codePositions(clean);
|
|
157
|
+
const escapedModule = moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
158
|
+
const pattern = new RegExp(
|
|
159
|
+
`^\\s*import\\s+(?:type\\s+)?\\{([\\s\\S]*?)\\}\\s+from\\s+["']${escapedModule}["']`,
|
|
160
|
+
"gm"
|
|
161
|
+
);
|
|
162
|
+
for (const match of clean.matchAll(pattern)) {
|
|
163
|
+
if (!positions[match.index]) continue;
|
|
164
|
+
for (const specifier of match[1].split(",")) {
|
|
165
|
+
const imported = specifier.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim();
|
|
166
|
+
if (imported) names.add(imported);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return names;
|
|
170
|
+
}
|
|
171
|
+
function containsAny(values, expected) {
|
|
172
|
+
return [...values].some((value) => expected.has(value));
|
|
173
|
+
}
|
|
174
|
+
function declaresObserve(source) {
|
|
175
|
+
const clean = withoutComments(source);
|
|
176
|
+
const positions = codePositions(clean);
|
|
177
|
+
for (const match of clean.matchAll(/\bobserve\s*:/g)) {
|
|
178
|
+
if (positions[match.index]) return true;
|
|
179
|
+
}
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
function auditReactAuthoritativePlaythrough(projectRoot) {
|
|
183
|
+
const clockFiles = [];
|
|
184
|
+
const controllerFiles = [];
|
|
185
|
+
const productionFiles = sourceFiles(projectRoot);
|
|
186
|
+
for (const file of productionFiles) {
|
|
187
|
+
const imports = namedImports(
|
|
188
|
+
readFileSync(file, "utf8"),
|
|
189
|
+
REACT_RUNTIME_ENTRY
|
|
190
|
+
);
|
|
191
|
+
const projectPath = relative(projectRoot, file).replaceAll("\\", "/");
|
|
192
|
+
if (containsAny(imports, CLOCK_IMPORTS)) clockFiles.push(projectPath);
|
|
193
|
+
if (containsAny(imports, CONTROLLER_IMPORTS))
|
|
194
|
+
controllerFiles.push(projectPath);
|
|
195
|
+
}
|
|
196
|
+
const appPath = join(projectRoot, PRODUCTION_APP);
|
|
197
|
+
const productionEntryExists = existsSync(appPath);
|
|
198
|
+
const productionUsesExample = productionFiles.some(
|
|
199
|
+
(file) => productionFileImportsExample(file, projectRoot)
|
|
200
|
+
);
|
|
201
|
+
const staleExampleTestFiles = productionUsesExample ? [] : runnableTestFiles(projectRoot).filter((file) => importsExampleAlias(readFileSync(file, "utf8"))).map((file) => relative(projectRoot, file).replaceAll("\\", "/"));
|
|
202
|
+
const issues = [];
|
|
203
|
+
if (staleExampleTestFiles.length > 0) {
|
|
204
|
+
issues.push(
|
|
205
|
+
`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.`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
if (clockFiles.length === 0 && controllerFiles.length === 0) {
|
|
209
|
+
return {
|
|
210
|
+
ok: issues.length === 0,
|
|
211
|
+
issues,
|
|
212
|
+
clockFiles,
|
|
213
|
+
controllerFiles,
|
|
214
|
+
productionEntryExists,
|
|
215
|
+
productionUsesExample,
|
|
216
|
+
staleExampleTestFiles
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
const testPath = join(projectRoot, PRODUCTION_PLAYTHROUGH);
|
|
220
|
+
const testSource = existsSync(testPath) ? readFileSync(testPath, "utf8") : "";
|
|
221
|
+
const testingImports = namedImports(testSource, REACT_TESTING_ENTRY);
|
|
222
|
+
const hasObserve = declaresObserve(testSource);
|
|
223
|
+
if (!hasObserve) {
|
|
224
|
+
issues.push(
|
|
225
|
+
`${PRODUCTION_PLAYTHROUGH} must declare observe because production uses the devkit clock or Controller ownership hook. Pass { observe: () => telemetry.read.session() } to playthroughTest, using Telemetry backed by the same production Controller rendered by <App />; DOM labels are not an authoritative gameplay boundary.`
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
if (clockFiles.length > 0 && !testingImports.has("ManualGameClock")) {
|
|
229
|
+
issues.push(
|
|
230
|
+
`${PRODUCTION_PLAYTHROUGH} must import ManualGameClock because production owns gameplay time in ${clockFiles.join(", ")}. Add this import to the test: import { ManualGameClock } from "miaoda-game-devkit/react/testing"; Then create one, inject it through the production <App /> factory, and advance it with step: () => clock.stepFrame().`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
ok: issues.length === 0,
|
|
235
|
+
issues,
|
|
236
|
+
clockFiles,
|
|
237
|
+
controllerFiles,
|
|
238
|
+
productionEntryExists,
|
|
239
|
+
productionUsesExample,
|
|
240
|
+
staleExampleTestFiles
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
11
244
|
// src/react/react-playthrough.ts
|
|
12
245
|
import { render } from "@testing-library/react";
|
|
13
246
|
import userEvent from "@testing-library/user-event";
|
|
@@ -594,6 +827,25 @@ function auditReactPlaythroughRun(tests) {
|
|
|
594
827
|
// src/react/react-playthrough-reporter.ts
|
|
595
828
|
var PRODUCTION_PLAYTHROUGH_FILE = "tests/production-playthrough.test.tsx";
|
|
596
829
|
var REPAIR_CONSTRAINT = "Preserve the intended gameplay outcome. Fix the production mechanic, deterministic driver, or authoritative observation that prevents it. Do not make the test pass by weakening or deleting assertions, replacing the outcome with back/menu/exit navigation, observing arbitrary UI text only to change a fingerprint, using a no-op step, or treating an intermediate active/in-flight/running phase as meaningful progress.";
|
|
830
|
+
function assessProductTestAlignment(projectRoot) {
|
|
831
|
+
const audit = auditReactAuthoritativePlaythrough(projectRoot);
|
|
832
|
+
if (audit.staleExampleTestFiles.length > 0) {
|
|
833
|
+
return { status: "FAILED", cause: audit.issues[0] };
|
|
834
|
+
}
|
|
835
|
+
if (!audit.productionEntryExists) {
|
|
836
|
+
return {
|
|
837
|
+
status: "NOT_VERIFIED",
|
|
838
|
+
cause: "The production src/App.tsx entry does not exist, so product-test alignment could not be verified."
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
if (audit.productionUsesExample) {
|
|
842
|
+
return {
|
|
843
|
+
status: "NOT_VERIFIED",
|
|
844
|
+
cause: "The production App still uses the replaceable src/game/example teaching game, so replacement-game test alignment is not applicable yet."
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
return { status: "PASS" };
|
|
848
|
+
}
|
|
597
849
|
function isMetadata(value) {
|
|
598
850
|
if (!value || typeof value !== "object") return false;
|
|
599
851
|
const metadata = value;
|
|
@@ -621,7 +873,7 @@ function toAuditInput(test2) {
|
|
|
621
873
|
}
|
|
622
874
|
function findPendingProductTests(modules, projectRoot) {
|
|
623
875
|
return modules.flatMap((module) => {
|
|
624
|
-
const file =
|
|
876
|
+
const file = relative2(projectRoot, module.moduleId).replaceAll("\\", "/");
|
|
625
877
|
if (file.split("/").includes("examples")) return [];
|
|
626
878
|
const tests = [...module.children.allTests()];
|
|
627
879
|
const pending = tests.filter((test2) => {
|
|
@@ -718,7 +970,10 @@ function truncateReporterLine(value, limit) {
|
|
|
718
970
|
}
|
|
719
971
|
function toModuleResult(module, projectRoot) {
|
|
720
972
|
const tests = [...module.children.allTests()];
|
|
721
|
-
const moduleFailure = selectReactFailure(
|
|
973
|
+
const moduleFailure = selectReactFailure(
|
|
974
|
+
module.errors(),
|
|
975
|
+
module.errors().length
|
|
976
|
+
);
|
|
722
977
|
const moduleErrors = extractFailureEntries(module.errors()).map(
|
|
723
978
|
(entry) => firstLine(entry.message) ?? entry.message
|
|
724
979
|
);
|
|
@@ -744,7 +999,7 @@ function toModuleResult(module, projectRoot) {
|
|
|
744
999
|
causeCode: selected.code,
|
|
745
1000
|
cause: selected.cause,
|
|
746
1001
|
related: selected.related,
|
|
747
|
-
location: test2.location ? `${
|
|
1002
|
+
location: test2.location ? `${relative2(projectRoot, module.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(selected.rawCause),
|
|
748
1003
|
hint: failureHint(selected.rawCause),
|
|
749
1004
|
trace: failureTrace(test2)
|
|
750
1005
|
};
|
|
@@ -760,7 +1015,7 @@ function toModuleResult(module, projectRoot) {
|
|
|
760
1015
|
});
|
|
761
1016
|
}
|
|
762
1017
|
return {
|
|
763
|
-
file:
|
|
1018
|
+
file: relative2(projectRoot, module.moduleId).replaceAll("\\", "/"),
|
|
764
1019
|
state: module.state(),
|
|
765
1020
|
errors,
|
|
766
1021
|
primaryError: moduleErrors[0] ?? failures[0]?.cause,
|
|
@@ -898,8 +1153,13 @@ function assessReactPlaythroughReport(input) {
|
|
|
898
1153
|
}
|
|
899
1154
|
return { ...base, status: "PASS", failsRun: false };
|
|
900
1155
|
}
|
|
901
|
-
function formatReactPlaythroughReport(report) {
|
|
902
|
-
const lines = [
|
|
1156
|
+
function formatReactPlaythroughReport(report, alignment) {
|
|
1157
|
+
const lines = [
|
|
1158
|
+
`REACT_PLAYTHROUGH_STRUCTURE: ${report.status}`,
|
|
1159
|
+
`PRODUCT_TEST_ALIGNMENT: ${alignment?.status ?? "NOT_VERIFIED"}`,
|
|
1160
|
+
`FILE: ${report.file}`
|
|
1161
|
+
];
|
|
1162
|
+
if (alignment?.cause) lines.push(`ALIGNMENT_CAUSE: ${alignment.cause}`);
|
|
903
1163
|
if (report.causeCode) lines.push(`CAUSE_CODE: ${report.causeCode}`);
|
|
904
1164
|
if (report.cause) lines.push(`CAUSE: ${report.cause}`);
|
|
905
1165
|
for (const [index, related] of (report.related ?? []).entries()) {
|
|
@@ -922,7 +1182,7 @@ var ReactPlaythroughReporter = class {
|
|
|
922
1182
|
/** 绑定模板根目录,以稳定识别生产可玩性测试而不依赖测试名称。 */
|
|
923
1183
|
constructor(projectRoot) {
|
|
924
1184
|
this.projectRoot = projectRoot;
|
|
925
|
-
this.expectedModuleId =
|
|
1185
|
+
this.expectedModuleId = resolve2(projectRoot, this.expectedFile);
|
|
926
1186
|
}
|
|
927
1187
|
projectRoot;
|
|
928
1188
|
expectedFile = PRODUCTION_PLAYTHROUGH_FILE;
|
|
@@ -932,7 +1192,7 @@ var ReactPlaythroughReporter = class {
|
|
|
932
1192
|
/** 记录本轮是否实际选择了生产流程文件,用于区分聚焦运行与门禁失败。 */
|
|
933
1193
|
onTestRunStart(specifications) {
|
|
934
1194
|
this.expectedFileScheduled = specifications.some(
|
|
935
|
-
(specification) =>
|
|
1195
|
+
(specification) => resolve2(specification.moduleId) === this.expectedModuleId
|
|
936
1196
|
);
|
|
937
1197
|
this.focusedSelection = specifications.some(
|
|
938
1198
|
(specification) => Boolean(specification.project.globalConfig.testNamePattern) || Boolean(specification.testNamePattern) || Boolean(specification.testLines?.length)
|
|
@@ -946,7 +1206,7 @@ var ReactPlaythroughReporter = class {
|
|
|
946
1206
|
);
|
|
947
1207
|
const report = assessReactPlaythroughReport({
|
|
948
1208
|
expectedFile: this.expectedFile,
|
|
949
|
-
expectedFileExists:
|
|
1209
|
+
expectedFileExists: existsSync2(this.expectedModuleId),
|
|
950
1210
|
expectedFileScheduled: this.expectedFileScheduled,
|
|
951
1211
|
focusedSelection: this.focusedSelection,
|
|
952
1212
|
modules: testModules.map(
|
|
@@ -956,8 +1216,9 @@ var ReactPlaythroughReporter = class {
|
|
|
956
1216
|
(entry) => firstLine(entry.message) ?? entry.message
|
|
957
1217
|
)
|
|
958
1218
|
});
|
|
959
|
-
const
|
|
960
|
-
|
|
1219
|
+
const alignment = assessProductTestAlignment(this.projectRoot);
|
|
1220
|
+
const output = formatReactPlaythroughReport(report, alignment);
|
|
1221
|
+
if (report.failsRun || alignment.status === "FAILED") {
|
|
961
1222
|
console.error(output);
|
|
962
1223
|
process.exitCode = 1;
|
|
963
1224
|
} else if (report.status === "WAIVED" || report.status === "NOT_CHECKED") {
|
|
@@ -973,7 +1234,7 @@ var ReactPlaythroughReporter = class {
|
|
|
973
1234
|
const summary = formatReactFailureSummary(
|
|
974
1235
|
testModules.map((module) => toModuleResult(module, this.projectRoot))
|
|
975
1236
|
);
|
|
976
|
-
if ((report.failsRun || pendingProductTests.length > 0) && summary[0] === "TEST_RESULT: PASS") {
|
|
1237
|
+
if ((report.failsRun || alignment.status === "FAILED" || pendingProductTests.length > 0) && summary[0] === "TEST_RESULT: PASS") {
|
|
977
1238
|
summary[0] = "TEST_RESULT: FAIL";
|
|
978
1239
|
}
|
|
979
1240
|
console.log(`
|
|
@@ -989,16 +1250,16 @@ function getJSDOMWorkerExecArgv() {
|
|
|
989
1250
|
|
|
990
1251
|
// src/react-vitest-config.ts
|
|
991
1252
|
function resolvePhaser3BrowserEntry(projectRoot) {
|
|
992
|
-
const manifestPath =
|
|
993
|
-
if (!
|
|
1253
|
+
const manifestPath = resolve3(projectRoot, "node_modules/phaser/package.json");
|
|
1254
|
+
if (!existsSync3(manifestPath)) return void 0;
|
|
994
1255
|
try {
|
|
995
|
-
const manifest = JSON.parse(
|
|
1256
|
+
const manifest = JSON.parse(readFileSync2(manifestPath, "utf8"));
|
|
996
1257
|
if (!manifest.version?.startsWith("3.")) return void 0;
|
|
997
|
-
const browserEntry =
|
|
998
|
-
|
|
1258
|
+
const browserEntry = resolve3(
|
|
1259
|
+
dirname2(manifestPath),
|
|
999
1260
|
manifest.browser ?? "dist/phaser.js"
|
|
1000
1261
|
);
|
|
1001
|
-
return
|
|
1262
|
+
return existsSync3(browserEntry) ? browserEntry : void 0;
|
|
1002
1263
|
} catch {
|
|
1003
1264
|
return void 0;
|
|
1004
1265
|
}
|
|
@@ -1013,7 +1274,7 @@ function defineReactGameVitestConfig(options) {
|
|
|
1013
1274
|
alias: {
|
|
1014
1275
|
...phaser3BrowserEntry ? { phaser: phaser3BrowserEntry } : {},
|
|
1015
1276
|
...options.aliases,
|
|
1016
|
-
"@":
|
|
1277
|
+
"@": resolve3(options.projectRoot, "src")
|
|
1017
1278
|
}
|
|
1018
1279
|
},
|
|
1019
1280
|
test: {
|
|
@@ -57,7 +57,7 @@ var rule = {
|
|
|
57
57
|
providedUser: "Use the user provided by playthroughTest; do not import or create another userEvent instance in the production playthrough.",
|
|
58
58
|
productionTestingImport: "Production source must not import miaoda-game-devkit/react/testing. Inject test clocks and observers through the production App factory boundary.",
|
|
59
59
|
productionEntry: "Render <App /> from the production playthrough. Import Controller and Telemetry helpers when needed, but do not render an internal game component directly.",
|
|
60
|
-
emptyStep:
|
|
60
|
+
emptyStep: 'A deterministic step must advance production-owned time, frames, or queued work. An empty step is not gameplay evidence. Add import { ManualGameClock } from "miaoda-game-devkit/react/testing"; inject it through the production <App /> factory, and use step: () => clock.stepFrame().'
|
|
61
61
|
},
|
|
62
62
|
schema: []
|
|
63
63
|
},
|