@seriousgaming/sdk 0.0.0-stage → 1.0.0-alpha.2

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 (170) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +1 -0
  3. package/README.md +33 -2
  4. package/lib/build/callbacks.d.ts +998 -0
  5. package/lib/build/callbacks.js +32 -0
  6. package/lib/build/context-merge.d.ts +59 -0
  7. package/lib/build/context-merge.js +118 -0
  8. package/lib/build/document.d.ts +36 -0
  9. package/lib/build/document.js +8 -0
  10. package/lib/build/encoding.d.ts +26 -0
  11. package/lib/build/encoding.js +78 -0
  12. package/lib/build/endpoints.d.ts +421 -0
  13. package/lib/build/endpoints.js +46 -0
  14. package/lib/build/hooks.d.ts +74 -0
  15. package/lib/build/hooks.js +1 -0
  16. package/lib/build/index.d.ts +18 -0
  17. package/lib/build/index.js +16 -0
  18. package/lib/build/llm.d.ts +166 -0
  19. package/lib/build/llm.js +17 -0
  20. package/lib/build/resource-schema.d.ts +1048 -0
  21. package/lib/build/resource-schema.js +7 -0
  22. package/lib/build/scheduling.d.ts +112 -0
  23. package/lib/build/scheduling.js +217 -0
  24. package/lib/build/seed.d.ts +51 -0
  25. package/lib/build/seed.js +89 -0
  26. package/lib/build/signatures.d.ts +372 -0
  27. package/lib/build/signatures.js +1 -0
  28. package/lib/build/speech.d.ts +102 -0
  29. package/lib/build/speech.js +11 -0
  30. package/lib/build/tick-validation.d.ts +23 -0
  31. package/lib/build/tick-validation.js +96 -0
  32. package/lib/build/timer.d.ts +74 -0
  33. package/lib/build/timer.js +1 -0
  34. package/lib/build/validators.d.ts +116 -0
  35. package/lib/build/validators.js +1 -0
  36. package/lib/commands.d.ts +357 -0
  37. package/lib/commands.js +14 -0
  38. package/lib/e2e.d.ts +238 -0
  39. package/lib/e2e.js +248 -0
  40. package/lib/projection.d.ts +266 -0
  41. package/lib/projection.js +23 -0
  42. package/lib/testing/index.d.ts +13 -0
  43. package/lib/testing/index.js +13 -0
  44. package/lib/testing/logic-args.d.ts +177 -0
  45. package/lib/testing/logic-args.js +229 -0
  46. package/lib/testing/stub-services.d.ts +41 -0
  47. package/lib/testing/stub-services.js +176 -0
  48. package/lib/ui/CrossTeamDataContext.d.ts +36 -0
  49. package/lib/ui/CrossTeamDataContext.js +32 -0
  50. package/lib/ui/PresentationContext.d.ts +72 -0
  51. package/lib/ui/PresentationContext.js +13 -0
  52. package/lib/ui/PresentationWrapper.d.ts +12 -0
  53. package/lib/ui/PresentationWrapper.js +78 -0
  54. package/lib/ui/SessionUserInterfaceCallbacksContext.d.ts +3 -0
  55. package/lib/ui/SessionUserInterfaceCallbacksContext.js +7 -0
  56. package/lib/ui/SessionUserInterfaceDataContext.d.ts +103 -0
  57. package/lib/ui/SessionUserInterfaceDataContext.js +17 -0
  58. package/lib/ui/SessionUserInterfaceHookFailuresContext.d.ts +44 -0
  59. package/lib/ui/SessionUserInterfaceHookFailuresContext.js +15 -0
  60. package/lib/ui/SessionUserInterfaceHostOverlaysContext.d.ts +23 -0
  61. package/lib/ui/SessionUserInterfaceHostOverlaysContext.js +23 -0
  62. package/lib/ui/SessionUserInterfaceLocaleContext.d.ts +11 -0
  63. package/lib/ui/SessionUserInterfaceLocaleContext.js +13 -0
  64. package/lib/ui/SessionUserInterfaceMetaContext.d.ts +39 -0
  65. package/lib/ui/SessionUserInterfaceMetaContext.js +13 -0
  66. package/lib/ui/SessionUserInterfaceParticipantsContext.d.ts +12 -0
  67. package/lib/ui/SessionUserInterfaceParticipantsContext.js +16 -0
  68. package/lib/ui/SessionUserInterfaceTeamConfigurationContext.d.ts +53 -0
  69. package/lib/ui/SessionUserInterfaceTeamConfigurationContext.js +23 -0
  70. package/lib/ui/SessionUserInterfaceTeamLinksContext.d.ts +56 -0
  71. package/lib/ui/SessionUserInterfaceTeamLinksContext.js +12 -0
  72. package/lib/ui/SessionUserInterfaceTeamsContext.d.ts +13 -0
  73. package/lib/ui/SessionUserInterfaceTeamsContext.js +13 -0
  74. package/lib/ui/SessionUserInterfaceThemeContext.d.ts +9 -0
  75. package/lib/ui/SessionUserInterfaceThemeContext.js +7 -0
  76. package/lib/ui/SessionUserInterfaceThemePreferenceContext.d.ts +3 -0
  77. package/lib/ui/SessionUserInterfaceThemePreferenceContext.js +7 -0
  78. package/lib/ui/SessionUserInterfaceWalkthroughLinkContext.d.ts +42 -0
  79. package/lib/ui/SessionUserInterfaceWalkthroughLinkContext.js +26 -0
  80. package/lib/ui/SessionUserInterfaceWrapper.d.ts +8 -0
  81. package/lib/ui/SessionUserInterfaceWrapper.js +321 -0
  82. package/lib/ui/SettingsEditorCallbacksContext.d.ts +3 -0
  83. package/lib/ui/SettingsEditorCallbacksContext.js +7 -0
  84. package/lib/ui/SettingsEditorContext.d.ts +33 -0
  85. package/lib/ui/SettingsEditorContext.js +16 -0
  86. package/lib/ui/SettingsEditorLocaleContext.d.ts +13 -0
  87. package/lib/ui/SettingsEditorLocaleContext.js +15 -0
  88. package/lib/ui/SettingsEditorThemeContext.d.ts +9 -0
  89. package/lib/ui/SettingsEditorThemeContext.js +7 -0
  90. package/lib/ui/SettingsEditorThemePreferenceContext.d.ts +3 -0
  91. package/lib/ui/SettingsEditorThemePreferenceContext.js +7 -0
  92. package/lib/ui/SettingsEditorWrapper.d.ts +13 -0
  93. package/lib/ui/SettingsEditorWrapper.js +74 -0
  94. package/lib/ui/UserInterfaceCallbacksContext.d.ts +3 -0
  95. package/lib/ui/UserInterfaceCallbacksContext.js +7 -0
  96. package/lib/ui/UserInterfaceDataContext.d.ts +78 -0
  97. package/lib/ui/UserInterfaceDataContext.js +11 -0
  98. package/lib/ui/UserInterfaceLocaleContext.d.ts +13 -0
  99. package/lib/ui/UserInterfaceLocaleContext.js +15 -0
  100. package/lib/ui/UserInterfaceOwnTeamContext.d.ts +25 -0
  101. package/lib/ui/UserInterfaceOwnTeamContext.js +26 -0
  102. package/lib/ui/UserInterfaceOwnTeamJoinLinkContext.d.ts +52 -0
  103. package/lib/ui/UserInterfaceOwnTeamJoinLinkContext.js +13 -0
  104. package/lib/ui/UserInterfaceParticipantContext.d.ts +16 -0
  105. package/lib/ui/UserInterfaceParticipantContext.js +24 -0
  106. package/lib/ui/UserInterfaceParticipantsContext.d.ts +12 -0
  107. package/lib/ui/UserInterfaceParticipantsContext.js +13 -0
  108. package/lib/ui/UserInterfaceRunKindContext.d.ts +31 -0
  109. package/lib/ui/UserInterfaceRunKindContext.js +31 -0
  110. package/lib/ui/UserInterfaceTeamsContext.d.ts +12 -0
  111. package/lib/ui/UserInterfaceTeamsContext.js +13 -0
  112. package/lib/ui/UserInterfaceThemeContext.d.ts +9 -0
  113. package/lib/ui/UserInterfaceThemeContext.js +7 -0
  114. package/lib/ui/UserInterfaceThemePreferenceContext.d.ts +3 -0
  115. package/lib/ui/UserInterfaceThemePreferenceContext.js +7 -0
  116. package/lib/ui/UserInterfaceTimerContext.d.ts +10 -0
  117. package/lib/ui/UserInterfaceTimerContext.js +12 -0
  118. package/lib/ui/UserInterfaceValidationContext.d.ts +25 -0
  119. package/lib/ui/UserInterfaceValidationContext.js +14 -0
  120. package/lib/ui/UserInterfaceViewerRoleContext.d.ts +14 -0
  121. package/lib/ui/UserInterfaceViewerRoleContext.js +17 -0
  122. package/lib/ui/UserInterfaceWrapper.d.ts +8 -0
  123. package/lib/ui/UserInterfaceWrapper.js +219 -0
  124. package/lib/ui/expectedParentOrigin.d.ts +10 -0
  125. package/lib/ui/expectedParentOrigin.js +32 -0
  126. package/lib/ui/index.d.ts +50 -0
  127. package/lib/ui/index.js +52 -0
  128. package/lib/ui/internal/SurfaceGate.d.ts +19 -0
  129. package/lib/ui/internal/SurfaceGate.js +17 -0
  130. package/lib/ui/internal/SurfaceScope.d.ts +17 -0
  131. package/lib/ui/internal/SurfaceScope.js +16 -0
  132. package/lib/ui/internal/createContexts.d.ts +50 -0
  133. package/lib/ui/internal/createContexts.js +32 -0
  134. package/lib/ui/internal/document-attributes.d.ts +9 -0
  135. package/lib/ui/internal/document-attributes.js +87 -0
  136. package/lib/ui/internal/surface-timer.d.ts +3 -0
  137. package/lib/ui/internal/surface-timer.js +11 -0
  138. package/lib/ui/internal/useLatestRef.d.ts +7 -0
  139. package/lib/ui/internal/useLatestRef.js +11 -0
  140. package/lib/ui/internal/useOptimisticMerge.d.ts +19 -0
  141. package/lib/ui/internal/useOptimisticMerge.js +28 -0
  142. package/lib/ui/internal/useSurfaceControls.d.ts +52 -0
  143. package/lib/ui/internal/useSurfaceControls.js +19 -0
  144. package/lib/ui/internal/useTypedPastState.d.ts +17 -0
  145. package/lib/ui/internal/useTypedPastState.js +17 -0
  146. package/lib/ui/mutation-commit.d.ts +32 -0
  147. package/lib/ui/mutation-commit.js +1 -0
  148. package/lib/ui/mutation-outcome.d.ts +66 -0
  149. package/lib/ui/mutation-outcome.js +49 -0
  150. package/lib/ui/request-id.d.ts +18 -0
  151. package/lib/ui/request-id.js +44 -0
  152. package/lib/ui/testing.d.ts +365 -0
  153. package/lib/ui/testing.js +572 -0
  154. package/lib/ui/tick-snapshot.d.ts +28 -0
  155. package/lib/ui/tick-snapshot.js +34 -0
  156. package/lib/ui/ui-protocol.d.ts +799 -0
  157. package/lib/ui/ui-protocol.js +50 -0
  158. package/lib/ui/useAsset.d.ts +80 -0
  159. package/lib/ui/useAsset.js +139 -0
  160. package/lib/ui/useEndpointCallbacks.d.ts +31 -0
  161. package/lib/ui/useEndpointCallbacks.js +68 -0
  162. package/lib/ui/useHostChannel.d.ts +90 -0
  163. package/lib/ui/useHostChannel.js +338 -0
  164. package/lib/ui/useMutationReporter.d.ts +100 -0
  165. package/lib/ui/useMutationReporter.js +97 -0
  166. package/lib/ui/usePastState.d.ts +39 -0
  167. package/lib/ui/usePastState.js +63 -0
  168. package/lib/ui/useViewerStorage.d.ts +54 -0
  169. package/lib/ui/useViewerStorage.js +141 -0
  170. package/package.json +89 -3
