miaoda-game-devkit 0.2.21 → 0.3.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.
package/README.md CHANGED
@@ -154,8 +154,8 @@ DOM 到 Phaser 的输入、命中测试和游戏状态变化,不验证 Canvas/
154
154
  给出直接修复步骤;测试文件无法加载或配置要求的测试尚未建立时,不再继续报告 Scene
155
155
  注册等派生问题。先处理第一类错误,再重新运行 `pnpm test`。
156
156
 
157
- 共享 Vitest 配置使用 `minimal` reporter `text-summary` coverage reporter,保留失败
158
- 定位、Scene 覆盖摘要和最终机器标识,同时避免输出完整测试列表和逐文件噪声。
157
+ Phaser Vitest 配置使用 `minimal` reporter;React Vitest 配置使用无 ANSI 的精简 reporter
158
+ 两者都保留失败定位、coverage 摘要和最终机器标识,同时避免输出完整 DOM 快照、测试列表和逐文件噪声。
159
159
 
160
160
  ## 发布顺序
161
161
 
@@ -10,4 +10,22 @@ interface DisposableGameController {
10
10
  */
11
11
  declare function useOwnedGameController<TController extends DisposableGameController>(createController: () => TController): TController;
12
12
 
13
- export { type DisposableGameController, useOwnedGameController };
13
+ /** 同时提供订阅与权威快照读取的游戏 Controller,是最小可渲染运行时。 */
14
+ interface SubscribableGameController<TState> extends DisposableGameController {
15
+ subscribe(listener: () => void): () => void;
16
+ snapshot(): TState;
17
+ }
18
+ /**
19
+ * 一次调用接管游戏 Controller 的创建、销毁与状态订阅:
20
+ * useOwnedGameController 的 ownership,加上 useSyncExternalStore 的渲染桥。
21
+ *
22
+ * 附带一道开发期检查:状态内容变了、但 snapshot() 返回的还是原来那个
23
+ * 对象时,React 会认为"什么都没变"而不刷新界面——游戏悄悄卡死,没有
24
+ * 任何报错。检测到这种情况就立刻抛错,并直接告诉你怎么修。
25
+ */
26
+ declare function useGameController<TController extends SubscribableGameController<unknown>>(createController: () => TController): {
27
+ game: TController;
28
+ state: ReturnType<TController["snapshot"]>;
29
+ };
30
+
31
+ export { type DisposableGameController, type SubscribableGameController, useGameController, useOwnedGameController };
@@ -10,4 +10,22 @@ interface DisposableGameController {
10
10
  */
11
11
  declare function useOwnedGameController<TController extends DisposableGameController>(createController: () => TController): TController;
12
12
 
13
- export { type DisposableGameController, useOwnedGameController };
13
+ /** 同时提供订阅与权威快照读取的游戏 Controller,是最小可渲染运行时。 */
14
+ interface SubscribableGameController<TState> extends DisposableGameController {
15
+ subscribe(listener: () => void): () => void;
16
+ snapshot(): TState;
17
+ }
18
+ /**
19
+ * 一次调用接管游戏 Controller 的创建、销毁与状态订阅:
20
+ * useOwnedGameController 的 ownership,加上 useSyncExternalStore 的渲染桥。
21
+ *
22
+ * 附带一道开发期检查:状态内容变了、但 snapshot() 返回的还是原来那个
23
+ * 对象时,React 会认为"什么都没变"而不刷新界面——游戏悄悄卡死,没有
24
+ * 任何报错。检测到这种情况就立刻抛错,并直接告诉你怎么修。
25
+ */
26
+ declare function useGameController<TController extends SubscribableGameController<unknown>>(createController: () => TController): {
27
+ game: TController;
28
+ state: ReturnType<TController["snapshot"]>;
29
+ };
30
+
31
+ export { type DisposableGameController, type SubscribableGameController, useGameController, useOwnedGameController };
@@ -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
+ useGameController: () => useGameController,
24
25
  useOwnedGameController: () => useOwnedGameController
25
26
  });
26
27
  module.exports = __toCommonJS(react_exports);
@@ -50,8 +51,57 @@ function useOwnedGameController(createController) {
50
51
  }, [controller]);
51
52
  return controller;
52
53
  }
54
+
55
+ // src/react/use-game-controller.ts
56
+ var import_react2 = require("react");
57
+ function useGameController(createController) {
58
+ const game = useOwnedGameController(createController);
59
+ useSnapshotIntegrityCheck(game);
60
+ const state = (0, import_react2.useSyncExternalStore)(
61
+ game.subscribe,
62
+ game.snapshot,
63
+ game.snapshot
64
+ );
65
+ return { game, state };
66
+ }
67
+ function useSnapshotIntegrityCheck(game) {
68
+ const reported = (0, import_react2.useRef)(false);
69
+ (0, import_react2.useEffect)(() => {
70
+ if (process.env.NODE_ENV === "production") return void 0;
71
+ let previous = game.snapshot();
72
+ let previousJson;
73
+ try {
74
+ previousJson = JSON.stringify(previous);
75
+ } catch {
76
+ previousJson = void 0;
77
+ }
78
+ return game.subscribe(() => {
79
+ if (reported.current) return;
80
+ const next = game.snapshot();
81
+ if (!Object.is(previous, next)) {
82
+ previous = next;
83
+ previousJson = void 0;
84
+ return;
85
+ }
86
+ let nextJson;
87
+ try {
88
+ nextJson = JSON.stringify(next);
89
+ } catch {
90
+ return;
91
+ }
92
+ if (previousJson !== void 0 && nextJson !== previousJson) {
93
+ reported.current = true;
94
+ throw new Error(
95
+ "Game state changed while snapshot() returned the same reference \u2014 React compares snapshots by reference and skips the re-render, so the UI freezes. \u539F\u56E0\uFF1AReact \u9760\u6BD4\u8F83\u5F15\u7528\u5224\u65AD\u72B6\u6001\u53D8\u6CA1\u53D8\uFF0C\u5F15\u7528\u76F8\u540C\u5C31\u5F53\u4F5C\u6CA1\u53D8\u5316\u3002\u4FEE\u590D\uFF1A\u6BCF\u6B21 notify \u524D\u6362\u4E00\u4E2A\u65B0\u5BF9\u8C61\uFF0C\u4F8B\u5982\u5728 controller \u91CC cachedSnapshot = { ...state }\uFF1B\u4E0D\u8981\u628A\u88AB\u539F\u5730\u4FEE\u6539\u7684\u5185\u90E8\u5BF9\u8C61\u76F4\u63A5\u7ED9 React\u3002"
96
+ );
97
+ }
98
+ previousJson = nextJson;
99
+ });
100
+ }, [game]);
101
+ }
53
102
  // Annotate the CommonJS export names for ESM import in node:
