miaoda-game-devkit 0.2.21 → 0.4.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
@@ -18,13 +18,14 @@
18
18
  这两次 checkpoint;checkpoint 只负责签到,不携带或验证状态。大型游戏仍只保留一条最短关键流程;
19
19
  分支、关卡规则和恢复清理由专门测试覆盖。不要再次调用 `userEvent.setup()`。
20
20
  时间、帧或自定义 scheduler 流程必须显式传入确定性 `step`;精确物理时间、暂停恢复
21
- 或调度清理测试可使用 `ManualGameClock`。
21
+ 或调度清理测试可使用 `ManualGameClock`。Canvas/Controller 流程在 `playthroughTest` 上声明一次
22
+ `observe`,返回 JSON 可序列化的权威 Telemetry 状态;框架自动比较流程状态并复用为超时诊断。
22
23
 
23
24
  Vitest 的 fake timers(包括 `vi.advanceTimersToNextFrame()`)适合在 devkit 自身的
24
25
  专门 scheduler 合约中使用,但不在所有 production playthrough 中全局启用;React
25
26
  Testing Library 的 `asyncWrapper` 和 user-event 内部 timer 会因此互相影响。使用自定义
26
27
  scheduler、Worker 或未通过浏览器 timer 接入的引擎时,应传显式 `step` 并通过
27
- `diagnostics` 暴露权威状态。
28
+ `diagnostics` 暴露权威状态;已声明 `observe` 的流程无需重复提供 `diagnostics`。
28
29
 
29
30
  - React Vitest 在项目声明 Phaser 3 时使用其 browser bundle,并提供仅供模块能力探测的
30
31
  最小 Canvas 2D 边界,使 Phaser 3 代码不会阻断普通 JSDOM 测试;Storage 使用 JSDOM
@@ -154,8 +155,8 @@ DOM 到 Phaser 的输入、命中测试和游戏状态变化,不验证 Canvas/
154
155
  给出直接修复步骤;测试文件无法加载或配置要求的测试尚未建立时,不再继续报告 Scene
155
156
  注册等派生问题。先处理第一类错误,再重新运行 `pnpm test`。
156
157
 
157
- 共享 Vitest 配置使用 `minimal` reporter `text-summary` coverage reporter,保留失败
158
- 定位、Scene 覆盖摘要和最终机器标识,同时避免输出完整测试列表和逐文件噪声。
158
+ Phaser Vitest 配置使用 `minimal` reporter;React Vitest 配置使用无 ANSI 的精简 reporter
159
+ 两者都保留失败定位、coverage 摘要和最终机器标识,同时避免输出完整 DOM 快照、测试列表和逐文件噪声。
159
160
 
160
161
  ## 发布顺序
161
162
 
@@ -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. React compares snapshots by reference and skips the render when Object.is(previous, next) is true, so the UI freezes. Publish a new top-level snapshot before each notification, for example cachedSnapshot = { ...state }, and never expose a mutable internal object as the snapshot."
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. React compares snapshots by reference and skips the render when Object.is(previous, next) is true, so the UI freezes. Publish a new top-level snapshot before each notification, for example cachedSnapshot = { ...state }, and never expose a mutable internal object as the snapshot."
68
+ );
69
+ }
70
+ previousJson = nextJson;
71
+ });
72
+ }, [game]);
73
+ }
26
74
  export {
27
75
  browserGameClock,
76
+ useGameController,
28
77
  useOwnedGameController
29
78
  };
