@uzuhq/code-sdk 0.7.6 → 0.7.7

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.
Files changed (52) hide show
  1. package/dist/dev-globals.d.ts +12 -27
  2. package/dist/dev-globals.js +0 -15
  3. package/dist/dev-hooks-ClWM8HzI.d.ts +682 -0
  4. package/dist/index.d.ts +152 -66
  5. package/dist/index.js +1837 -416
  6. package/package.json +8 -5
  7. package/dist/action-types.test-d.d.ts +0 -11
  8. package/dist/action-types.test-d.js +0 -101
  9. package/dist/dev-hooks.d.ts +0 -241
  10. package/dist/dev-hooks.js +0 -132
  11. package/dist/dev-hooks.test.d.ts +0 -1
  12. package/dist/dev-hooks.test.js +0 -294
  13. package/dist/dev-prediction-traps.d.ts +0 -32
  14. package/dist/dev-prediction-traps.js +0 -0
  15. package/dist/dev-prediction-traps.test.d.ts +0 -1
  16. package/dist/dev-prediction-traps.test.js +0 -178
  17. package/dist/dev-state-patch.d.ts +0 -81
  18. package/dist/dev-state-patch.js +0 -295
  19. package/dist/dev-state-patch.test.d.ts +0 -1
  20. package/dist/dev-state-patch.test.js +0 -333
  21. package/dist/json-patch.d.ts +0 -7
  22. package/dist/json-patch.js +0 -78
  23. package/dist/random.d.ts +0 -11
  24. package/dist/random.js +0 -34
  25. package/dist/reconnectable-ws.d.ts +0 -60
  26. package/dist/reconnectable-ws.js +0 -229
  27. package/dist/room.d.ts +0 -23
  28. package/dist/room.js +0 -36
  29. package/dist/roster-params.test.d.ts +0 -1
  30. package/dist/roster-params.test.js +0 -86
  31. package/dist/run/local-server-action.d.ts +0 -16
  32. package/dist/run/local-server-action.js +0 -217
  33. package/dist/run/local-server-action.test.d.ts +0 -1
  34. package/dist/run/local-server-action.test.js +0 -242
  35. package/dist/run/optimistic-action-client.d.ts +0 -68
  36. package/dist/run/optimistic-action-client.js +0 -209
  37. package/dist/run/optimistic-action-client.test.d.ts +0 -1
  38. package/dist/run/optimistic-action-client.test.js +0 -430
  39. package/dist/run/server-action.d.ts +0 -16
  40. package/dist/run/server-action.js +0 -181
  41. package/dist/run/server-action.test.d.ts +0 -1
  42. package/dist/run/server-action.test.js +0 -105
  43. package/dist/server-clock.d.ts +0 -29
  44. package/dist/server-clock.js +0 -40
  45. package/dist/server-only.d.ts +0 -33
  46. package/dist/server-only.js +0 -21
  47. package/dist/sync/local.d.ts +0 -8
  48. package/dist/sync/local.js +0 -50
  49. package/dist/sync/online.d.ts +0 -5
  50. package/dist/sync/online.js +0 -165
  51. package/dist/types.d.ts +0 -345
  52. package/dist/types.js +0 -8