54
103
  0 && (module.exports = {
55
104
  browserGameClock,
105
+ useGameController,
56
106
  useOwnedGameController
57
107
  });
@@ -23,7 +23,56 @@ function useOwnedGameController(createController) {
23
23
  }, [controller]);
24
24
  return controller;
25
25
  }
26
+
27
+ // src/react/use-game-controller.ts
28
+ import { useEffect as useEffect2, useRef as useRef2, useSyncExternalStore } from "react";
29
+ function useGameController(createController) {
30
+ const game = useOwnedGameController(createController);
31
+ useSnapshotIntegrityCheck(game);
32
+ const state = useSyncExternalStore(
33
+ game.subscribe,
34
+ game.snapshot,
35
+ game.snapshot
36
+ );
37
+ return { game, state };
38
+ }
39
+ function useSnapshotIntegrityCheck(game) {
40
+ const reported = useRef2(false);
41
+ useEffect2(() => {
42
+ if (process.env.NODE_ENV === "production") return void 0;
43
+ let previous = game.snapshot();
44
+ let previousJson;
45
+ try {
46
+ previousJson = JSON.stringify(previous);
47
+ } catch {
48
+ previousJson = void 0;
49
+ }
50
+ return game.subscribe(() => {
51
+ if (reported.current) return;
52
+ const next = game.snapshot();
53
+ if (!Object.is(previous, next)) {
54
+ previous = next;
55
+ previousJson = void 0;
56
+ return;
57
+ }
58
+ let nextJson;
59
+ try {
60
+ nextJson = JSON.stringify(next);
61
+ } catch {
62
+ return;
63
+ }
64
+ if (previousJson !== void 0 && nextJson !== previousJson) {
65
+ reported.current = true;
66
+ throw new Error(
67
+ "Game state changed while snapshot() returned the same reference \u2014 React compares snapshots by reference and skips the re-render, so the UI freezes. \u539F\u56E0\uFF1AReact \u9760\u6BD4\u8F83\u5F15\u7528\u5224\u65AD\u72B6\u6001\u53D8\u6CA1\u53D8\uFF0C\u5F15\u7528\u76F8\u540C\u5C31\u5F53\u4F5C\u6CA1\u53D8\u5316\u3002\u4FEE\u590D\uFF1A\u6BCF\u6B21 notify \u524D\u6362\u4E00\u4E2A\u65B0\u5BF9\u8C61\uFF0C\u4F8B\u5982\u5728 controller \u91CC cachedSnapshot = { ...state }\uFF1B\u4E0D\u8981\u628A\u88AB\u539F\u5730\u4FEE\u6539\u7684\u5185\u90E8\u5BF9\u8C61\u76F4\u63A5\u7ED9 React\u3002"
68
+ );
69
+ }
70
+ previousJson = nextJson;
71
+ });
72
+ }, [game]);
73
+ }
26
74
  export {
27
75
  browserGameClock,
76
+ useGameController,
28
77
  useOwnedGameController
29
78
  };
@@ -33,6 +33,16 @@ interface StepUntilOptions {
33
33
  * The value is sampled only when the bound is exhausted.
34
34
  */
35
35
  diagnostics?: () => unknown;
36
+ /**
37
+ * 默认有一道检查:如果等待的结果"不用玩就已经成立"(第 0 步即成立,
38
+ * 且页面文本从进入游戏起一个字都没变),说明这个结果跟游戏过程无关,
39
+ * 判为失败——它能抓住输入没接上、界面卡死、断言了静态标题这类假通过。
40
+ *
41
+ * 例外:Canvas 游戏的结果画在画布上,页面文本本来就不会变。这类游戏
42
+ * 观察的是 Telemetry 等页面外的权威状态,此时置 true 跳过该检查。
43
+ * 结果通过 DOM 呈现的游戏不要传。
44
+ */
45
+ allowStaticDom?: boolean;
36
46
  }
37
47
 
38
48
  /** 单条 React 主流程测试留下的可序列化运行期证据。 */
@@ -33,6 +33,16 @@ interface StepUntilOptions {
33
33
  * The value is sampled only when the bound is exhausted.
34
34
  */
35
35
  diagnostics?: () => unknown;
36
+ /**
37
+ * 默认有一道检查:如果等待的结果"不用玩就已经成立"(第 0 步即成立,
38
+ * 且页面文本从进入游戏起一个字都没变),说明这个结果跟游戏过程无关,
39
+ * 判为失败——它能抓住输入没接上、界面卡死、断言了静态标题这类假通过。
40
+ *
41
+ * 例外:Canvas 游戏的结果画在画布上,页面文本本来就不会变。这类游戏
42
+ * 观察的是 Telemetry 等页面外的权威状态,此时置 true 跳过该检查。
43
+ * 结果通过 DOM 呈现的游戏不要传。
44
+ */
45
+ allowStaticDom?: boolean;
36
46
  }
37
47
 
38
48
  /** 单条 React 主流程测试留下的可序列化运行期证据。 */
@@ -192,6 +192,7 @@ function definePlaythrough(element, run, waiverReason) {
192
192
  const evidence = metadata.evidence;
193
193
  let assertionsAtOutcome;
194
194
  let enteredRecorded = false;
195
+ let domTextAtEntered;
195
196
  const recordInput = () => {
196
197
  evidence.domInputEvents += 1;
197
198
  };
@@ -244,6 +245,7 @@ function definePlaythrough(element, run, waiverReason) {
244
245
  );
245
246
  }
246
247
  enteredRecorded = true;
248
+ domTextAtEntered = document.body.textContent ?? "";
247
249
  evidence.checkpoints.push(kind);
248
250
  return;
249
251
  }
@@ -271,6 +273,11 @@ function definePlaythrough(element, run, waiverReason) {
271
273
  'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
272
274
  );
273
275
  }