@@ -66,6 +66,11 @@ interface ReactPlaythroughMetadata {
66
66
  type ReactPlaythroughInputKind = "entry" | "primary";
67
67
  /** 可审计流程签到;entered 是入口,progress/terminal 是有效结果。 */
68
68
  type ReactPlaythroughCheckpointKind = "entered" | "progress" | "terminal";
69
+ /** Canvas/Controller flows declare one authoritative read; simple DOM flows omit it. */
70
+ interface ReactPlaythroughOptions {
71
+ /** Stable, JSON-serializable production state sampled across the audited flow. */
72
+ observe: () => unknown;
73
+ }
69
74
  /** 主流程回调唯一需要学习的测试工具。 */
70
75
  interface ReactPlaythroughArguments {
71
76
  /** Testing Library 对生产游戏入口的渲染结果。 */
@@ -97,6 +102,8 @@ type ReactPlaythroughRun = (arguments_: ReactPlaythroughArguments) => void | Pro
97
102
  interface ReactPlaythroughTest {
98
103
  /** 渲染生产入口并声明一条最低可玩主流程。 */
99
104
  (element: ReactNode, run: ReactPlaythroughRun): void;
105
+ /** Use one authoritative observer when gameplay results live outside the DOM. */
106
+ (element: ReactNode, options: ReactPlaythroughOptions, run: ReactPlaythroughRun): void;
100
107
  /** 仅允许带充分理由地豁免确实没有玩家输入的项目。 */
101
108
  skip(reason: string, element: ReactNode, run: ReactPlaythroughRun): void;
102
109
  }
@@ -134,4 +141,4 @@ declare module "vitest" {
134
141
  }
135
142
  }
136
143
 
137
- export { ManualGameClock, type ReactPlaythroughArguments, type ReactPlaythroughAuditInput, type ReactPlaythroughAuditResult, type ReactPlaythroughCheckpointKind, type ReactPlaythroughEvidence, type ReactPlaythroughInputKind, type ReactPlaythroughMetadata, type ReactPlaythroughTest, type StepUntilOptions, auditReactPlaythroughRun, playthroughTest };
144
+ export { ManualGameClock, type ReactPlaythroughArguments, type ReactPlaythroughAuditInput, type ReactPlaythroughAuditResult, type ReactPlaythroughCheckpointKind, type ReactPlaythroughEvidence, type ReactPlaythroughInputKind, type ReactPlaythroughMetadata, type ReactPlaythroughOptions, type ReactPlaythroughTest, type StepUntilOptions, auditReactPlaythroughRun, playthroughTest };
@@ -66,6 +66,11 @@ interface ReactPlaythroughMetadata {
66
66
  type ReactPlaythroughInputKind = "entry" | "primary";
67
67
  /** 可审计流程签到;entered 是入口,progress/terminal 是有效结果。 */
68
68
  type ReactPlaythroughCheckpointKind = "entered" | "progress" | "terminal";
69
+ /** Canvas/Controller flows declare one authoritative read; simple DOM flows omit it. */
70
+ interface ReactPlaythroughOptions {
71
+ /** Stable, JSON-serializable production state sampled across the audited flow. */
72
+ observe: () => unknown;
73
+ }
69
74
  /** 主流程回调唯一需要学习的测试工具。 */
70
75
  interface ReactPlaythroughArguments {
71
76
  /** Testing Library 对生产游戏入口的渲染结果。 */
@@ -97,6 +102,8 @@ type ReactPlaythroughRun = (arguments_: ReactPlaythroughArguments) => void | Pro
97
102
  interface ReactPlaythroughTest {
98
103
  /** 渲染生产入口并声明一条最低可玩主流程。 */
99
104
  (element: ReactNode, run: ReactPlaythroughRun): void;
105
+ /** Use one authoritative observer when gameplay results live outside the DOM. */
106
+ (element: ReactNode, options: ReactPlaythroughOptions, run: ReactPlaythroughRun): void;
100
107
  /** 仅允许带充分理由地豁免确实没有玩家输入的项目。 */
101
108
  skip(reason: string, element: ReactNode, run: ReactPlaythroughRun): void;
102
109
  }
@@ -134,4 +141,4 @@ declare module "vitest" {
134
141
  }
135
142
  }
136
143
 
137
- export { ManualGameClock, type ReactPlaythroughArguments, type ReactPlaythroughAuditInput, type ReactPlaythroughAuditResult, type ReactPlaythroughCheckpointKind, type ReactPlaythroughEvidence, type ReactPlaythroughInputKind, type ReactPlaythroughMetadata, type ReactPlaythroughTest, type StepUntilOptions, auditReactPlaythroughRun, playthroughTest };
144
+ export { ManualGameClock, type ReactPlaythroughArguments, type ReactPlaythroughAuditInput, type ReactPlaythroughAuditResult, type ReactPlaythroughCheckpointKind, type ReactPlaythroughEvidence, type ReactPlaythroughInputKind, type ReactPlaythroughMetadata, type ReactPlaythroughOptions, type ReactPlaythroughTest, type StepUntilOptions, auditReactPlaythroughRun, playthroughTest };
@@ -152,20 +152,50 @@ var INPUT_EVENTS = [
152
152
  "touchend"
153
153
  ];
154
154
  var MIN_CHECKPOINTS = 2;
155
+ var MAX_FORMATTED_OBSERVATION_LENGTH = 500;
156
+ function formatObservation(fingerprint) {
157
+ if (fingerprint.length <= MAX_FORMATTED_OBSERVATION_LENGTH) return fingerprint;
158
+ return `${fingerprint.slice(0, MAX_FORMATTED_OBSERVATION_LENGTH)}\u2026 (${fingerprint.length} chars)`;
159
+ }
160
+ function sampleObservation(observe, stage) {
161
+ let value;
162
+ try {
163
+ value = observe();
164
+ } catch (error) {
165
+ throw new Error(`observe() threw at ${stage}: ${String(error)}`);
166
+ }
167
+ try {
168
+ const fingerprint = JSON.stringify(value);
169
+ if (fingerprint === void 0) throw new Error("unsupported value");
170
+ return { fingerprint, formatted: formatObservation(fingerprint) };
171
+ } catch {
172
+ throw new Error(
173
+ `observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
174
+ );
175
+ }
176
+ }
177
+ function formatObservationTimeline(entered, afterPrimary, outcome) {
178
+ return [
179
+ `entered=${entered.formatted}`,
180
+ `after-primary=${afterPrimary?.formatted ?? "<not sampled>"}`,
181
+ `outcome=${outcome.formatted}`
182
+ ].join(", ");
183
+ }
155
184
  function describeMissingEvidence(evidence) {
156
- if (!evidence || evidence.entryInputs === 0) return "entry \u8F93\u5165";
157
- if (evidence.primaryInputs === 0) return "primary \u8F93\u5165";
185
+ if (!evidence || evidence.entryInputs === 0) return "an entry input";
186
+ if (evidence.primaryInputs === 0) return "a primary gameplay input";
158
187
  if (!evidence.checkpoints.includes("entered")) return "entered checkpoint";
159
- if (evidence.boundedRuns === 0) return "\u6709\u754C stepUntil";
160
- if (evidence.assertionsAfterOutcome === 0) return "stepUntil \u540E\u7684\u7ED3\u679C\u65AD\u8A00";
188
+ if (evidence.boundedRuns === 0) return "a bounded stepUntil call";
189
+ if (evidence.assertionsAfterOutcome === 0)
190
+ return "an outcome assertion after stepUntil";
161
191
  if (evidence.checkpoints.length < MIN_CHECKPOINTS)
162
- return `\u81F3\u5C11 ${MIN_CHECKPOINTS} \u4E2A checkpoint`;
192
+ return `at least ${MIN_CHECKPOINTS} checkpoints`;
163
193
  if (!evidence.checkpoints.some(
164
194
  (checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
165
195
  )) {
166
196
  return "progress/terminal checkpoint";
167
197
  }
168
- return "\u5B8C\u6574\u7684 playthrough \u6821\u9A8C\u6807\u8BB0";
198
+ return "a complete playthrough verification marker";
169
199
  }
170
200
  function createMetadata(waiverReason) {
171
201
  return {
@@ -182,7 +212,7 @@ function createMetadata(waiverReason) {
182
212
  }
183
213
  };
184
214
  }
185
- function definePlaythrough(element, run, waiverReason) {
215
+ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
186
216
  const reason = normalizePlaythroughWaiverReason(waiverReason);
187
217
  const metadata = createMetadata(reason);
188
218
  (0, import_vitest.test)("production game completes a bounded playthrough", {
@@ -192,6 +222,9 @@ function definePlaythrough(element, run, waiverReason) {
192
222
  const evidence = metadata.evidence;
193
223
  let assertionsAtOutcome;
194
224
  let enteredRecorded = false;
225
+ let domTextAtEntered;
226
+ let enteredObservation;
227
+ let afterPrimaryObservation;
195
228
  const recordInput = () => {
196
229
  evidence.domInputEvents += 1;
197
230
  };
@@ -229,7 +262,15 @@ function definePlaythrough(element, run, waiverReason) {
229
262
  );
230
263
  }
231
264
  if (kind === "entry") evidence.entryInputs += 1;
232
- else evidence.primaryInputs += 1;
265
+ else {
266
+ evidence.primaryInputs += 1;
267
+ if (playthroughOptions?.observe) {
268
+ afterPrimaryObservation = sampleObservation(
269
+ playthroughOptions.observe,
270
+ "after-primary"
271
+ );
272
+ }
273
+ }
233
274
  },
234
275
  checkpoint(kind) {
235
276
  if (kind === "entered") {
@@ -244,6 +285,14 @@ function definePlaythrough(element, run, waiverReason) {
244
285
  );
245
286
  }
246
287
  enteredRecorded = true;
288
+ if (playthroughOptions?.observe) {
289
+ enteredObservation = sampleObservation(
290
+ playthroughOptions.observe,
291
+ "entered"
292
+ );
293
+ } else {
294
+ domTextAtEntered = document.body.textContent ?? "";
295
+ }
247
296
  evidence.checkpoints.push(kind);
248
297
  return;
249
298
  }
@@ -264,13 +313,37 @@ function definePlaythrough(element, run, waiverReason) {
264
313
  }
265
314
  evidence.checkpoints.push(kind);
266
315
  },
267
- async stepUntil(condition, options = {}) {
268
- const steps = await runBoundedUntil(condition, options);
316
+ async stepUntil(condition, stepOptions = {}) {
317
+ const boundedOptions = stepOptions.diagnostics || !playthroughOptions?.observe ? stepOptions : {
318
+ ...stepOptions,
319
+ diagnostics: playthroughOptions.observe
320
+ };
321
+ const steps = await runBoundedUntil(condition, boundedOptions);
269
322
  if (evidence.primaryInputs === 0) {
270
323
  throw new Error(
271
324
  'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
272
325
  );
273
326
  }
327
+ if (playthroughOptions?.observe) {
328
+ if (!enteredObservation) {
329
+ throw new Error(
330
+ 'observe requires checkpoint("entered") before primary gameplay input.'
331
+ );
332
+ }
333
+ const outcomeObservation = sampleObservation(
334
+ playthroughOptions.observe,
335
+ "outcome"
336
+ );
337
+ if (outcomeObservation.fingerprint === enteredObservation.fingerprint) {
338
+ throw new Error(
339
+ `The authoritative observation did not change from checkpoint("entered") to the outcome. Timeline: ${formatObservationTimeline(enteredObservation, afterPrimaryObservation, outcomeObservation)}`
340
+ );
341
+ }
342
+ } else if (steps === 0 && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
343
+ throw new Error(
344
+ 'stepUntil found the outcome at step 0, and the DOM has not changed since checkpoint("entered"). The flow therefore provides no evidence that the primary gameplay input produced a result. For Canvas or Controller state outside the DOM, declare one playthrough observe callback.'
345
+ );
346
+ }
274
347
  evidence.boundedRuns += 1;
275
348
  assertionsAtOutcome = expect.getState().assertionCalls;
276
349
  return steps;
@@ -312,9 +385,16 @@ function definePlaythrough(element, run, waiverReason) {
312
385
  });
313
386
  }
314
387
  var playthroughTest = Object.assign(
315
- (element, run) => definePlaythrough(element, run),
388
+ (element, optionsOrRun, maybeRun) => {
389
+ if (typeof optionsOrRun === "function") {
390
+ definePlaythrough(element, optionsOrRun);
391
+ return;
392
+ }
393
+ if (!maybeRun) throw new TypeError("playthroughTest requires a run callback.");
394
+ definePlaythrough(element, maybeRun, optionsOrRun);
395
+ },
316
396
  {
317
- skip: (reason, element, run) => definePlaythrough(element, run, reason)
397
+ skip: (reason, element, run) => definePlaythrough(element, run, void 0, reason)
318
398
  }
319
399
  );
320
400
  function auditReactPlaythroughRun(tests) {
@@ -331,7 +411,7 @@ function auditReactPlaythroughRun(tests) {
331
411
  const issues = [];
332
412
  if (declared.length === 0) {
333
413
  issues.push(
334
- '\u7F3A\u5C11\u751F\u4EA7\u6E38\u620F\u53EF\u73A9\u6027\u9A8C\u8BC1\uFF1A\u4F7F\u7528 playthroughTest \u6E32\u67D3 <App />\uFF0C\u4F9D\u6B21\u6267\u884C performInput("entry")\u3001checkpoint("entered")\u3001performInput("primary")\u3001stepUntil\u3001\u7528 playthroughTest \u63D0\u4F9B\u7684 expect \u65AD\u8A00\u6743\u5A01\u7ED3\u679C\uFF0C\u5E76\u8BB0\u5F55 checkpoint("progress") \u6216 checkpoint("terminal")\uFF1B\u81F3\u5C11\u5B8C\u6210\u8FD9\u4E24\u6B21 checkpoint \u7B7E\u5230\u3002'
414
+ 'No production gameplay verification was declared. Use playthroughTest to render <App />, then run performInput("entry"), checkpoint("entered"), performInput("primary"), and a bounded stepUntil. Assert the authoritative outcome with the expect provided by playthroughTest, then record checkpoint("progress") or checkpoint("terminal").'
335
415
  );
336
416
  } else {
337
417
  for (const candidate of declared) {
@@ -340,13 +420,17 @@ function auditReactPlaythroughRun(tests) {
340
420
  if (isValid || isWaived) continue;
341
421
  if (candidate.state === "skipped") {
342
422
  issues.push(
343
- `\u73A9\u6CD5\u9A8C\u8BC1\u201C${candidate.name}\u201D\u88AB\u8DF3\u8FC7\uFF0C\u4F46\u6CA1\u6709\u81F3\u5C11 20 \u4E2A\u5B57\u7B26\u7684\u660E\u786E\u7406\u7531\u3002`
423
+ `Playthrough ${JSON.stringify(candidate.name)} was skipped without an explicit reason of at least 20 characters.`
344
424
  );
345
425
  } else if (candidate.state !== "passed") {
346
- issues.push(`\u73A9\u6CD5\u9A8C\u8BC1\u201C${candidate.name}\u201D\u7684\u72B6\u6001\u4E3A ${candidate.state}\u3002`);
426
+ issues.push(
427
+ `Playthrough ${JSON.stringify(candidate.name)} finished with state ${candidate.state}.`
428
+ );
347
429
  } else {
348
430
  const missing = describeMissingEvidence(candidate.metadata?.evidence);
349
- issues.push(`\u73A9\u6CD5\u9A8C\u8BC1\u201C${candidate.name}\u201D\u7F3A\u5C11${missing}\u3002`);
431
+ issues.push(
432
+ `Playthrough ${JSON.stringify(candidate.name)} is missing ${missing}.`
433
+ );
350
434
  }
351
435
  }
352
436
  }
@@ -114,20 +114,50 @@ var INPUT_EVENTS = [
114
114
  "touchend"
115
115
  ];
116
116
  var MIN_CHECKPOINTS = 2;
117
+ var MAX_FORMATTED_OBSERVATION_LENGTH = 500;
118
+ function formatObservation(fingerprint) {
119
+ if (fingerprint.length <= MAX_FORMATTED_OBSERVATION_LENGTH) return fingerprint;
120
+ return `${fingerprint.slice(0, MAX_FORMATTED_OBSERVATION_LENGTH)}\u2026 (${fingerprint.length} chars)`;
121
+ }
122
+ function sampleObservation(observe, stage) {
123
+ let value;
124
+ try {
125
+ value = observe();
126
+ } catch (error) {
127
+ throw new Error(`observe() threw at ${stage}: ${String(error)}`);
128
+ }
129
+ try {
130
+ const fingerprint = JSON.stringify(value);
131
+ if (fingerprint === void 0) throw new Error("unsupported value");
132
+ return { fingerprint, formatted: formatObservation(fingerprint) };
133
+ } catch {
134
+ throw new Error(
135
+ `observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
136
+ );
137
+ }
138
+ }
139
+ function formatObservationTimeline(entered, afterPrimary, outcome) {
140
+ return [
141
+ `entered=${entered.formatted}`,
142
+ `after-primary=${afterPrimary?.formatted ?? "<not sampled>"}`,
143
+ `outcome=${outcome.formatted}`
144
+ ].join(", ");
145
+ }
117
146
  function describeMissingEvidence(evidence) {
118
- if (!evidence || evidence.entryInputs === 0) return "entry \u8F93\u5165";
119
- if (evidence.primaryInputs === 0) return "primary \u8F93\u5165";
147
+ if (!evidence || evidence.entryInputs === 0) return "an entry input";
148
+ if (evidence.primaryInputs === 0) return "a primary gameplay input";
120
149
  if (!evidence.checkpoints.includes("entered")) return "entered checkpoint";
121
- if (evidence.boundedRuns === 0) return "\u6709\u754C stepUntil";
122
- if (evidence.assertionsAfterOutcome === 0) return "stepUntil \u540E\u7684\u7ED3\u679C\u65AD\u8A00";
150
+ if (evidence.boundedRuns === 0) return "a bounded stepUntil call";
151
+ if (evidence.assertionsAfterOutcome === 0)
152
+ return "an outcome assertion after stepUntil";
123
153
  if (evidence.checkpoints.length < MIN_CHECKPOINTS)
124
- return `\u81F3\u5C11 ${MIN_CHECKPOINTS} \u4E2A checkpoint`;
154
+ return `at least ${MIN_CHECKPOINTS} checkpoints`;
125
155
  if (!evidence.checkpoints.some(
126
156
  (checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
127
157
  )) {
128
158
  return "progress/terminal checkpoint";
129
159
  }
130
- return "\u5B8C\u6574\u7684 playthrough \u6821\u9A8C\u6807\u8BB0";
160
+ return "a complete playthrough verification marker";
131
161
  }
132
162
  function createMetadata(waiverReason) {
133
163
  return {
@@ -144,7 +174,7 @@ function createMetadata(waiverReason) {
144
174
  }
145
175
  };
146
176
  }
147
- function definePlaythrough(element, run, waiverReason) {
177
+ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
148
178
  const reason = normalizePlaythroughWaiverReason(waiverReason);
149
179
  const metadata = createMetadata(reason);
150
180
  test("production game completes a bounded playthrough", {
@@ -154,6 +184,9 @@ function definePlaythrough(element, run, waiverReason) {
154
184
  const evidence = metadata.evidence;
155
185
  let assertionsAtOutcome;
156
186
  let enteredRecorded = false;
187
+ let domTextAtEntered;
188
+ let enteredObservation;
189
+ let afterPrimaryObservation;
157
190
  const recordInput = () => {
158
191
  evidence.domInputEvents += 1;
159
192
  };
@@ -191,7 +224,15 @@ function definePlaythrough(element, run, waiverReason) {
191
224
  );
192
225
  }
193
226
  if (kind === "entry") evidence.entryInputs += 1;
194
- else evidence.primaryInputs += 1;
227
+ else {
228
+ evidence.primaryInputs += 1;
229
+ if (playthroughOptions?.observe) {
230
+ afterPrimaryObservation = sampleObservation(
231
+ playthroughOptions.observe,
232
+ "after-primary"
233
+ );
234
+ }
235
+ }
195
236
  },
196
237
  checkpoint(kind) {
197
238
  if (kind === "entered") {
@@ -206,6 +247,14 @@ function definePlaythrough(element, run, waiverReason) {
206
247
  );
207
248
  }
208
249
  enteredRecorded = true;
250
+ if (playthroughOptions?.observe) {
251
+ enteredObservation = sampleObservation(
252
+ playthroughOptions.observe,
253
+ "entered"
254
+ );
255
+ } else {
256
+ domTextAtEntered = document.body.textContent ?? "";
257
+ }
209
258
  evidence.checkpoints.push(kind);
210
259
  return;
211
260
  }
@@ -226,13 +275,37 @@ function definePlaythrough(element, run, waiverReason) {
226
275
  }
227
276
  evidence.checkpoints.push(kind);
228
277
  },
229
- async stepUntil(condition, options = {}) {
230
- const steps = await runBoundedUntil(condition, options);
278
+ async stepUntil(condition, stepOptions = {}) {
279
+ const boundedOptions = stepOptions.diagnostics || !playthroughOptions?.observe ? stepOptions : {
280
+ ...stepOptions,
281
+ diagnostics: playthroughOptions.observe
282
+ };
283
+ const steps = await runBoundedUntil(condition, boundedOptions);
231
284
  if (evidence.primaryInputs === 0) {
232
285
  throw new Error(
233
286
  'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
234
287
  );
235
288
  }
289
+ if (playthroughOptions?.observe) {
290
+ if (!enteredObservation) {
291
+ throw new Error(
292
+ 'observe requires checkpoint("entered") before primary gameplay input.'
293
+ );
294
+ }
295
+ const outcomeObservation = sampleObservation(
296
+ playthroughOptions.observe,
297
+ "outcome"
298
+ );
299
+ if (outcomeObservation.fingerprint === enteredObservation.fingerprint) {
300
+ throw new Error(
301
+ `The authoritative observation did not change from checkpoint("entered") to the outcome. Timeline: ${formatObservationTimeline(enteredObservation, afterPrimaryObservation, outcomeObservation)}`
302
+ );
303
+ }
304
+ } else if (steps === 0 && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
305
+ throw new Error(
306
+ 'stepUntil found the outcome at step 0, and the DOM has not changed since checkpoint("entered"). The flow therefore provides no evidence that the primary gameplay input produced a result. For Canvas or Controller state outside the DOM, declare one playthrough observe callback.'
307
+ );
308
+ }
236
309
  evidence.boundedRuns += 1;
237
310
  assertionsAtOutcome = expect.getState().assertionCalls;
238
311
  return steps;
@@ -274,9 +347,16 @@ function definePlaythrough(element, run, waiverReason) {
274
347
  });
275
348
  }
276
349
  var playthroughTest = Object.assign(
277
- (element, run) => definePlaythrough(element, run),
350
+ (element, optionsOrRun, maybeRun) => {
351
+ if (typeof optionsOrRun === "function") {
352
+ definePlaythrough(element, optionsOrRun);
353
+ return;
354
+ }
355
+ if (!maybeRun) throw new TypeError("playthroughTest requires a run callback.");
356
+ definePlaythrough(element, maybeRun, optionsOrRun);
357
+ },
278
358
  {
279
- skip: (reason, element, run) => definePlaythrough(element, run, reason)
359
+ skip: (reason, element, run) => definePlaythrough(element, run, void 0, reason)
280
360
  }
281
361
  );
282
362
  function auditReactPlaythroughRun(tests) {
@@ -293,7 +373,7 @@ function auditReactPlaythroughRun(tests) {
293
373
  const issues = [];
294
374
  if (declared.length === 0) {
295
375
  issues.push(
296
- '\u7F3A\u5C11\u751F\u4EA7\u6E38\u620F\u53EF\u73A9\u6027\u9A8C\u8BC1\uFF1A\u4F7F\u7528 playthroughTest \u6E32\u67D3 <App />\uFF0C\u4F9D\u6B21\u6267\u884C performInput("entry")\u3001checkpoint("entered")\u3001performInput("primary")\u3001stepUntil\u3001\u7528 playthroughTest \u63D0\u4F9B\u7684 expect \u65AD\u8A00\u6743\u5A01\u7ED3\u679C\uFF0C\u5E76\u8BB0\u5F55 checkpoint("progress") \u6216 checkpoint("terminal")\uFF1B\u81F3\u5C11\u5B8C\u6210\u8FD9\u4E24\u6B21 checkpoint \u7B7E\u5230\u3002'
376
+ 'No production gameplay verification was declared. Use playthroughTest to render <App />, then run performInput("entry"), checkpoint("entered"), performInput("primary"), and a bounded stepUntil. Assert the authoritative outcome with the expect provided by playthroughTest, then record checkpoint("progress") or checkpoint("terminal").'
297
377
  );
298
378
  } else {
299
379
  for (const candidate of declared) {
@@ -302,13 +382,17 @@ function auditReactPlaythroughRun(tests) {
302
382
  if (isValid || isWaived) continue;
303
383
  if (candidate.state === "skipped") {
304
384
  issues.push(
305
- `\u73A9\u6CD5\u9A8C\u8BC1\u201C${candidate.name}\u201D\u88AB\u8DF3\u8FC7\uFF0C\u4F46\u6CA1\u6709\u81F3\u5C11 20 \u4E2A\u5B57\u7B26\u7684\u660E\u786E\u7406\u7531\u3002`
385
+ `Playthrough ${JSON.stringify(candidate.name)} was skipped without an explicit reason of at least 20 characters.`
306
386
  );
307
387
  } else if (candidate.state !== "passed") {
308
- issues.push(`\u73A9\u6CD5\u9A8C\u8BC1\u201C${candidate.name}\u201D\u7684\u72B6\u6001\u4E3A ${candidate.state}\u3002`);
388
+ issues.push(
389
+ `Playthrough ${JSON.stringify(candidate.name)} finished with state ${candidate.state}.`
390
+ );
309
391
  } else {
310
392
  const missing = describeMissingEvidence(candidate.metadata?.evidence);
311
- issues.push(`\u73A9\u6CD5\u9A8C\u8BC1\u201C${candidate.name}\u201D\u7F3A\u5C11${missing}\u3002`);
393
+ issues.push(
394
+ `Playthrough ${JSON.stringify(candidate.name)} is missing ${missing}.`
395
+ );
312
396
  }
313
397
  }
314
398
  }