@@ -1,294 +0,0 @@
1
- /**
2
- * dev-hooks.ts の unit test。
3
- *
4
- * カバー対象:
5
- * - createDevHooks: read / send / setRawState / mergeRawState / patchRawState /
6
- * waitForSnapshot の API contract
7
- * - attachDevHooks: window.__uzu_dev への代入と上書き
8
- *
9
- * applyJsonMergePatch / applyJsonPatch そのものの単体テストは
10
- * `dev-state-patch.test.ts` に分離。
11
- */
12
- import { afterEach, describe, expect, it, vi } from 'vitest';
13
- import { attachDevHooks, createDevHooks } from './dev-hooks.js';
14
- import { applyJsonMergePatch, applyJsonPatch } from './dev-state-patch.js';
15
- function makeState() {
16
- return {
17
- game: { currentPhaseId: 'phase_a', timerEndsAt: 1000 },
18
- players: {
19
- p1: { ready: false, name: 'Alice' },
20
- p2: { ready: false, name: 'Bob' },
21
- },
22
- scenario: { id: 'scenario_1' },
23
- tags: ['initial'],
24
- };
25
- }
26
- describe('createDevHooks', () => {
27
- function makeCtx(overrides = {}) {
28
- // setRawState で参照ごと差し替えられるよう wrap する。test 側の expect は
29
- // box.current 経由で常に最新参照を読む。
30
- const box = { current: makeState() };
31
- const listeners = new Set();
32
- const sendAction = vi.fn();
33
- const notify = (snap) => {
34
- listeners.forEach((cb) => cb(snap));
35
- };
36
- const ctx = {
37
- getSnapshot: () => box.current,
38
- getRawState: () => box.current,
39
- setRawState: async (next) => {
40
- box.current = next;
41
- },
42
- mergeRawState: async (patch) => {
43
- applyJsonMergePatch(box.current, patch);
44
- },
45
- patchRawState: async (ops) => {
46
- applyJsonPatch(box.current, ops);
47
- },
48
- playerId: () => 'p1',
49
- sendAction,
50
- subscribeSnapshot: (cb) => {
51
- listeners.add(cb);
52
- return () => {
53
- listeners.delete(cb);
54
- };
55
- },
56
- ...overrides,
57
- };
58
- return { ctx, state: box, notify, sendAction };
59
- }
60
- it('Read API: getSnapshot / getRawState / playerId を返す', () => {
61
- const { ctx, state } = makeCtx();
62
- const hooks = createDevHooks(ctx);
63
- expect(hooks.getSnapshot()).toBe(state.current);
64
- expect(hooks.getRawState()).toBe(state.current);
65
- expect(hooks.playerId()).toBe('p1');
66
- });
67
- it('send() は ctx.sendAction に args オブジェクトをそのまま渡す', () => {
68
- const { ctx, sendAction } = makeCtx();
69
- const hooks = createDevHooks(ctx);
70
- expect(hooks.send).toBeDefined();
71
- hooks.send({ as: 'p2', type: 'vote', payload: { target: 'p1' } });
72
- expect(sendAction).toHaveBeenCalledWith({
73
- as: 'p2',
74
- type: 'vote',
75
- payload: { target: 'p1' },
76
- });
77
- });
78
- it('ctx.sendAction 未提供だと hooks.send は undefined', () => {
79
- const { ctx } = makeCtx({ sendAction: undefined });
80
- const hooks = createDevHooks(ctx);
81
- expect(hooks.send).toBeUndefined();
82
- });
83
- it('send() は ctx.sendAction の rejection をそのまま伝搬する', async () => {
84
- const sendActionReject = vi.fn(() => Promise.reject(new Error('Not your turn')));
85
- const { ctx } = makeCtx({ sendAction: sendActionReject });
86
- const hooks = createDevHooks(ctx);
87
- await expect(hooks.send({ as: 'p1', type: 't' })).rejects.toThrow('Not your turn');
88
- });
89
- // ─── setRawState (全置換) ─────────────────────────
90
- it('setRawState() は Promise を返し state を全置換する', async () => {
91
- const { ctx, state } = makeCtx();
92
- const hooks = createDevHooks(ctx);
93
- const next = {
94
- game: { currentPhaseId: 'phase_z', timerEndsAt: 999 },
95
- players: { px: { ready: true, name: 'X' } },
96
- scenario: null,
97
- };
98
- await expect(hooks.setRawState(next)).resolves.toBeUndefined();
99
- expect(state.current).toBe(next);
100
- // 旧 state にあった players.p1 は新 state に含まれないので消える (= 全置換意味)
101
- expect(state.current.players.p1).toBeUndefined();
102
- });
103
- it('ctx.setRawState 未提供だと hooks.setRawState は undefined', () => {
104
- const { ctx } = makeCtx({ setRawState: undefined });
105
- const hooks = createDevHooks(ctx);
106
- expect(hooks.setRawState).toBeUndefined();
107
- });
108
- // ─── mergeRawState (RFC 7396 風 merge patch) ─────
109
- it('mergeRawState() は object 階層を recursive merge する', async () => {
110
- const { ctx, state } = makeCtx();
111
- const hooks = createDevHooks(ctx);
112
- expect(hooks.mergeRawState).toBeDefined();
113
- await hooks.mergeRawState({ players: { p1: { ready: true } } });
114
- expect(state.current.players.p1.ready).toBe(true);
115
- expect(state.current.players.p1.name).toBe('Alice');
116
- expect(state.current.players.p2.ready).toBe(false);
117
- });
118
- it('mergeRawState() で array に object patch を当てると rejection になる', async () => {
119
- const { ctx } = makeCtx();
120
- const hooks = createDevHooks(ctx);
121
- await expect(hooks.mergeRawState({ tags: { 0: 'oops' } })).rejects.toThrow(/refusing to merge a plain object into array field/);
122
- });
123
- it('ctx.mergeRawState 未提供だと hooks.mergeRawState は undefined', () => {
124
- const { ctx } = makeCtx({ mergeRawState: undefined });
125
- const hooks = createDevHooks(ctx);
126
- expect(hooks.mergeRawState).toBeUndefined();
127
- });
128
- // ─── patchRawState (RFC 6902 json patch) ─────────
129
- it('patchRawState() は path-based ops を順に適用する', async () => {
130
- const { ctx, state } = makeCtx();
131
- const hooks = createDevHooks(ctx);
132
- expect(hooks.patchRawState).toBeDefined();
133
- await hooks.patchRawState([
134
- { op: 'replace', path: '/game/currentPhaseId', value: 'phase_b' },
135
- { op: 'replace', path: '/players/p1/ready', value: true },
136
- { op: 'add', path: '/tags/-', value: 'extra' },
137
- ]);
138
- expect(state.current.game.currentPhaseId).toBe('phase_b');
139
- expect(state.current.players.p1.ready).toBe(true);
140
- expect(state.current.tags).toEqual(['initial', 'extra']);
141
- });
142
- it('ctx.patchRawState 未提供だと hooks.patchRawState は undefined', () => {
143
- const { ctx } = makeCtx({ patchRawState: undefined });
144
- const hooks = createDevHooks(ctx);
145
- expect(hooks.patchRawState).toBeUndefined();
146
- });
147
- it('tick 制御: ctx で提供されると hooks に wire される', () => {
148
- const pauseTick = vi.fn();
149
- const resumeTick = vi.fn();
150
- const stepTick = vi.fn();
151
- const { ctx } = makeCtx({
152
- pauseTick,
153
- resumeTick,
154
- stepTick,
155
- getCurrentTick: () => 42,
156
- isTickPaused: () => true,
157
- });
158
- const hooks = createDevHooks(ctx);
159
- hooks.pauseTick();
160
- hooks.resumeTick();
161
- hooks.stepTick(3);
162
- expect(pauseTick).toHaveBeenCalledOnce();
163
- expect(resumeTick).toHaveBeenCalledOnce();
164
- expect(stepTick).toHaveBeenCalledWith(3);
165
- expect(hooks.getCurrentTick()).toBe(42);
166
- expect(hooks.isTickPaused()).toBe(true);
167
- });
168
- it('tick 制御: ctx 未提供だと hooks 側でも undefined のまま', () => {
169
- const { ctx } = makeCtx(); // pauseTick 等を渡さない
170
- const hooks = createDevHooks(ctx);
171
- expect(hooks.pauseTick).toBeUndefined();
172
- expect(hooks.resumeTick).toBeUndefined();
173
- expect(hooks.stepTick).toBeUndefined();
174
- expect(hooks.getCurrentTick).toBeUndefined();
175
- expect(hooks.isTickPaused).toBeUndefined();
176
- });
177
- it('reset: ctx で提供されると hooks に wire され opts がそのまま渡る', () => {
178
- const reset = vi.fn();
179
- const { ctx } = makeCtx({ reset });
180
- const hooks = createDevHooks(ctx);
181
- expect(hooks.reset).toBeDefined();
182
- hooks.reset();
183
- expect(reset).toHaveBeenCalledWith(undefined);
184
- hooks.reset({ seed: 7 });
185
- expect(reset).toHaveBeenLastCalledWith({ seed: 7 });
186
- hooks.reset({ seed: 'random' });
187
- expect(reset).toHaveBeenLastCalledWith({ seed: 'random' });
188
- });
189
- it('reset: ctx 未提供だと hooks.reset は undefined', () => {
190
- const { ctx } = makeCtx();
191
- const hooks = createDevHooks(ctx);
192
- expect(hooks.reset).toBeUndefined();
193
- });
194
- it('subscribeSnapshot: notify を listen し unsubscribe 後は呼ばれない', () => {
195
- const { ctx, state, notify } = makeCtx();
196
- const hooks = createDevHooks(ctx);
197
- const trace = [];
198
- const unsub = hooks.subscribeSnapshot((s) => {
199
- trace.push(s.game.currentPhaseId);
200
- });
201
- state.current.game.currentPhaseId = 'phase_b';
202
- notify(state.current);
203
- state.current.game.currentPhaseId = 'phase_c';
204
- notify(state.current);
205
- expect(trace).toEqual(['phase_b', 'phase_c']);
206
- unsub();
207
- state.current.game.currentPhaseId = 'phase_d';
208
- notify(state.current);
209
- expect(trace).toEqual(['phase_b', 'phase_c']);
210
- });
211
- it('subscribeEvents: ctx で提供されると hooks に wire される', () => {
212
- const subscribeEvents = vi.fn(() => () => { });
213
- const { ctx } = makeCtx({ subscribeEvents });
214
- const hooks = createDevHooks(ctx);
215
- expect(hooks.subscribeEvents).toBeDefined();
216
- const cb = (_evts) => { };
217
- hooks.subscribeEvents(cb);
218
- expect(subscribeEvents).toHaveBeenCalledWith(cb);
219
- });
220
- it('subscribeEvents: ctx 未提供だと hooks.subscribeEvents は undefined', () => {
221
- const { ctx } = makeCtx();
222
- const hooks = createDevHooks(ctx);
223
- expect(hooks.subscribeEvents).toBeUndefined();
224
- });
225
- it('subscribeEvents: unsubscribe 関数がそのまま戻る', () => {
226
- const unsubscribe = vi.fn();
227
- const subscribeEvents = vi.fn(() => unsubscribe);
228
- const { ctx } = makeCtx({ subscribeEvents });
229
- const hooks = createDevHooks(ctx);
230
- const off = hooks.subscribeEvents(() => { });
231
- off();
232
- expect(unsubscribe).toHaveBeenCalledOnce();
233
- });
234
- it('waitForSnapshot: predicate が即時 true なら現在 snapshot で resolve', async () => {
235
- const { ctx } = makeCtx();
236
- const hooks = createDevHooks(ctx);
237
- const result = await hooks.waitForSnapshot((s) => s.game.currentPhaseId === 'phase_a');
238
- expect(result.game.currentPhaseId).toBe('phase_a');
239
- });
240
- it('waitForSnapshot: predicate が false なら subscribe で待ち、notify で resolve', async () => {
241
- const { ctx, state, notify } = makeCtx();
242
- const hooks = createDevHooks(ctx);
243
- const promise = hooks.waitForSnapshot((s) => s.game.currentPhaseId === 'phase_b');
244
- state.current.game.currentPhaseId = 'phase_b';
245
- notify(state.current);
246
- const result = await promise;
247
- expect(result.game.currentPhaseId).toBe('phase_b');
248
- });
249
- it('waitForSnapshot: timeoutMs 経過で reject する', async () => {
250
- const { ctx, state } = makeCtx();
251
- // 初期 snapshot は phase_a なので、phase_b 待ちはタイムアウトする
252
- state.current.game.currentPhaseId = 'phase_a';
253
- const hooks = createDevHooks(ctx);
254
- await expect(hooks.waitForSnapshot((s) => s.game.currentPhaseId === 'phase_b', {
255
- timeoutMs: 50,
256
- })).rejects.toThrow(/timed out/);
257
- });
258
- });
259
- describe('attachDevHooks', () => {
260
- afterEach(() => {
261
- delete window.__uzu_dev;
262
- });
263
- it('window.__uzu_dev に hooks を attach する', () => {
264
- const state = makeState();
265
- attachDevHooks({
266
- getSnapshot: () => state,
267
- playerId: () => 'p1',
268
- sendAction: vi.fn(),
269
- subscribeSnapshot: () => () => { },
270
- });
271
- expect(window.__uzu_dev).toBeDefined();
272
- expect(window.__uzu_dev.playerId()).toBe('p1');
273
- });
274
- it('複数回 attach すると最後のものに置き換わる (silently, no warn)', () => {
275
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
276
- attachDevHooks({
277
- getSnapshot: () => 'first',
278
- playerId: () => 'first_id',
279
- sendAction: vi.fn(),
280
- subscribeSnapshot: () => () => { },
281
- });
282
- attachDevHooks({
283
- getSnapshot: () => 'second',
284
- playerId: () => 'second_id',
285
- sendAction: vi.fn(),
286
- subscribeSnapshot: () => () => { },
287
- });
288
- expect(window.__uzu_dev.playerId()).toBe('second_id');
289
- expect(window.__uzu_dev.getSnapshot()).toBe('second');
290
- // init() -> run() の順で 2 回 attach されるのは正常フローなので warn を出さない契約
291
- expect(warnSpy).not.toHaveBeenCalled();
292
- warnSpy.mockRestore();
293
- });
294
- });
@@ -1,32 +0,0 @@
1
- /**
2
- * @docs
3
- * - ServerAction仕様: docs/docs/uzu_code/connection-method/arch3-authority.md
4
- * - 開発パターン: docs/docs/uzu_code/sdk-guide/patterns.md
5
- *
6
- * 素の action handler のクライアント先読み実行中に、サーバーと結果が一致しない API
7
- * (実時刻 / 乱数) が呼ばれたら警告する。
8
- *
9
- * 先読みは「サーバーと同じコードを同じ入力で走らせれば同じ結果になる」ことが前提で、
10
- * 実時刻や乱数を読むとその前提が崩れる。ズレた state は一瞬表示されたあと ack で
11
- * 上書きされ、画面が飛ぶ。
12
- *
13
- * 静的解析ではなく実行時に差し替えるのは、handler がヘルパー関数を何段挟んでいても
14
- * 捕まえたいから。同じ理由で、演出用途 (描画ループの `Math.sin(Date.now() / 400)` など)
15
- * は先読み経路を通らないので原理的に誤検知しない。
16
- */
17
- export interface PredictionWarning {
18
- /** 呼び出した action 名 */
19
- action: string;
20
- /** 呼ばれた API 名 (`'Date.now()'` など) */
21
- api: string;
22
- }
23
- /**
24
- * 素の action handler の先読み実行を計装して走らせる。
25
- *
26
- * Flutter native ホスト (本番) では計装せず素通しする。判定基準は dev hooks と同じ。
27
- */
28
- export declare const runPredicted: (action: string, run: () => void) => void;
29
- /** 検出済みの警告一覧。`__uzu_dev.getPredictionWarnings()` から E2E で assert する用。 */
30
- export declare const getPredictionWarnings: () => readonly PredictionWarning[];
31
- /** 検出結果をリセットする (テスト用)。 */
32
- export declare const clearPredictionWarnings: () => void;
Binary file
@@ -1 +0,0 @@
1
- export {};
@@ -1,178 +0,0 @@
1
- /**
2
- * dev-prediction-traps.ts の unit test。
3
- *
4
- * 素の action handler をクライアント先読みで実行する間だけグローバルを差し替え、
5
- * サーバーと結果が一致しない API (実時刻 / 乱数) の呼び出しを検出する仕組みを検証する。
6
- *
7
- * カバー対象:
8
- * - 検出対象 API それぞれが記録されること
9
- * - ヘルパー関数を経由した呼び出しも捕まえること (静的解析では追えない経路)
10
- * - 同じ action / API の重複を 1 件に畳むこと
11
- * - 実行後にグローバルが必ず元へ戻ること (例外時も含む)
12
- * - 先読みの外では計装されていないこと
13
- * - 本番 (Flutter native ホスト) では計装しないこと
14
- */
15
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
16
- import { clearPredictionWarnings, getPredictionWarnings, runPredicted, } from './dev-prediction-traps.js';
17
- beforeEach(() => {
18
- clearPredictionWarnings();
19
- // 警告本文は console に出るので、テスト出力を汚さないよう黙らせる。
20
- vi.spyOn(console, 'groupCollapsed').mockImplementation(() => { });
21
- vi.spyOn(console, 'log').mockImplementation(() => { });
22
- vi.spyOn(console, 'groupEnd').mockImplementation(() => { });
23
- });
24
- afterEach(() => {
25
- vi.restoreAllMocks();
26
- delete window.FlutterHost;
27
- });
28
- describe('runPredicted', () => {
29
- /** 検出対象 API を先読み中に呼ぶと、action 名とセットで記録される。 */
30
- describe('非決定的な API の検出', () => {
31
- it('Date.now() を記録する', () => {
32
- runPredicted('gm.timer.set', () => {
33
- Date.now();
34
- });
35
- expect(getPredictionWarnings()).toEqual([{ action: 'gm.timer.set', api: 'Date.now()' }]);
36
- });
37
- it('引数なしの new Date() を記録する', () => {
38
- runPredicted('startPresentation', () => {
39
- new Date();
40
- });
41
- expect(getPredictionWarnings()).toEqual([{ action: 'startPresentation', api: 'new Date()' }]);
42
- });
43
- /**
44
- * 引数付きの new Date() は与えられた値から決まるので、サーバーと結果がズレない。
45
- * 誤検知を避けるため記録しない。
46
- */
47
- it('引数付きの new Date(...) は記録しない', () => {
48
- runPredicted('setDeadline', () => {
49
- new Date(1700000000000);
50
- });
51
- expect(getPredictionWarnings()).toEqual([]);
52
- });
53
- it('Math.random() を記録する', () => {
54
- runPredicted('restart', () => {
55
- Math.random();
56
- });
57
- expect(getPredictionWarnings()).toEqual([{ action: 'restart', api: 'Math.random()' }]);
58
- });
59
- it('performance.now() を記録する', () => {
60
- runPredicted('tickLocal', () => {
61
- performance.now();
62
- });
63
- expect(getPredictionWarnings()).toEqual([{ action: 'tickLocal', api: 'performance.now()' }]);
64
- });
65
- /**
66
- * 静的解析が最も苦手とする経路。handler 本体に API が現れず、ヘルパー関数の中で
67
- * 呼ばれていても捕まえられることを保証する (kikaidochu の setLine が実際にこの形)。
68
- */
69
- it('ヘルパー関数を経由した呼び出しも記録する', () => {
70
- const setDeadlineViaHelper = () => Date.now() + 60000;
71
- runPredicted('setLine', () => {
72
- setDeadlineViaHelper();
73
- });
74
- expect(getPredictionWarnings()).toEqual([{ action: 'setLine', api: 'Date.now()' }]);
75
- });
76
- /** 元の API の戻り値はそのまま通す (計装で挙動を変えない)。 */
77
- it('元の API の戻り値を変えない', () => {
78
- let observed = 0;
79
- runPredicted('noop', () => {
80
- observed = Date.now();
81
- });
82
- expect(observed).toBeGreaterThan(0);
83
- });
84
- });
85
- describe('重複の抑制', () => {
86
- /** 同じ action で同じ API を何度呼んでも記録は 1 件。連打でログが溢れない。 */
87
- it('同一 action / 同一 API は 1 件に畳む', () => {
88
- runPredicted('gm.timer.set', () => {
89
- Date.now();
90
- Date.now();
91
- });
92
- runPredicted('gm.timer.set', () => {
93
- Date.now();
94
- });
95
- expect(getPredictionWarnings()).toEqual([{ action: 'gm.timer.set', api: 'Date.now()' }]);
96
- });
97
- /** action が違えば別件として記録する。どの action を直すべきか分かる必要がある。 */
98
- it('action が違えば別件として記録する', () => {
99
- runPredicted('a', () => {
100
- Date.now();
101
- });
102
- runPredicted('b', () => {
103
- Date.now();
104
- });
105
- expect(getPredictionWarnings()).toEqual([
106
- { action: 'a', api: 'Date.now()' },
107
- { action: 'b', api: 'Date.now()' },
108
- ]);
109
- });
110
- /** 同じ action でも API が違えば別件。両方直す必要があるため。 */
111
- it('API が違えば別件として記録する', () => {
112
- runPredicted('restart', () => {
113
- Date.now();
114
- Math.random();
115
- });
116
- expect(getPredictionWarnings()).toEqual([
117
- { action: 'restart', api: 'Date.now()' },
118
- { action: 'restart', api: 'Math.random()' },
119
- ]);
120
- });
121
- });
122
- describe('グローバルの復元', () => {
123
- /** 先読みの外で呼ばれる API は計装されていない (描画ループが誤検知しない前提)。 */
124
- it('実行後にグローバルが元へ戻る', () => {
125
- const beforeNow = Date.now;
126
- const beforeRandom = Math.random;
127
- const beforeDate = Date;
128
- runPredicted('a', () => {
129
- Date.now();
130
- });
131
- expect(Date.now).toBe(beforeNow);
132
- expect(Math.random).toBe(beforeRandom);
133
- expect(Date).toBe(beforeDate);
134
- });
135
- /**
136
- * handler が throw しても復元する。楽観実行は throw を握りつぶして
137
- * サーバー送信だけ続ける経路があるので、ここで漏れるとグローバルが汚染されたまま残る。
138
- */
139
- it('handler が例外を投げてもグローバルが元へ戻る', () => {
140
- const beforeNow = Date.now;
141
- const beforeDate = Date;
142
- expect(() => {
143
- runPredicted('a', () => {
144
- Date.now();
145
- throw new Error('handler failed');
146
- });
147
- }).toThrow('handler failed');
148
- expect(Date.now).toBe(beforeNow);
149
- expect(Date).toBe(beforeDate);
150
- // 例外で中断しても、そこまでに呼ばれた API は記録されている。
151
- expect(getPredictionWarnings()).toEqual([{ action: 'a', api: 'Date.now()' }]);
152
- });
153
- /** 先読みの外の呼び出しは記録されない。描画コードの Date.now() を拾わない保証。 */
154
- it('先読みの外で呼んだ API は記録しない', () => {
155
- Date.now();
156
- Math.random();
157
- expect(getPredictionWarnings()).toEqual([]);
158
- });
159
- });
160
- describe('本番での無効化', () => {
161
- /**
162
- * Flutter native ホストでは計装しない。判定基準は dev hooks の attach と同じく
163
- * `window.FlutterHost` の有無。
164
- */
165
- it('window.FlutterHost があるときは計装せず素通しする', () => {
166
- window.FlutterHost = { postMessage: () => { } };
167
- const beforeNow = Date.now;
168
- let ran = false;
169
- runPredicted('gm.timer.set', () => {
170
- Date.now();
171
- ran = true;
172
- });
173
- expect(ran).toBe(true);
174
- expect(Date.now).toBe(beforeNow);
175
- expect(getPredictionWarnings()).toEqual([]);
176
- });
177
- });
178
- });
@@ -1,81 +0,0 @@
1
- /**
2
- * @docs
3
- * - 外部 automation API: docs/docs/uzu_code/sdk-guide/dev-hooks.md
4
- *
5
- * `__uzu_dev.mergeRawState` / `__uzu_dev.patchRawState` の中身。
6
- * scenario state を「object 階層の部分更新 (RFC 7396)」と「array 要素単体の書換
7
- * 含む path-based ops (RFC 6902)」で書き換えるための utility。
8
- */
9
- /**
10
- * RFC 7396 風 Merge Patch。
11
- *
12
- * RFC 7396 strict との差分:
13
- * - `null` は target 側の **削除ではなく** null をセットする (`timerEndsAt: null`
14
- * のような nullable field を狙い撃ちするユースケースを優先)
15
- * - 値を削除したいときは {@link JsonPatchOp} の `remove` を使う
16
- *
17
- * Generic は使い手側の type を残すための shape ヒントで、ランタイム上は
18
- * `Record<string, unknown>` と等価。
19
- */
20
- export type JsonMergePatch<S = unknown> = Partial<{
21
- [K in keyof S]: S[K] extends unknown[] ? S[K] : S[K] extends object ? JsonMergePatch<S[K]> : S[K] | null;
22
- }> & Record<string, unknown>;
23
- /**
24
- * RFC 6902 JSON Patch operation。
25
- *
26
- * - `add` / `replace` / `test`: `value` 必須
27
- * - `remove`: `value` / `from` 不要
28
- * - `move` / `copy`: `from` 必須 (JSON Pointer)
29
- * - `path` は JSON Pointer (例: `/board/1/4`、root は空文字)
30
- */
31
- export type JsonPatchOp = {
32
- op: 'add';
33
- path: string;
34
- value: unknown;
35
- } | {
36
- op: 'remove';
37
- path: string;
38
- } | {
39
- op: 'replace';
40
- path: string;
41
- value: unknown;
42
- } | {
43
- op: 'move';
44
- path: string;
45
- from: string;
46
- } | {
47
- op: 'copy';
48
- path: string;
49
- from: string;
50
- } | {
51
- op: 'test';
52
- path: string;
53
- value: unknown;
54
- };
55
- /**
56
- * RFC 7396 (Merge Patch) を target に in-place 適用する。
57
- *
58
- * 規則:
59
- * - `undefined` は no-op (key 自体を patch から落としたのと同じ)
60
- * - `null` は **null をセット** (RFC 7396 strict と異なる、deletion はしない)
61
- * - patch が object かつ target も object → 再帰 merge
62
- * - patch が array → atomic replace (要素単位 merge なし、RFC 7396 spec 通り)
63
- * - patch が primitive → overwrite
64
- * - **target が array で patch が non-array object** → throw (silent な
65
- * `[..., ...]` → `{ "0": ..., "1": ... }` 化を防ぐ)
66
- *
67
- * 型 mismatch (例: object → primitive) は overwrite で許可する。
68
- */
69
- export declare function applyJsonMergePatch(target: Record<string, unknown>, patch: JsonMergePatch): void;
70
- /**
71
- * RFC 6902 (JSON Patch) operations を target に in-place 適用する。
72
- *
73
- * 1 op でも失敗したら **その時点で throw** する (RFC 6902 spec: operations は
74
- * sequentially evaluate、失敗時の rollback は規定なし)。caller が atomic に
75
- * したいなら state を事前に snapshot しておく。
76
- *
77
- * root pointer (`''`) は本実装では `add` / `replace` のみ許可し、
78
- * doc のフィールドを丸ごと差し替える形で動く (= 結局 `setRawState` で十分な
79
- * ケースなので、わざわざ `patchRawState` で使うことはほぼない)。
80
- */
81
- export declare function applyJsonPatch(target: Record<string, unknown>, ops: JsonPatchOp[]): void;