@@ -0,0 +1,572 @@
1
+ import { render } from '@testing-library/react';
2
+ import { createElement } from 'react';
3
+ import { applyContextUpdate } from '../build/context-merge.js';
4
+ import { DEFAULT_SURFACE_TIMER } from './internal/surface-timer.js';
5
+ import { SessionUserInterfaceCallbacksContext } from './SessionUserInterfaceCallbacksContext.js';
6
+ import { SessionUserInterfaceDataContext } from './SessionUserInterfaceDataContext.js';
7
+ import { SessionUserInterfaceHookFailuresContext } from './SessionUserInterfaceHookFailuresContext.js';
8
+ import { SessionUserInterfaceHostOverlaysContext } from './SessionUserInterfaceHostOverlaysContext.js';
9
+ import { SessionUserInterfaceLocaleContext } from './SessionUserInterfaceLocaleContext.js';
10
+ import { SessionUserInterfaceMetaContext } from './SessionUserInterfaceMetaContext.js';
11
+ import { SessionUserInterfaceParticipantsContext } from './SessionUserInterfaceParticipantsContext.js';
12
+ import { SessionUserInterfaceTeamConfigurationContext } from './SessionUserInterfaceTeamConfigurationContext.js';
13
+ import { SessionUserInterfaceTeamLinksContext } from './SessionUserInterfaceTeamLinksContext.js';
14
+ import { SessionUserInterfaceTeamsContext } from './SessionUserInterfaceTeamsContext.js';
15
+ import { SessionUserInterfaceThemeContext } from './SessionUserInterfaceThemeContext.js';
16
+ import { SessionUserInterfaceThemePreferenceContext } from './SessionUserInterfaceThemePreferenceContext.js';
17
+ import { SessionUserInterfaceWalkthroughLinkContext } from './SessionUserInterfaceWalkthroughLinkContext.js';
18
+ import { SettingsEditorCallbacksContext } from './SettingsEditorCallbacksContext.js';
19
+ import { SettingsEditorContext } from './SettingsEditorContext.js';
20
+ import { SettingsEditorLocaleContext } from './SettingsEditorLocaleContext.js';
21
+ import { SettingsEditorThemeContext } from './SettingsEditorThemeContext.js';
22
+ import { SettingsEditorThemePreferenceContext } from './SettingsEditorThemePreferenceContext.js';
23
+ import { AssetContext } from './useAsset.js';
24
+ import { PastStateContext } from './usePastState.js';
25
+ import { UserInterfaceCallbacksContext } from './UserInterfaceCallbacksContext.js';
26
+ import { UserInterfaceDataContext } from './UserInterfaceDataContext.js';
27
+ import { UserInterfaceLocaleContext } from './UserInterfaceLocaleContext.js';
28
+ import { UserInterfaceOwnTeamContext } from './UserInterfaceOwnTeamContext.js';
29
+ import { UserInterfaceOwnTeamJoinLinkContext, } from './UserInterfaceOwnTeamJoinLinkContext.js';
30
+ import { UserInterfaceParticipantContext } from './UserInterfaceParticipantContext.js';
31
+ import { UserInterfaceParticipantsContext } from './UserInterfaceParticipantsContext.js';
32
+ import { UserInterfaceRunKindContext } from './UserInterfaceRunKindContext.js';
33
+ import { UserInterfaceTeamsContext } from './UserInterfaceTeamsContext.js';
34
+ import { UserInterfaceThemeContext } from './UserInterfaceThemeContext.js';
35
+ import { UserInterfaceThemePreferenceContext } from './UserInterfaceThemePreferenceContext.js';
36
+ import { UserInterfaceTimerContext } from './UserInterfaceTimerContext.js';
37
+ import { UserInterfaceValidationContext } from './UserInterfaceValidationContext.js';
38
+ import { UserInterfaceViewerRoleContext } from './UserInterfaceViewerRoleContext.js';
39
+ import { ViewerStorageContext } from './useViewerStorage.js';
40
+ // Each `contrastText` is the black or white a host would pick (§12.3): the one
41
+ // of the two contrasting more with its colour, at 4.6:1 or better throughout.
42
+ const DEFAULT_LIGHT_COLORS = {
43
+ primary: '#1976d2',
44
+ secondary: '#9c27b0',
45
+ success: '#4caf50',
46
+ error: '#f44336',
47
+ warning: '#ff9800',
48
+ info: '#2196f3',
49
+ contrastText: {
50
+ primary: '#ffffff',
51
+ secondary: '#ffffff',
52
+ success: '#000000',
53
+ error: '#000000',
54
+ warning: '#000000',
55
+ info: '#000000',
56
+ },
57
+ };
58
+ const DEFAULT_DARK_COLORS = {
59
+ primary: '#90caf9',
60
+ secondary: '#ce93d8',
61
+ success: '#66bb6a',
62
+ error: '#f44336',
63
+ warning: '#ffa726',
64
+ info: '#29b6f6',
65
+ contrastText: {
66
+ primary: '#000000',
67
+ secondary: '#000000',
68
+ success: '#000000',
69
+ error: '#000000',
70
+ warning: '#000000',
71
+ info: '#000000',
72
+ },
73
+ };
74
+ /** What `useViewerStorage` namespaces by in a render with no host. */
75
+ const DEFAULT_SESSION_KEY = 'test-session-key';
76
+ /**
77
+ * Creates a valid UserInterfaceTheme for use in tests.
78
+ *
79
+ * @example
80
+ * // Basic usage — light mode with default MUI palette
81
+ * <UserInterfaceThemeContext.Provider value={createMockTheme()}>
82
+ *
83
+ * @example
84
+ * // Override mode or colours
85
+ * <UserInterfaceThemeContext.Provider value={createMockTheme({ mode: 'dark' })}>
86
+ */
87
+ export function createMockTheme(overrides) {
88
+ return {
89
+ mode: 'light',
90
+ light: DEFAULT_LIGHT_COLORS,
91
+ dark: DEFAULT_DARK_COLORS,
92
+ ...overrides,
93
+ };
94
+ }
95
+ /**
96
+ * Creates a valid SettingsEditorTheme for use in tests.
97
+ *
98
+ * @example
99
+ * <SettingsEditorThemeContext.Provider value={createMockSettingsEditorTheme()}>
100
+ */
101
+ export function createMockSettingsEditorTheme(overrides) {
102
+ return {
103
+ mode: 'light',
104
+ light: DEFAULT_LIGHT_COLORS,
105
+ dark: DEFAULT_DARK_COLORS,
106
+ ...overrides,
107
+ };
108
+ }
109
+ /**
110
+ * Creates a valid SessionUserInterfaceTheme for use in tests.
111
+ */
112
+ export function createMockSessionTheme(overrides) {
113
+ return {
114
+ mode: 'light',
115
+ light: DEFAULT_LIGHT_COLORS,
116
+ dark: DEFAULT_DARK_COLORS,
117
+ ...overrides,
118
+ };
119
+ }
120
+ // ---------------------------------------------------------------------------
121
+ // Stub factory — creates a no-op function (or async no-op) suitable for use
122
+ // as a default callback in test helpers. Consumers can override individual
123
+ // callbacks via the options object.
124
+ // ---------------------------------------------------------------------------
125
+ const noop = () => { };
126
+ const asyncNoop = async () => { };
127
+ // Default mock for `getPastState`, which answers with what the query reaches
128
+ // rather than with a nullable value: an empty result is 'nothing reviewable',
129
+ // so a surface under test renders its empty state rather than having to guard
130
+ // a null. Tests that exercise review screens override it via `callbacks`.
131
+ const asyncEmpty = async () => [];
132
+ const asyncEmptyReference = async () => '';
133
+ const asyncEmptyAsset = async () => ({
134
+ data: new Uint8Array(),
135
+ mimeType: 'application/octet-stream',
136
+ name: '',
137
+ });
138
+ // ---------------------------------------------------------------------------
139
+ // Default values reused across render helpers
140
+ // ---------------------------------------------------------------------------
141
+ const DEFAULT_LOCALE = { locale: 'en' };
142
+ // Sentinel prefixes for test helpers. The wrappers themselves no longer carry
143
+ // a default — production hosts must declare prefixes via setAssetUrls. Tests
144
+ // only need *some* prefix so that `getAssetUrl` returns a deterministic value;
145
+ // override via the `blobPrefix` / `scenarioAssetPrefix` / `sharedAssetPrefix`
146
+ // options.
147
+ const DEFAULT_BLOB_URL_PREFIX = 'test:blob:';
148
+ const DEFAULT_ASSET_URL_PREFIX = 'test:asset:';
149
+ const DEFAULT_SHARED_ASSET_URL_PREFIX = 'test:shared:';
150
+ // A live, unfrozen, PIN-less session with the waiting room closed: the state a
151
+ // facilitator surface spends nearly all its time in, so tests opt in to the
152
+ // awkward cases rather than out of them.
153
+ const DEFAULT_SESSION_META = {
154
+ sessionId: 'session-1',
155
+ name: 'Test session',
156
+ readOnly: false,
157
+ waitingRoomOpen: false,
158
+ joinPin: null,
159
+ };
160
+ /**
161
+ * Bounds a rendered surface sees when a test does not supply its own.
162
+ *
163
+ * Unlike the wrapper's pre-handshake default, this stands in for bounds the
164
+ * host has already sent, so it is a plausible session rather than the
165
+ * unreachable `max: 0` (#365). A test asserting on behaviour at the maximum
166
+ * must pass its own `teamConfiguration`: the helper's number is a convenience,
167
+ * never the thing under test.
168
+ */
169
+ const DEFAULT_TEAM_CONFIGURATION = {
170
+ mode: 'independent',
171
+ max: 8,
172
+ };
173
+ const BLOB_PREFIX = 'blob:';
174
+ const ASSET_PREFIX = 'asset:';
175
+ const SHARED_PREFIX = 'shared:';
176
+ /**
177
+ * Construct a `getAssetUrl` stub that mirrors the SDK wrappers' production
178
+ * behaviour: dispatch on the reference prefix and concatenate with the
179
+ * matching URL prefix. Used by every render helper below.
180
+ */
181
+ function makeGetAssetUrl(blobPrefix, scenarioAssetPrefix, sharedAssetPrefix) {
182
+ return (reference) => {
183
+ if (reference.startsWith(BLOB_PREFIX)) {
184
+ return blobPrefix + reference.slice(BLOB_PREFIX.length);
185
+ }
186
+ if (reference.startsWith(SHARED_PREFIX)) {
187
+ return sharedAssetPrefix + reference.slice(SHARED_PREFIX.length);
188
+ }
189
+ if (reference.startsWith(ASSET_PREFIX)) {
190
+ return scenarioAssetPrefix + reference.slice(ASSET_PREFIX.length);
191
+ }
192
+ throw new Error(`Invalid asset reference "${reference}": expected an "asset:", "shared:", or "blob:" prefix`);
193
+ };
194
+ }
195
+ /**
196
+ * Creates a valid Participant for use in tests — the caller's own
197
+ * identity, as returned by `useUserInterfaceParticipant()`.
198
+ *
199
+ * @example
200
+ * // The caller, with a custom name
201
+ * renderUserInterface(<App />, { participant: createMockParticipant({ name: 'Alice' }) });
202
+ */
203
+ export function createMockParticipant(overrides) {
204
+ return {
205
+ id: '1',
206
+ name: 'Participant 1',
207
+ index: 0,
208
+ connected: true,
209
+ teamId: 'team-1',
210
+ ...overrides,
211
+ };
212
+ }
213
+ /**
214
+ * Creates a roster of participants for use in tests — the list returned by
215
+ * `useUserInterfaceParticipants()` (and the session equivalent, which
216
+ * shares the same shape). Ids, names, and indices follow the runtime's
217
+ * conventions (`id` is `String(index + 1)`, `name` is `Participant N`), and
218
+ * every entry is `connected` by default.
219
+ *
220
+ * @example
221
+ * // A team of three, all online
222
+ * renderUserInterface(<App />, { participants: createMockParticipants(3) });
223
+ */
224
+ export function createMockParticipants(count = 1, teamId = 'team-1') {
225
+ return Array.from({ length: count }, (_, index) => ({
226
+ id: String(index + 1),
227
+ name: `Participant ${index + 1}`,
228
+ index,
229
+ connected: true,
230
+ teamId,
231
+ }));
232
+ }
233
+ /**
234
+ * A `TeamState` for a session-surface fixture, with the fields a test rarely
235
+ * cares about filled in.
236
+ *
237
+ * `revision` is the reason this exists. It is required, because the wrapper
238
+ * needs a baseline to compare an `updateTeamState` delta against, but no
239
+ * surface ever reads it, so making every fixture carry one would be friction
240
+ * for a field with no meaning to the test.
241
+ *
242
+ * Type it with the surface's generated `TeamContext`, and a shared capability
243
+ * left on a team's fixture fails to compile, as reading one through a team
244
+ * does in the surface. Capabilities the fixture leaves out are absent.
245
+ *
246
+ * ```typescript
247
+ * import type { TeamContext } from '../types';
248
+ *
249
+ * createMockTeamState<TeamContext>({ context: { price: { Price: 5 } } });
250
+ * ```
251
+ */
252
+ export function createMockTeamState(overrides) {
253
+ const { context, ...rest } = overrides ?? {};
254
+ return {
255
+ context: (context ?? {}),
256
+ completedTicks: 0,
257
+ timer: DEFAULT_SURFACE_TIMER,
258
+ revision: 1,
259
+ ...rest,
260
+ };
261
+ }
262
+ /**
263
+ * A `SharedState` for a session-surface fixture: the shared store's context,
264
+ * with a `revision` filled in for the same reason {@link createMockTeamState}
265
+ * fills one. Type it with the surface's generated `SharedContext`.
266
+ *
267
+ * ```typescript
268
+ * createMockSharedState<SharedContext>({ context: { market: { Price: 5 } } });
269
+ * ```
270
+ */
271
+ export function createMockSharedState(overrides) {
272
+ const { context, ...rest } = overrides ?? {};
273
+ return {
274
+ context: (context ?? {}),
275
+ revision: 1,
276
+ ...rest,
277
+ };
278
+ }
279
+ /**
280
+ * A `setData` for a stateful test harness that writes the way the host does:
281
+ * objects merge recursively, arrays, scalars and `null` replace whole, and
282
+ * any `deletes` are removed after the merge.
283
+ *
284
+ * A hand-rolled `{ ...previous, ...patch }` does not. It replaces a whole
285
+ * capability with whatever the patch names, so a minimal write (the shape
286
+ * every concurrency-safe surface sends) appears to wipe every field it did
287
+ * not mention. Tests then fail on writes that are correct in production, or
288
+ * worse, pass on writes that are not (#520).
289
+ *
290
+ * ```tsx
291
+ * const [data, setLocal] = useState(mockData);
292
+ * const setData = mergingSetData(setLocal);
293
+ * setData({}, { deletes: [`pinboard.Notes.${noteId}`] });
294
+ * ```
295
+ *
296
+ * Wrap it in a `vi.fn()` when the test also asserts on the payload.
297
+ */
298
+ export function mergingSetData(setLocal) {
299
+ // `T extends object` rather than `Record<string, unknown>`: a generated
300
+ // `Context` is an interface, and an interface has no implicit index
301
+ // signature, so the tighter constraint would reject the one type every
302
+ // caller actually holds. The merge only ever reads own enumerable keys.
303
+ return (patch, options) => setLocal((previous) => applyContextUpdate(previous, patch, options?.deletes));
304
+ }
305
+ const DEFAULT_PARTICIPANT = createMockParticipant();
306
+ const DEFAULT_TIMER = DEFAULT_SURFACE_TIMER;
307
+ /**
308
+ * Resolve the own team a rendered player surface should see, mirroring the
309
+ * fallback order the real wrapper applies: an explicit value wins, otherwise the
310
+ * participant's team is resolved against the team list. An observer render (no
311
+ * participant) yields `null` unless `ownTeam` is given, matching a host that does
312
+ * not send `setOwnTeam`.
313
+ */
314
+ function resolveOwnTeam(options) {
315
+ if (options.ownTeam !== undefined)
316
+ return options.ownTeam;
317
+ const participant = options.participant !== undefined
318
+ ? options.participant
319
+ : options.viewerRole === 'observer'
320
+ ? null
321
+ : DEFAULT_PARTICIPANT;
322
+ if (!participant)
323
+ return null;
324
+ return (options.teams ?? []).find((team) => team.teamId === participant.teamId) ?? null;
325
+ }
326
+ function provide(context, value) {
327
+ return (child) => createElement(context.Provider, { value }, child);
328
+ }
329
+ function wrapProviders(providers, child) {
330
+ return providers.reduceRight((acc, wrap) => wrap(acc), child);
331
+ }
332
+ /**
333
+ * Renders a component wrapped in all UserInterface context providers with
334
+ * sensible defaults. Override any context value via the options object.
335
+ *
336
+ * Returns the standard `@testing-library/react` render result plus the mock
337
+ * `setData` and resolved `callbacks` for assertions.
338
+ *
339
+ * Callbacks you do not supply default to no-ops rather than spies, so pass your
340
+ * own `vi.fn()` for any you intend to assert on.
341
+ *
342
+ * @example
343
+ * ```tsx
344
+ * const requestTick = vi.fn();
345
+ * const { setData } = renderUserInterface(<App />, {
346
+ * data: { 'com.example.cap': { Score: 42 } },
347
+ * settings: { scoring: { MaxScore: 100 } },
348
+ * callbacks: { requestTick },
349
+ * });
350
+ *
351
+ * fireEvent.click(screen.getByText('Submit'));
352
+ * expect(requestTick).toHaveBeenCalled();
353
+ * ```
354
+ */
355
+ export function renderUserInterface(ui, options = {}) {
356
+ const resolvedSetData = options.setData ?? noop;
357
+ // The context's `setData` resolves with a commit; a mock usually returns
358
+ // nothing, so answer as an on-time write unless the mock says otherwise.
359
+ const contextSetData = async (...args) => (await Reflect.apply(resolvedSetData, undefined, args)) ?? { late: false };
360
+ const blobPrefix = options.blobPrefix ?? DEFAULT_BLOB_URL_PREFIX;
361
+ const scenarioAssetPrefix = options.scenarioAssetPrefix ?? DEFAULT_ASSET_URL_PREFIX;
362
+ const sharedAssetPrefix = options.sharedAssetPrefix ?? DEFAULT_SHARED_ASSET_URL_PREFIX;
363
+ const resolvedCallbacks = {
364
+ requestTick: asyncNoop,
365
+ renameOwnTeam: asyncNoop,
366
+ uploadBlob: asyncEmptyReference,
367
+ getAssetUrl: makeGetAssetUrl(blobPrefix, scenarioAssetPrefix, sharedAssetPrefix),
368
+ readAsset: asyncEmptyAsset,
369
+ getPastState: asyncEmpty,
370
+ getAllTeamsParticipants: async () => [],
371
+ setThemePreference: noop,
372
+ endpoints: {},
373
+ ...options.callbacks,
374
+ };
375
+ const dataValue = {
376
+ data: (options.data ?? {}),
377
+ settings: options.settings ?? {},
378
+ completedTicks: options.completedTicks ?? 0,
379
+ isRoundProcessing: options.isRoundProcessing ?? false,
380
+ setData: contextSetData,
381
+ };
382
+ const participant = options.participant !== undefined
383
+ ? options.participant
384
+ : options.viewerRole === 'observer'
385
+ ? null
386
+ : DEFAULT_PARTICIPANT;
387
+ const providers = [
388
+ provide(ViewerStorageContext, {
389
+ sessionKey: options.sessionKey ?? DEFAULT_SESSION_KEY,
390
+ surface: 'player',
391
+ viewer: participant?.id ?? options.viewerRole ?? 'participant',
392
+ }),
393
+ provide(AssetContext, {
394
+ getAssetUrl: resolvedCallbacks.getAssetUrl,
395
+ locale: (options.locale ?? DEFAULT_LOCALE).locale,
396
+ }),
397
+ provide(PastStateContext, resolvedCallbacks.getPastState),
398
+ provide(UserInterfaceThemeContext, options.theme ?? createMockTheme()),
399
+ provide(UserInterfaceThemePreferenceContext, options.themePreference ?? 'system'),
400
+ provide(UserInterfaceLocaleContext, options.locale ?? DEFAULT_LOCALE),
401
+ provide(UserInterfaceViewerRoleContext, options.viewerRole ?? 'participant'),
402
+ provide(UserInterfaceRunKindContext, options.runKind ?? 'session'),
403
+ // An observer has no caller identity, so the participant defaults to
404
+ // null in that role unless the caller overrides it explicitly.
405
+ provide(UserInterfaceParticipantContext, participant),
406
+ provide(UserInterfaceParticipantsContext, options.participants ?? []),
407
+ provide(UserInterfaceTeamsContext, options.teams ?? []),
408
+ provide(UserInterfaceOwnTeamContext, resolveOwnTeam(options)),
409
+ provide(UserInterfaceOwnTeamJoinLinkContext, options.ownTeamJoinLink ?? null),
410
+ provide(UserInterfaceTimerContext, options.timer ?? DEFAULT_TIMER),
411
+ provide(UserInterfaceValidationContext, {
412
+ rejections: options.validationRejections ?? [],
413
+ clearRejections: options.clearRejections ?? noop,
414
+ }),
415
+ provide(UserInterfaceDataContext, dataValue),
416
+ provide(UserInterfaceCallbacksContext, resolvedCallbacks),
417
+ ];
418
+ const result = render(wrapProviders(providers, ui));
419
+ return { ...result, setData: resolvedSetData, callbacks: resolvedCallbacks };
420
+ }
421
+ /**
422
+ * Renders a component wrapped in every settings editor context provider: the
423
+ * `setup` slot and the `scenario_editor` slot share them.
424
+ *
425
+ * `updateSettings` defaults to a no-op rather than a spy, so pass your own
426
+ * `vi.fn()` to assert on it.
427
+ *
428
+ * @example
429
+ * ```tsx
430
+ * const updateSettings = vi.fn();
431
+ * renderSettingsEditor(<App />, {
432
+ * settings: { engine: { MaxPlayers: 10 } },
433
+ * updateSettings,
434
+ * });
435
+ *
436
+ * fireEvent.change(screen.getByLabelText('Max Players'), { target: { value: '20' } });
437
+ * expect(updateSettings).toHaveBeenCalled();
438
+ * ```
439
+ */
440
+ export function renderSettingsEditor(ui, options = {}) {
441
+ const resolvedUpdateSettings = (options.updateSettings ?? asyncNoop);
442
+ const blobPrefix = options.blobPrefix ?? DEFAULT_BLOB_URL_PREFIX;
443
+ const scenarioAssetPrefix = options.scenarioAssetPrefix ?? DEFAULT_ASSET_URL_PREFIX;
444
+ const sharedAssetPrefix = options.sharedAssetPrefix ?? DEFAULT_SHARED_ASSET_URL_PREFIX;
445
+ const resolvedCallbacks = {
446
+ uploadBlob: asyncEmptyReference,
447
+ getAssetUrl: makeGetAssetUrl(blobPrefix, scenarioAssetPrefix, sharedAssetPrefix),
448
+ readAsset: asyncEmptyAsset,
449
+ setThemePreference: noop,
450
+ requestPlay: noop,
451
+ endpoints: {},
452
+ ...options.callbacks,
453
+ };
454
+ const settings = options.settings ?? {};
455
+ const editorValue = {
456
+ settings,
457
+ updateSettings: resolvedUpdateSettings,
458
+ configures: options.configures ?? Object.keys(settings),
459
+ };
460
+ const providers = [
461
+ provide(ViewerStorageContext, {
462
+ sessionKey: options.sessionKey ?? DEFAULT_SESSION_KEY,
463
+ surface: 'settings-editor',
464
+ viewer: 'facilitator',
465
+ }),
466
+ provide(AssetContext, {
467
+ getAssetUrl: resolvedCallbacks.getAssetUrl,
468
+ locale: (options.locale ?? DEFAULT_LOCALE).locale,
469
+ }),
470
+ provide(SettingsEditorThemeContext, options.theme ?? createMockSettingsEditorTheme()),
471
+ provide(SettingsEditorThemePreferenceContext, options.themePreference ?? 'system'),
472
+ provide(SettingsEditorLocaleContext, options.locale ?? DEFAULT_LOCALE),
473
+ provide(SettingsEditorContext, editorValue),
474
+ provide(SettingsEditorCallbacksContext, resolvedCallbacks),
475
+ ];
476
+ const result = render(wrapProviders(providers, ui));
477
+ return { ...result, updateSettings: resolvedUpdateSettings, callbacks: resolvedCallbacks };
478
+ }
479
+ /**
480
+ * Renders a component wrapped in all SessionUserInterface context providers.
481
+ *
482
+ * Callbacks you do not supply default to no-ops rather than spies, so pass your
483
+ * own `vi.fn()` for any you intend to assert on.
484
+ *
485
+ * @example
486
+ * ```tsx
487
+ * const resetGame = vi.fn();
488
+ * renderSessionUserInterface(<SessionDashboard />, {
489
+ * sessionData: { teams: { 'team-1': createMockTeamState() } },
490
+ * teams: [{ teamId: 'team-1', index: 0, name: 'Team 1' }],
491
+ * callbacks: { resetGame },
492
+ * });
493
+ *
494
+ * fireEvent.click(screen.getByText('Reset All'));
495
+ * expect(resetGame).toHaveBeenCalled();
496
+ * ```
497
+ */
498
+ export function renderSessionUserInterface(ui, options = {}) {
499
+ const resolvedSetData = options.setData ?? noop;
500
+ // The shared-scope form calls the mock with the update alone, as the host's
501
+ // own `setData` is called.
502
+ const contextSetData = async (...args) => {
503
+ Reflect.apply(resolvedSetData, undefined, args);
504
+ };
505
+ const blobPrefix = options.blobPrefix ?? DEFAULT_BLOB_URL_PREFIX;
506
+ const scenarioAssetPrefix = options.scenarioAssetPrefix ?? DEFAULT_ASSET_URL_PREFIX;
507
+ const sharedAssetPrefix = options.sharedAssetPrefix ?? DEFAULT_SHARED_ASSET_URL_PREFIX;
508
+ const resolvedCallbacks = {
509
+ uploadBlob: asyncEmptyReference,
510
+ getAssetUrl: makeGetAssetUrl(blobPrefix, scenarioAssetPrefix, sharedAssetPrefix),
511
+ readAsset: asyncEmptyAsset,
512
+ getPastState: asyncEmpty,
513
+ requestTick: asyncNoop,
514
+ pauseTimer: asyncNoop,
515
+ resumeTimer: asyncNoop,
516
+ resetGame: asyncNoop,
517
+ undoTick: asyncNoop,
518
+ extendTick: asyncNoop,
519
+ openHostOverlay: asyncNoop,
520
+ setWaitingRoom: asyncNoop,
521
+ resetWalkthrough: asyncNoop,
522
+ createTeam: asyncNoop,
523
+ renameTeam: asyncNoop,
524
+ renameParticipant: asyncNoop,
525
+ moveParticipant: asyncNoop,
526
+ deleteTeam: asyncNoop,
527
+ setThemePreference: noop,
528
+ endpoints: {},
529
+ ...options.callbacks,
530
+ };
531
+ const dataValue = {
532
+ // The provider holds the untyped shape; the surface's own
533
+ // `useSessionUserInterfaceData<SessionData>` types it back.
534
+ sessionData: {
535
+ shared: (options.sessionData?.shared ?? createMockSharedState()),
536
+ teams: (options.sessionData?.teams ?? {}),
537
+ },
538
+ settings: options.settings ?? {},
539
+ setData: contextSetData,
540
+ };
541
+ const providers = [
542
+ provide(ViewerStorageContext, {
543
+ sessionKey: options.sessionKey ?? DEFAULT_SESSION_KEY,
544
+ surface: 'session',
545
+ viewer: 'facilitator',
546
+ }),
547
+ provide(AssetContext, {
548
+ getAssetUrl: resolvedCallbacks.getAssetUrl,
549
+ locale: (options.locale ?? DEFAULT_LOCALE).locale,
550
+ }),
551
+ provide(PastStateContext, resolvedCallbacks.getPastState),
552
+ provide(SessionUserInterfaceThemeContext, options.theme ?? createMockSessionTheme()),
553
+ provide(SessionUserInterfaceThemePreferenceContext, options.themePreference ?? 'system'),
554
+ provide(SessionUserInterfaceLocaleContext, options.locale ?? DEFAULT_LOCALE),
555
+ provide(SessionUserInterfaceParticipantsContext, options.participants ?? []),
556
+ provide(SessionUserInterfaceTeamsContext, options.teams ?? []),
557
+ provide(SessionUserInterfaceTeamConfigurationContext, options.teamConfiguration ?? DEFAULT_TEAM_CONFIGURATION),
558
+ provide(SessionUserInterfaceMetaContext, options.sessionMeta ?? DEFAULT_SESSION_META),
559
+ provide(SessionUserInterfaceTeamLinksContext, options.teamLinks ?? []),
560
+ provide(UserInterfaceRunKindContext, options.runKind ?? 'session'),
561
+ provide(SessionUserInterfaceHostOverlaysContext, options.hostOverlays ?? []),
562
+ provide(SessionUserInterfaceWalkthroughLinkContext, options.walkthroughLink ?? null),
563
+ provide(SessionUserInterfaceHookFailuresContext, {
564
+ failures: options.hookFailures ?? [],
565
+ clearFailures: options.clearHookFailures ?? noop,
566
+ }),
567
+ provide(SessionUserInterfaceDataContext, dataValue),
568
+ provide(SessionUserInterfaceCallbacksContext, resolvedCallbacks),
569
+ ];
570
+ const result = render(wrapProviders(providers, ui));
571
+ return { ...result, setData: resolvedSetData, callbacks: resolvedCallbacks };
572
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Error a `getPastState` call rejects with on a genuine read fault.
3
+ *
4
+ * `getPastState` (§12) answers with the rows its query reaches, so every
5
+ * *expected* miss — a tick at or above the head, an undone or reset tick, an
6
+ * out-of-range tick, an unknown team, a capability the surface may not read —
7
+ * is simply an absent row. It rejects with a `TickSnapshotError` only when the
8
+ * read itself fails: the host is not connected, the connection resets
9
+ * mid-flight, or the host reports an unexpected fault. This is deliberately
10
+ * distinct from `MutationError`, which is for fallible *writes*; a read never
11
+ * conflicts, is never rejected by a validator, and is never denied.
12
+ *
13
+ * ```typescript
14
+ * try {
15
+ * const [past] = await callbacks.getPastState({ fromTick: day });
16
+ * if (!past) {
17
+ * // expected: that day is not reviewable (out of range, undone, reset)
18
+ * }
19
+ * } catch (e) {
20
+ * if (e instanceof TickSnapshotError) {
21
+ * // transport/host fault — surface a retry affordance
22
+ * }
23
+ * }
24
+ * ```
25
+ */
26
+ export declare class TickSnapshotError extends Error {
27
+ constructor(message: string);
28
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Error a `getPastState` call rejects with on a genuine read fault.
3
+ *
4
+ * `getPastState` (§12) answers with the rows its query reaches, so every
5
+ * *expected* miss — a tick at or above the head, an undone or reset tick, an
6
+ * out-of-range tick, an unknown team, a capability the surface may not read —
7
+ * is simply an absent row. It rejects with a `TickSnapshotError` only when the
8
+ * read itself fails: the host is not connected, the connection resets
9
+ * mid-flight, or the host reports an unexpected fault. This is deliberately
10
+ * distinct from `MutationError`, which is for fallible *writes*; a read never
11
+ * conflicts, is never rejected by a validator, and is never denied.
12
+ *
13
+ * ```typescript
14
+ * try {
15
+ * const [past] = await callbacks.getPastState({ fromTick: day });
16
+ * if (!past) {
17
+ * // expected: that day is not reviewable (out of range, undone, reset)
18
+ * }
19
+ * } catch (e) {
20
+ * if (e instanceof TickSnapshotError) {
21
+ * // transport/host fault — surface a retry affordance
22
+ * }
23
+ * }
24
+ * ```
25
+ */
26
+ export class TickSnapshotError extends Error {
27
+ constructor(message) {
28
+ super(message);
29
+ this.name = 'TickSnapshotError';
30
+ // Restore the prototype chain so `instanceof` works after transpilation
31
+ // to ES5-targeted output.
32
+ Object.setPrototypeOf(this, TickSnapshotError.prototype);
33
+ }
34
+ }