276
+ if (steps === 0 && options.allowStaticDom !== true && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
277
+ throw new Error(
278
+ 'stepUntil outcome was already true at step 0 and the DOM has not changed since checkpoint("entered"), so the flow cannot prove that gameplay changed anything. \u610F\u601D\uFF1A\u6E38\u620F\u4E00\u6B65\u90FD\u6CA1\u73A9\uFF0C\u7B49\u5F85\u7684"\u7ED3\u679C"\u5C31\u5DF2\u7ECF\u6210\u7ACB\uFF0C\u9875\u9762\u4E5F\u4E00\u4E2A\u5B57\u6CA1\u53D8\u2014\u2014\u8FD9\u4E2A\u7ED3\u679C\u8BC1\u660E\u4E0D\u4E86\u4EFB\u4F55\u4E8B\u3002\u5E38\u89C1\u539F\u56E0\uFF1A\u2460 \u8F93\u5165\u6CA1\u6709\u63A5\u5230\u6E38\u620F\u4E0A\uFF1B\u2461 \u754C\u9762\u5361\u6B7B\uFF08\u72B6\u6001\u6539\u4E86\u4F46 snapshot \u5F15\u7528\u6CA1\u6362\uFF0CReact \u6CA1\u6709\u5237\u65B0\uFF09\uFF1B\u2462 \u65AD\u8A00\u4E86\u5F00\u5C40\u524D\u5C31\u5B58\u5728\u7684\u9759\u6001\u6587\u672C\u3002\u4FEE\u590D\uFF1A\u7B49\u5F85\u5E76\u65AD\u8A00\u53EA\u6709\u73A9\u8D77\u6765\u4E4B\u540E\u624D\u4F1A\u51FA\u73B0\u7684\u4E1C\u897F\uFF08\u5F00\u59CB\u906E\u7F69\u6D88\u5931\u3001\u6BD4\u5206\u53D8\u5316\u3001\u7ED3\u7B97\u753B\u9762\u51FA\u73B0\uFF09\u3002\u4F8B\u5916\uFF1A\u7ED3\u679C\u753B\u5728 Canvas \u4E0A\u3001\u7ECF Telemetry \u7B49\u9875\u9762\u5916\u72B6\u6001\u89C2\u5BDF\u7684\u6E38\u620F\uFF0C\u663E\u5F0F\u4F20 { allowStaticDom: true }\u3002'
279
+ );
280
+ }
274
281
  evidence.boundedRuns += 1;
275
282
  assertionsAtOutcome = expect.getState().assertionCalls;
276
283
  return steps;
@@ -154,6 +154,7 @@ function definePlaythrough(element, run, waiverReason) {
154
154
  const evidence = metadata.evidence;
155
155
  let assertionsAtOutcome;
156
156
  let enteredRecorded = false;
157
+ let domTextAtEntered;
157
158
  const recordInput = () => {
158
159
  evidence.domInputEvents += 1;
159
160
  };
@@ -206,6 +207,7 @@ function definePlaythrough(element, run, waiverReason) {
206
207
  );
207
208
  }
208
209
  enteredRecorded = true;
210
+ domTextAtEntered = document.body.textContent ?? "";
209
211
  evidence.checkpoints.push(kind);
210
212
  return;
211
213
  }
@@ -233,6 +235,11 @@ function definePlaythrough(element, run, waiverReason) {
233
235
  'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
234
236
  );
235
237
  }
238
+ if (steps === 0 && options.allowStaticDom !== true && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
239
+ throw new Error(
240
+ 'stepUntil outcome was already true at step 0 and the DOM has not changed since checkpoint("entered"), so the flow cannot prove that gameplay changed anything. \u610F\u601D\uFF1A\u6E38\u620F\u4E00\u6B65\u90FD\u6CA1\u73A9\uFF0C\u7B49\u5F85\u7684"\u7ED3\u679C"\u5C31\u5DF2\u7ECF\u6210\u7ACB\uFF0C\u9875\u9762\u4E5F\u4E00\u4E2A\u5B57\u6CA1\u53D8\u2014\u2014\u8FD9\u4E2A\u7ED3\u679C\u8BC1\u660E\u4E0D\u4E86\u4EFB\u4F55\u4E8B\u3002\u5E38\u89C1\u539F\u56E0\uFF1A\u2460 \u8F93\u5165\u6CA1\u6709\u63A5\u5230\u6E38\u620F\u4E0A\uFF1B\u2461 \u754C\u9762\u5361\u6B7B\uFF08\u72B6\u6001\u6539\u4E86\u4F46 snapshot \u5F15\u7528\u6CA1\u6362\uFF0CReact \u6CA1\u6709\u5237\u65B0\uFF09\uFF1B\u2462 \u65AD\u8A00\u4E86\u5F00\u5C40\u524D\u5C31\u5B58\u5728\u7684\u9759\u6001\u6587\u672C\u3002\u4FEE\u590D\uFF1A\u7B49\u5F85\u5E76\u65AD\u8A00\u53EA\u6709\u73A9\u8D77\u6765\u4E4B\u540E\u624D\u4F1A\u51FA\u73B0\u7684\u4E1C\u897F\uFF08\u5F00\u59CB\u906E\u7F69\u6D88\u5931\u3001\u6BD4\u5206\u53D8\u5316\u3001\u7ED3\u7B97\u753B\u9762\u51FA\u73B0\uFF09\u3002\u4F8B\u5916\uFF1A\u7ED3\u679C\u753B\u5728 Canvas \u4E0A\u3001\u7ECF Telemetry \u7B49\u9875\u9762\u5916\u72B6\u6001\u89C2\u5BDF\u7684\u6E38\u620F\uFF0C\u663E\u5F0F\u4F20 { allowStaticDom: true }\u3002'
241
+ );
242
+ }
236
243
  evidence.boundedRuns += 1;
237
244
  assertionsAtOutcome = expect.getState().assertionCalls;
238
245
  return steps;
@@ -40,6 +40,7 @@ var import_config = require("vitest/config");
40
40
  // src/react/react-playthrough-reporter.ts
41
41
  var import_node_fs = require("fs");
42
42
  var import_node_path = require("path");
43
+ var import_node_util = require("util");
43
44
 
44
45
  // src/react/react-playthrough.ts
45
46
  var import_react2 = require("@testing-library/react");
@@ -142,6 +143,7 @@ function definePlaythrough(element, run, waiverReason) {
142
143
  const evidence = metadata.evidence;
143
144
  let assertionsAtOutcome;
144
145
  let enteredRecorded = false;
146
+ let domTextAtEntered;
145
147
  const recordInput = () => {
146
148
  evidence.domInputEvents += 1;
147
149
  };
@@ -194,6 +196,7 @@ function definePlaythrough(element, run, waiverReason) {
194
196
  );
195
197
  }
196
198
  enteredRecorded = true;
199
+ domTextAtEntered = document.body.textContent ?? "";
197
200
  evidence.checkpoints.push(kind);
198
201
  return;
199
202
  }
@@ -221,6 +224,11 @@ function definePlaythrough(element, run, waiverReason) {
221
224
  'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
222
225
  );
223
226
  }
