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 +5 -4
- package/dist/react/index.d.mts +19 -1
- package/dist/react/index.d.ts +19 -1
- package/dist/react/index.js +50 -0
- package/dist/react/index.mjs +49 -0
- package/dist/react/testing.d.mts +8 -1
- package/dist/react/testing.d.ts +8 -1
- package/dist/react/testing.js +100 -16
- package/dist/react/testing.mjs +100 -16
- package/dist/react/vitest-config.js +195 -32
- package/dist/react/vitest-config.mjs +195 -32
- package/dist/rules/react-test-boundary-plugin.js +97 -0
- package/oxlint-config.json +4 -2
- package/package.json +1 -1
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
|
-
|
|
158
|
-
|
|
158
|
+
Phaser Vitest 配置使用 `minimal` reporter;React Vitest 配置使用无 ANSI 的精简 reporter。
|
|
159
|
+
两者都保留失败定位、coverage 摘要和最终机器标识,同时避免输出完整 DOM 快照、测试列表和逐文件噪声。
|
|
159
160
|
|
|
160
161
|
## 发布顺序
|
|
161
162
|
|
package/dist/react/index.d.mts
CHANGED
|
@@ -10,4 +10,22 @@ interface DisposableGameController {
|
|
|
10
10
|
*/
|
|
11
11
|
declare function useOwnedGameController<TController extends DisposableGameController>(createController: () => TController): TController;
|
|
12
12
|
|
|
13
|
-
|
|
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 };
|
package/dist/react/index.d.ts
CHANGED
|
@@ -10,4 +10,22 @@ interface DisposableGameController {
|
|
|
10
10
|
*/
|
|
11
11
|
declare function useOwnedGameController<TController extends DisposableGameController>(createController: () => TController): TController;
|
|
12
12
|
|
|
13
|
-
|
|
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 };
|
package/dist/react/index.js
CHANGED
|
@@ -21,6 +21,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
21
21
|
var react_exports = {};
|
|
22
22
|
__export(react_exports, {
|
|
23
23
|
browserGameClock: () => browserGameClock,
|
|
24
|
+
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
|
});
|
package/dist/react/index.mjs
CHANGED
|
@@ -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
|
};
|
package/dist/react/testing.d.mts
CHANGED
|
@@ -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 };
|
package/dist/react/testing.d.ts
CHANGED
|
@@ -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 };
|
package/dist/react/testing.js
CHANGED
|
@@ -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
|
|
157
|
-
if (evidence.primaryInputs === 0) return "primary
|
|
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 "
|
|
160
|
-
if (evidence.assertionsAfterOutcome === 0)
|
|
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
|
|
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 "
|
|
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
|
|
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,
|
|
268
|
-
const
|
|
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,
|
|
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
|
-
'
|
|
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
|
-
|
|
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(
|
|
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(
|
|
431
|
+
issues.push(
|
|
432
|
+
`Playthrough ${JSON.stringify(candidate.name)} is missing ${missing}.`
|
|
433
|
+
);
|
|
350
434
|
}
|
|
351
435
|
}
|
|
352
436
|
}
|
package/dist/react/testing.mjs
CHANGED
|
@@ -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
|
|
119
|
-
if (evidence.primaryInputs === 0) return "primary
|
|
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 "
|
|
122
|
-
if (evidence.assertionsAfterOutcome === 0)
|
|
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
|
|
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 "
|
|
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
|
|
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,
|
|
230
|
-
const
|
|
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,
|
|
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
|
-
'
|
|
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
|
-
|
|
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(
|
|
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(
|
|
393
|
+
issues.push(
|
|
394
|
+
`Playthrough ${JSON.stringify(candidate.name)} is missing ${missing}.`
|
|
395
|
+
);
|
|
312
396
|
}
|
|
313
397
|
}
|
|
314
398
|
}
|