227
+ if (steps === 0 && options.allowStaticDom !== true && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
228
+ throw new Error(
229
+ 'stepUntil outcome was already true at step 0 and the DOM has not changed since checkpoint("entered"), so the flow cannot prove that gameplay changed anything. \u610F\u601D\uFF1A\u6E38\u620F\u4E00\u6B65\u90FD\u6CA1\u73A9\uFF0C\u7B49\u5F85\u7684"\u7ED3\u679C"\u5C31\u5DF2\u7ECF\u6210\u7ACB\uFF0C\u9875\u9762\u4E5F\u4E00\u4E2A\u5B57\u6CA1\u53D8\u2014\u2014\u8FD9\u4E2A\u7ED3\u679C\u8BC1\u660E\u4E0D\u4E86\u4EFB\u4F55\u4E8B\u3002\u5E38\u89C1\u539F\u56E0\uFF1A\u2460 \u8F93\u5165\u6CA1\u6709\u63A5\u5230\u6E38\u620F\u4E0A\uFF1B\u2461 \u754C\u9762\u5361\u6B7B\uFF08\u72B6\u6001\u6539\u4E86\u4F46 snapshot \u5F15\u7528\u6CA1\u6362\uFF0CReact \u6CA1\u6709\u5237\u65B0\uFF09\uFF1B\u2462 \u65AD\u8A00\u4E86\u5F00\u5C40\u524D\u5C31\u5B58\u5728\u7684\u9759\u6001\u6587\u672C\u3002\u4FEE\u590D\uFF1A\u7B49\u5F85\u5E76\u65AD\u8A00\u53EA\u6709\u73A9\u8D77\u6765\u4E4B\u540E\u624D\u4F1A\u51FA\u73B0\u7684\u4E1C\u897F\uFF08\u5F00\u59CB\u906E\u7F69\u6D88\u5931\u3001\u6BD4\u5206\u53D8\u5316\u3001\u7ED3\u7B97\u753B\u9762\u51FA\u73B0\uFF09\u3002\u4F8B\u5916\uFF1A\u7ED3\u679C\u753B\u5728 Canvas \u4E0A\u3001\u7ECF Telemetry \u7B49\u9875\u9762\u5916\u72B6\u6001\u89C2\u5BDF\u7684\u6E38\u620F\uFF0C\u663E\u5F0F\u4F20 { allowStaticDom: true }\u3002'
230
+ );
231
+ }
224
232
  evidence.boundedRuns += 1;
225
233
  assertionsAtOutcome = expect.getState().assertionCalls;
226
234
  return steps;
@@ -323,7 +331,18 @@ function toAuditInput(test2) {
323
331
  }
324
332
  function firstLine(value) {
325
333
  if (typeof value !== "string") return void 0;
326
- return value.split("\n").map((line) => line.trim()).find(Boolean);
334
+ return (0, import_node_util.stripVTControlCharacters)(value).split("\n").map((line) => line.trim()).find(Boolean);
335
+ }
336
+ function failureHint(value) {
337
+ const names = [...value.matchAll(/button\s*\n\s*Name "([^"]+)"/g)].map(
338
+ (match) => match[1]
339
+ );
340
+ if (names.length === 0) return void 0;
341
+ return `Available button names: ${names.map((name) => JSON.stringify(name)).join(", ")}`;
342
+ }
343
+ function errorLocation(value) {
344
+ const match = value.match(/(?:^|\n)\s*at\s+(.*?\.(?:test|spec)\.[^\n]+:\d+:\d+)/);
345
+ return match?.[1];
327
346
  }
328
347
  function toModuleResult(module2, projectRoot) {
329
348
  const tests = [...module2.children.allTests()];
@@ -333,13 +352,48 @@ function toModuleResult(module2, projectRoot) {
333
352
  (test2) => (test2.result().errors ?? []).map((error) => firstLine(error.message))
334
353
  )
335
354
  ].filter((message) => Boolean(message));
355
+ const failures = tests.filter((test2) => test2.result().state === "failed").map((test2) => {
356
+ const raw = test2.result().errors?.[0]?.message ?? module2.errors()[0]?.message ?? "Unknown failure";
357
+ return {
358
+ test: test2.fullName,
359
+ cause: firstLine(raw) ?? "Unknown failure",
360
+ location: test2.location ? `${(0, import_node_path.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(raw),
361
+ hint: failureHint(raw)
362
+ };
363
+ });
364
+ if (failures.length === 0 && tests.length === 0 && module2.errors().length > 0) {
365
+ const raw = module2.errors()[0]?.message ?? "Module failed to load";
366
+ failures.push({
367
+ test: "<collection>",
368
+ cause: firstLine(raw) ?? "Module failed to load",
369
+ location: errorLocation(raw),
370
+ hint: failureHint(raw)
371
+ });
372
+ }
336
373
  return {
337
374
  file: (0, import_node_path.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/"),
338
375
  state: module2.state(),
339
376
  errors,
340
- tests: tests.map(toAuditInput)
377
+ tests: tests.map(toAuditInput),
378
+ failures
341
379
  };
342
380
  }
381
+ function formatFailureSummary(modules) {
382
+ const failures = modules.flatMap(
383
+ (module2) => (module2.failures ?? []).map((failure) => ({ ...failure, file: module2.file }))
384
+ );
385
+ if (failures.length === 0) return ["TEST_RESULT: PASS"];
386
+ const lines = [`FAILED_TESTS: ${failures.length}`];
387
+ for (const [index, failure] of failures.entries()) {
388
+ lines.push(`FAILURE_${index + 1}: ${failure.file}`);
389
+ lines.push(`TEST: ${failure.test}`);
390
+ lines.push(`CAUSE: ${failure.cause}`);
391
+ if (failure.location) lines.push(`AT: ${failure.location}`);
392
+ if (failure.hint) lines.push(`HINT: ${failure.hint}`);
393
+ }
394
+ lines.push("TEST_RESULT: FAIL");
395
+ return lines;
396
+ }
343
397
  function assessReactPlaythroughReport(input) {
344
398
  const base = { file: input.expectedFile };
345
399
  if (!input.expectedFileScheduled) {
@@ -388,12 +442,14 @@ function assessReactPlaythroughReport(input) {
388
442
  const cause = productionModule.errors[0] ?? input.unhandledErrors?.[0] ?? audit.issues[0] ?? "\u751F\u4EA7\u53EF\u73A9\u6D41\u7A0B\u6CA1\u6709\u7559\u4E0B\u5B8C\u6574\u8BC1\u636E\u3002";
389
443
  const timedOut = /outcome was not reached within \d+ steps/i.test(cause);
390
444
  const missingStep = /No step callback was provided/i.test(cause);
445
+ const staticOutcome = /already true at step 0 and the DOM has not changed/i.test(cause);
446
+ const staleSnapshot = /snapshot\(\) returned the same reference/i.test(cause);
391
447
  const missingStructuredEvidence = /performInput|checkpoint/.test(cause);
392
448
  return {
393
449
  ...base,
394
450
  status: "FAILED",
395
451
  cause,
396
- next: missingStep ? "\u8BE5\u6D41\u7A0B\u662F\u65F6\u95F4\u6216\u5E27\u9A71\u52A8\u7684\uFF0C\u4F46 stepUntil \u6CA1\u6709\u63A8\u8FDB\u6E38\u620F\u65F6\u95F4\uFF1B\u4E3A\u751F\u4EA7\u6E38\u620F\u6CE8\u5165 devkit \u7684 GameClock\uFF0C\u5E76\u4F20\u5165 step: () => clock.stepFrame()\u3002\u4E0D\u8981\u7528\u771F\u5B9E setTimeout\u3002" : timedOut ? "\u771F\u5B9E\u8F93\u5165\u5DF2\u6267\u884C\uFF0C\u4F46\u73A9\u6CD5\u6CA1\u6709\u5728\u4E0A\u9650\u5185\u4EA7\u751F\u7ED3\u679C\uFF1B\u68C0\u67E5\u751F\u4EA7\u63A7\u5236\u662F\u5426\u6536\u5230\u8F93\u5165\uFF0C\u518D\u67E5\u770B\u8D85\u65F6\u9519\u8BEF\u4E2D\u7684 Last diagnostics \u5224\u65AD\u662F\u6E38\u620F\u5FAA\u73AF\u3001\u89C4\u5219\u72B6\u6001\u8FD8\u662F UI \u540C\u6B65\u672A\u63A8\u8FDB\u3002" : missingStructuredEvidence ? '\u6309\u987A\u5E8F\u8865\u9F50\u81F3\u5C11\u4E24\u6B21\u7B7E\u5230\uFF1AperformInput("entry") \u540E\u8C03\u7528 checkpoint("entered")\uFF1B\u518D\u7528 performInput("primary") \u6267\u884C\u6838\u5FC3\u64CD\u4F5C\uFF0CstepUntil \u7B49\u5F85\u7ED3\u679C\uFF0C\u7528\u56DE\u8C03\u63D0\u4F9B\u7684 expect \u65AD\u8A00\u540E\u8C03\u7528 checkpoint("progress") \u6216 checkpoint("terminal")\u3002' : "\u4ECE\u751F\u4EA7\u5165\u53E3\u6267\u884C\u771F\u5B9E DOM \u8F93\u5165\uFF0C\u7528 stepUntil \u6709\u754C\u63A8\u8FDB\u5230\u73A9\u5BB6\u53EF\u89C1\u7ED3\u679C\u6216\u6E38\u620F\u6743\u5A01\u72B6\u6001\uFF0C\u5E76\u5728\u5176\u540E\u65AD\u8A00\uFF1B\u4E0D\u8981\u76F4\u8FBE\u5185\u90E8\u5173\u5361\u6216\u4FEE\u6539\u73A9\u6CD5\u72B6\u6001\u3002",
452
+ next: staleSnapshot ? "\u6E38\u620F\u539F\u5730\u4FEE\u6539\u72B6\u6001\u540E\u6CA1\u6709\u53D1\u5E03\u65B0\u7684\u5FEB\u7167\u5F15\u7528\uFF0CReact \u56E0 Object.is \u6BD4\u8F83\u76F8\u540C\u800C\u8DF3\u8FC7\u91CD\u6E32\u3002\u5728 controller \u7684 notify \u8DEF\u5F84\u4E0A\u53D1\u5E03\u65B0\u9876\u5C42\u5BF9\u8C61\uFF08cachedSnapshot = { ...state }\uFF09\uFF0C\u4E0D\u8981\u628A\u53EF\u53D8\u7684\u5185\u90E8\u5BF9\u8C61\u76F4\u63A5\u4F5C\u4E3A\u5FEB\u7167\u66B4\u9732\u3002" : staticOutcome ? "\u7ED3\u679C\u5728\u63A8\u8FDB\u524D\u5DF2\u6210\u7ACB\u4E14 DOM \u81EA entered \u4EE5\u6765\u65E0\u53D8\u5316\uFF1A\u8F93\u5165\u53EF\u80FD\u672A\u63A5\u5230\u751F\u4EA7\u63A7\u5236\uFF0CUI \u53EF\u80FD\u51BB\u7ED3\uFF0C\u4E5F\u53EF\u80FD\u65AD\u8A00\u4E86\u6E38\u620F\u5F00\u59CB\u524D\u5C31\u5B58\u5728\u7684\u9759\u6001\u6587\u672C\u3002\u6539\u4E3A\u7B49\u5F85\u5E76\u65AD\u8A00\u53EA\u6709\u73A9\u6CD5\u63A8\u8FDB\u540E\u624D\u51FA\u73B0\u7684\u72B6\u6001\uFF08\u5F00\u59CB\u906E\u7F69\u6D88\u5931\u3001\u6BD4\u5206\u53D8\u5316\u3001\u7ED3\u7B97\u51FA\u73B0\uFF09\uFF1B\u7ECF Canvas/Telemetry \u7B49 DOM \u5916\u6743\u5A01\u72B6\u6001\u89C2\u5BDF\u7684\u6D41\u7A0B\u4F20 { allowStaticDom: true }\u3002" : missingStep ? "\u8BE5\u6D41\u7A0B\u662F\u65F6\u95F4\u6216\u5E27\u9A71\u52A8\u7684\uFF0C\u4F46 stepUntil \u6CA1\u6709\u63A8\u8FDB\u6E38\u620F\u65F6\u95F4\uFF1B\u4E3A\u751F\u4EA7\u6E38\u620F\u6CE8\u5165 devkit \u7684 GameClock\uFF0C\u5E76\u4F20\u5165 step: () => clock.stepFrame()\u3002\u4E0D\u8981\u7528\u771F\u5B9E setTimeout\u3002" : timedOut ? "\u771F\u5B9E\u8F93\u5165\u5DF2\u6267\u884C\uFF0C\u4F46\u73A9\u6CD5\u6CA1\u6709\u5728\u4E0A\u9650\u5185\u4EA7\u751F\u7ED3\u679C\uFF1B\u68C0\u67E5\u751F\u4EA7\u63A7\u5236\u662F\u5426\u6536\u5230\u8F93\u5165\uFF0C\u518D\u67E5\u770B\u8D85\u65F6\u9519\u8BEF\u4E2D\u7684 Last diagnostics \u5224\u65AD\u662F\u6E38\u620F\u5FAA\u73AF\u3001\u89C4\u5219\u72B6\u6001\u8FD8\u662F UI \u540C\u6B65\u672A\u63A8\u8FDB\u3002" : missingStructuredEvidence ? '\u6309\u987A\u5E8F\u8865\u9F50\u81F3\u5C11\u4E24\u6B21\u7B7E\u5230\uFF1AperformInput("entry") \u540E\u8C03\u7528 checkpoint("entered")\uFF1B\u518D\u7528 performInput("primary") \u6267\u884C\u6838\u5FC3\u64CD\u4F5C\uFF0CstepUntil \u7B49\u5F85\u7ED3\u679C\uFF0C\u7528\u56DE\u8C03\u63D0\u4F9B\u7684 expect \u65AD\u8A00\u540E\u8C03\u7528 checkpoint("progress") \u6216 checkpoint("terminal")\u3002' : "\u4ECE\u751F\u4EA7\u5165\u53E3\u6267\u884C\u771F\u5B9E DOM \u8F93\u5165\uFF0C\u7528 stepUntil \u6709\u754C\u63A8\u8FDB\u5230\u73A9\u5BB6\u53EF\u89C1\u7ED3\u679C\u6216\u6E38\u620F\u6743\u5A01\u72B6\u6001\uFF0C\u5E76\u5728\u5176\u540E\u65AD\u8A00\uFF1B\u4E0D\u8981\u76F4\u8FBE\u5185\u90E8\u5173\u5361\u6216\u4FEE\u6539\u73A9\u6CD5\u72B6\u6001\u3002",
397
453
  failsRun: true
398
454
  };
399
455
  }
@@ -458,6 +514,14 @@ var ReactPlaythroughReporter = class {
458
514
  } else {
459
515
  console.log(output);
460
516
  }
517
+ const summary = formatFailureSummary(
518
+ testModules.map((module2) => toModuleResult(module2, this.projectRoot))
519
+ );
520
+ if (report.failsRun && summary[0] === "TEST_RESULT: PASS") {
521
+ summary[0] = "TEST_RESULT: FAIL";
522
+ }
523
+ console.log(`
524
+ ${summary.join("\n")}`);
461
525
  }
462
526
  };
463
527
 
@@ -506,6 +570,7 @@ function defineReactGameVitestConfig(options) {
506
570
  environmentOptions: {
507
571
  jsdom: { url: "http://localhost/", pretendToBeVisual: true }
508
572
  },
573
+ includeTaskLocation: true,
509
574
  setupFiles: [
510
575
  "miaoda-game-devkit/react/vitest-setup",
511
576
  ...options.additionalSetupFiles ?? []
@@ -513,8 +578,8 @@ function defineReactGameVitestConfig(options) {
513
578
  sequence: {
514
579
  setupFiles: "list"
515
580
  },
516
- // minimal 保留业务失败;附加 reporter 负责项目级最低可玩性门禁。
517
- reporters: ["minimal", new ReactPlaythroughReporter(options.projectRoot)],
581
+ // 单一 reporter 输出无 ANSI 的紧凑失败摘要和项目级可玩性门禁。
582
+ reporters: [new ReactPlaythroughReporter(options.projectRoot)],
518
583
  restoreMocks: true,
519
584
  clearMocks: true,
520
585
  testTimeout: options.testTimeout,
@@ -6,6 +6,7 @@ import { defineConfig } from "vitest/config";
6
6
  // src/react/react-playthrough-reporter.ts
7
7
  import { existsSync } from "fs";
8
8
  import { relative, resolve } from "path";
9
+ import { stripVTControlCharacters } from "util";
9
10
 
10
11
  // src/react/react-playthrough.ts
11
12
  import { render } from "@testing-library/react";
@@ -108,6 +109,7 @@ function definePlaythrough(element, run, waiverReason) {
108
109
  const evidence = metadata.evidence;
109
110
  let assertionsAtOutcome;
110
111
  let enteredRecorded = false;
112
+ let domTextAtEntered;
111
113
  const recordInput = () => {
112
114
  evidence.domInputEvents += 1;
113
115
  };
@@ -160,6 +162,7 @@ function definePlaythrough(element, run, waiverReason) {
160
162
  );
161
163
  }
162
164
  enteredRecorded = true;
165
+ domTextAtEntered = document.body.textContent ?? "";
163
166
  evidence.checkpoints.push(kind);
164
167
  return;
165
168
  }
@@ -187,6 +190,11 @@ function definePlaythrough(element, run, waiverReason) {
187
190
  'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
188
191
  );
189
192
  }
193
+ if (steps === 0 && options.allowStaticDom !== true && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
194
+ throw new Error(
195
+ 'stepUntil outcome was already true at step 0 and the DOM has not changed since checkpoint("entered"), so the flow cannot prove that gameplay changed anything. \u610F\u601D\uFF1A\u6E38\u620F\u4E00\u6B65\u90FD\u6CA1\u73A9\uFF0C\u7B49\u5F85\u7684"\u7ED3\u679C"\u5C31\u5DF2\u7ECF\u6210\u7ACB\uFF0C\u9875\u9762\u4E5F\u4E00\u4E2A\u5B57\u6CA1\u53D8\u2014\u2014\u8FD9\u4E2A\u7ED3\u679C\u8BC1\u660E\u4E0D\u4E86\u4EFB\u4F55\u4E8B\u3002\u5E38\u89C1\u539F\u56E0\uFF1A\u2460 \u8F93\u5165\u6CA1\u6709\u63A5\u5230\u6E38\u620F\u4E0A\uFF1B\u2461 \u754C\u9762\u5361\u6B7B\uFF08\u72B6\u6001\u6539\u4E86\u4F46 snapshot \u5F15\u7528\u6CA1\u6362\uFF0CReact \u6CA1\u6709\u5237\u65B0\uFF09\uFF1B\u2462 \u65AD\u8A00\u4E86\u5F00\u5C40\u524D\u5C31\u5B58\u5728\u7684\u9759\u6001\u6587\u672C\u3002\u4FEE\u590D\uFF1A\u7B49\u5F85\u5E76\u65AD\u8A00\u53EA\u6709\u73A9\u8D77\u6765\u4E4B\u540E\u624D\u4F1A\u51FA\u73B0\u7684\u4E1C\u897F\uFF08\u5F00\u59CB\u906E\u7F69\u6D88\u5931\u3001\u6BD4\u5206\u53D8\u5316\u3001\u7ED3\u7B97\u753B\u9762\u51FA\u73B0\uFF09\u3002\u4F8B\u5916\uFF1A\u7ED3\u679C\u753B\u5728 Canvas \u4E0A\u3001\u7ECF Telemetry \u7B49\u9875\u9762\u5916\u72B6\u6001\u89C2\u5BDF\u7684\u6E38\u620F\uFF0C\u663E\u5F0F\u4F20 { allowStaticDom: true }\u3002'
196
+ );
197
+ }
190
198
  evidence.boundedRuns += 1;
191
199
  assertionsAtOutcome = expect.getState().assertionCalls;
192
200
  return steps;
@@ -289,7 +297,18 @@ function toAuditInput(test2) {
289
297
  }
290
298
  function firstLine(value) {
291
299
  if (typeof value !== "string") return void 0;
292
- return value.split("\n").map((line) => line.trim()).find(Boolean);
300
+ return stripVTControlCharacters(value).split("\n").map((line) => line.trim()).find(Boolean);
301
+ }
302
+ function failureHint(value) {
303
+ const names = [...value.matchAll(/button\s*\n\s*Name "([^"]+)"/g)].map(
304
+ (match) => match[1]
305
+ );
306
+ if (names.length === 0) return void 0;
307
+ return `Available button names: ${names.map((name) => JSON.stringify(name)).join(", ")}`;
308
+ }
309
+ function errorLocation(value) {
310
+ const match = value.match(/(?:^|\n)\s*at\s+(.*?\.(?:test|spec)\.[^\n]+:\d+:\d+)/);
311
+ return match?.[1];
293
312
  }
294
313
  function toModuleResult(module, projectRoot) {
295
314
  const tests = [...module.children.allTests()];
@@ -299,13 +318,48 @@ function toModuleResult(module, projectRoot) {
299
318
  (test2) => (test2.result().errors ?? []).map((error) => firstLine(error.message))
300
319
  )
301
320
  ].filter((message) => Boolean(message));
321
+ const failures = tests.filter((test2) => test2.result().state === "failed").map((test2) => {
322
+ const raw = test2.result().errors?.[0]?.message ?? module.errors()[0]?.message ?? "Unknown failure";
323
+ return {
324
+ test: test2.fullName,
325
+ cause: firstLine(raw) ?? "Unknown failure",
326
+ location: test2.location ? `${relative(projectRoot, module.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(raw),
327
+ hint: failureHint(raw)
328
+ };
329
+ });
330
+ if (failures.length === 0 && tests.length === 0 && module.errors().length > 0) {
331
+ const raw = module.errors()[0]?.message ?? "Module failed to load";
332
+ failures.push({
333
+ test: "<collection>",
334
+ cause: firstLine(raw) ?? "Module failed to load",
335
+ location: errorLocation(raw),
336
+ hint: failureHint(raw)
337
+ });
338
+ }
302
339
  return {
303
340
  file: relative(projectRoot, module.moduleId).replaceAll("\\", "/"),
304
341
  state: module.state(),
305
342
  errors,
306
- tests: tests.map(toAuditInput)
343
+ tests: tests.map(toAuditInput),
344
+ failures
307
345
  };
308
346
  }
347
+ function formatFailureSummary(modules) {
348
+ const failures = modules.flatMap(
349
+ (module) => (module.failures ?? []).map((failure) => ({ ...failure, file: module.file }))
350
+ );
351
+ if (failures.length === 0) return ["TEST_RESULT: PASS"];
352
+ const lines = [`FAILED_TESTS: ${failures.length}`];
353
+ for (const [index, failure] of failures.entries()) {
354
+ lines.push(`FAILURE_${index + 1}: ${failure.file}`);
355
+ lines.push(`TEST: ${failure.test}`);
356
+ lines.push(`CAUSE: ${failure.cause}`);
357
+ if (failure.location) lines.push(`AT: ${failure.location}`);
358
+ if (failure.hint) lines.push(`HINT: ${failure.hint}`);
359
+ }
360
+ lines.push("TEST_RESULT: FAIL");
361
+ return lines;
362
+ }
309
363
  function assessReactPlaythroughReport(input) {
310
364
  const base = { file: input.expectedFile };
311
365
  if (!input.expectedFileScheduled) {
@@ -354,12 +408,14 @@ function assessReactPlaythroughReport(input) {
354
408
  const cause = productionModule.errors[0] ?? input.unhandledErrors?.[0] ?? audit.issues[0] ?? "\u751F\u4EA7\u53EF\u73A9\u6D41\u7A0B\u6CA1\u6709\u7559\u4E0B\u5B8C\u6574\u8BC1\u636E\u3002";
355
409
  const timedOut = /outcome was not reached within \d+ steps/i.test(cause);
356
410
  const missingStep = /No step callback was provided/i.test(cause);
411
+ const staticOutcome = /already true at step 0 and the DOM has not changed/i.test(cause);
412
+ const staleSnapshot = /snapshot\(\) returned the same reference/i.test(cause);
357
413
  const missingStructuredEvidence = /performInput|checkpoint/.test(cause);
358
414
  return {
359
415
  ...base,
360
416
  status: "FAILED",
361
417
  cause,
362
- next: missingStep ? "\u8BE5\u6D41\u7A0B\u662F\u65F6\u95F4\u6216\u5E27\u9A71\u52A8\u7684\uFF0C\u4F46 stepUntil \u6CA1\u6709\u63A8\u8FDB\u6E38\u620F\u65F6\u95F4\uFF1B\u4E3A\u751F\u4EA7\u6E38\u620F\u6CE8\u5165 devkit \u7684 GameClock\uFF0C\u5E76\u4F20\u5165 step: () => clock.stepFrame()\u3002\u4E0D\u8981\u7528\u771F\u5B9E setTimeout\u3002" : timedOut ? "\u771F\u5B9E\u8F93\u5165\u5DF2\u6267\u884C\uFF0C\u4F46\u73A9\u6CD5\u6CA1\u6709\u5728\u4E0A\u9650\u5185\u4EA7\u751F\u7ED3\u679C\uFF1B\u68C0\u67E5\u751F\u4EA7\u63A7\u5236\u662F\u5426\u6536\u5230\u8F93\u5165\uFF0C\u518D\u67E5\u770B\u8D85\u65F6\u9519\u8BEF\u4E2D\u7684 Last diagnostics \u5224\u65AD\u662F\u6E38\u620F\u5FAA\u73AF\u3001\u89C4\u5219\u72B6\u6001\u8FD8\u662F UI \u540C\u6B65\u672A\u63A8\u8FDB\u3002" : missingStructuredEvidence ? '\u6309\u987A\u5E8F\u8865\u9F50\u81F3\u5C11\u4E24\u6B21\u7B7E\u5230\uFF1AperformInput("entry") \u540E\u8C03\u7528 checkpoint("entered")\uFF1B\u518D\u7528 performInput("primary") \u6267\u884C\u6838\u5FC3\u64CD\u4F5C\uFF0CstepUntil \u7B49\u5F85\u7ED3\u679C\uFF0C\u7528\u56DE\u8C03\u63D0\u4F9B\u7684 expect \u65AD\u8A00\u540E\u8C03\u7528 checkpoint("progress") \u6216 checkpoint("terminal")\u3002' : "\u4ECE\u751F\u4EA7\u5165\u53E3\u6267\u884C\u771F\u5B9E DOM \u8F93\u5165\uFF0C\u7528 stepUntil \u6709\u754C\u63A8\u8FDB\u5230\u73A9\u5BB6\u53EF\u89C1\u7ED3\u679C\u6216\u6E38\u620F\u6743\u5A01\u72B6\u6001\uFF0C\u5E76\u5728\u5176\u540E\u65AD\u8A00\uFF1B\u4E0D\u8981\u76F4\u8FBE\u5185\u90E8\u5173\u5361\u6216\u4FEE\u6539\u73A9\u6CD5\u72B6\u6001\u3002",
418
+ next: staleSnapshot ? "\u6E38\u620F\u539F\u5730\u4FEE\u6539\u72B6\u6001\u540E\u6CA1\u6709\u53D1\u5E03\u65B0\u7684\u5FEB\u7167\u5F15\u7528\uFF0CReact \u56E0 Object.is \u6BD4\u8F83\u76F8\u540C\u800C\u8DF3\u8FC7\u91CD\u6E32\u3002\u5728 controller \u7684 notify \u8DEF\u5F84\u4E0A\u53D1\u5E03\u65B0\u9876\u5C42\u5BF9\u8C61\uFF08cachedSnapshot = { ...state }\uFF09\uFF0C\u4E0D\u8981\u628A\u53EF\u53D8\u7684\u5185\u90E8\u5BF9\u8C61\u76F4\u63A5\u4F5C\u4E3A\u5FEB\u7167\u66B4\u9732\u3002" : staticOutcome ? "\u7ED3\u679C\u5728\u63A8\u8FDB\u524D\u5DF2\u6210\u7ACB\u4E14 DOM \u81EA entered \u4EE5\u6765\u65E0\u53D8\u5316\uFF1A\u8F93\u5165\u53EF\u80FD\u672A\u63A5\u5230\u751F\u4EA7\u63A7\u5236\uFF0CUI \u53EF\u80FD\u51BB\u7ED3\uFF0C\u4E5F\u53EF\u80FD\u65AD\u8A00\u4E86\u6E38\u620F\u5F00\u59CB\u524D\u5C31\u5B58\u5728\u7684\u9759\u6001\u6587\u672C\u3002\u6539\u4E3A\u7B49\u5F85\u5E76\u65AD\u8A00\u53EA\u6709\u73A9\u6CD5\u63A8\u8FDB\u540E\u624D\u51FA\u73B0\u7684\u72B6\u6001\uFF08\u5F00\u59CB\u906E\u7F69\u6D88\u5931\u3001\u6BD4\u5206\u53D8\u5316\u3001\u7ED3\u7B97\u51FA\u73B0\uFF09\uFF1B\u7ECF Canvas/Telemetry \u7B49 DOM \u5916\u6743\u5A01\u72B6\u6001\u89C2\u5BDF\u7684\u6D41\u7A0B\u4F20 { allowStaticDom: true }\u3002" : missingStep ? "\u8BE5\u6D41\u7A0B\u662F\u65F6\u95F4\u6216\u5E27\u9A71\u52A8\u7684\uFF0C\u4F46 stepUntil \u6CA1\u6709\u63A8\u8FDB\u6E38\u620F\u65F6\u95F4\uFF1B\u4E3A\u751F\u4EA7\u6E38\u620F\u6CE8\u5165 devkit \u7684 GameClock\uFF0C\u5E76\u4F20\u5165 step: () => clock.stepFrame()\u3002\u4E0D\u8981\u7528\u771F\u5B9E setTimeout\u3002" : timedOut ? "\u771F\u5B9E\u8F93\u5165\u5DF2\u6267\u884C\uFF0C\u4F46\u73A9\u6CD5\u6CA1\u6709\u5728\u4E0A\u9650\u5185\u4EA7\u751F\u7ED3\u679C\uFF1B\u68C0\u67E5\u751F\u4EA7\u63A7\u5236\u662F\u5426\u6536\u5230\u8F93\u5165\uFF0C\u518D\u67E5\u770B\u8D85\u65F6\u9519\u8BEF\u4E2D\u7684 Last diagnostics \u5224\u65AD\u662F\u6E38\u620F\u5FAA\u73AF\u3001\u89C4\u5219\u72B6\u6001\u8FD8\u662F UI \u540C\u6B65\u672A\u63A8\u8FDB\u3002" : missingStructuredEvidence ? '\u6309\u987A\u5E8F\u8865\u9F50\u81F3\u5C11\u4E24\u6B21\u7B7E\u5230\uFF1AperformInput("entry") \u540E\u8C03\u7528 checkpoint("entered")\uFF1B\u518D\u7528 performInput("primary") \u6267\u884C\u6838\u5FC3\u64CD\u4F5C\uFF0CstepUntil \u7B49\u5F85\u7ED3\u679C\uFF0C\u7528\u56DE\u8C03\u63D0\u4F9B\u7684 expect \u65AD\u8A00\u540E\u8C03\u7528 checkpoint("progress") \u6216 checkpoint("terminal")\u3002' : "\u4ECE\u751F\u4EA7\u5165\u53E3\u6267\u884C\u771F\u5B9E DOM \u8F93\u5165\uFF0C\u7528 stepUntil \u6709\u754C\u63A8\u8FDB\u5230\u73A9\u5BB6\u53EF\u89C1\u7ED3\u679C\u6216\u6E38\u620F\u6743\u5A01\u72B6\u6001\uFF0C\u5E76\u5728\u5176\u540E\u65AD\u8A00\uFF1B\u4E0D\u8981\u76F4\u8FBE\u5185\u90E8\u5173\u5361\u6216\u4FEE\u6539\u73A9\u6CD5\u72B6\u6001\u3002",
363
419
  failsRun: true
364
420
  };
365
421
  }
@@ -424,6 +480,14 @@ var ReactPlaythroughReporter = class {
424
480
  } else {
425
481
  console.log(output);
426
482
  }
483
+ const summary = formatFailureSummary(
484
+ testModules.map((module) => toModuleResult(module, this.projectRoot))
485
+ );
486
+ if (report.failsRun && summary[0] === "TEST_RESULT: PASS") {
487
+ summary[0] = "TEST_RESULT: FAIL";
488
+ }
489
+ console.log(`
490
+ ${summary.join("\n")}`);
427
491
  }
428
492
  };
429
493
 
@@ -472,6 +536,7 @@ function defineReactGameVitestConfig(options) {
472
536
  environmentOptions: {
473
537
  jsdom: { url: "http://localhost/", pretendToBeVisual: true }
474
538
  },
539
+ includeTaskLocation: true,
475
540
  setupFiles: [
476
541
  "miaoda-game-devkit/react/vitest-setup",
477
542
  ...options.additionalSetupFiles ?? []
@@ -479,8 +544,8 @@ function defineReactGameVitestConfig(options) {
479
544
  sequence: {
480
545
  setupFiles: "list"
481
546
  },
482
- // minimal 保留业务失败;附加 reporter 负责项目级最低可玩性门禁。
483
- reporters: ["minimal", new ReactPlaythroughReporter(options.projectRoot)],
547
+ // 单一 reporter 输出无 ANSI 的紧凑失败摘要和项目级可玩性门禁。
548
+ reporters: [new ReactPlaythroughReporter(options.projectRoot)],
484
549
  restoreMocks: true,
485
550
  clearMocks: true,
486
551
  testTimeout: options.testTimeout,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miaoda-game-devkit",
3
- "version": "0.2.21",
3
+ "version": "0.3.0",
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",