@seriousgaming/sdk-tools 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 (391) hide show
  1. package/LICENSE +93 -0
  2. package/README.md +38 -2
  3. package/dist/bundling/logic-exports.d.ts +34 -0
  4. package/dist/bundling/node-builtins.d.ts +21 -0
  5. package/dist/bundling/transpiler.d.ts +7 -0
  6. package/dist/cli.js +27404 -0
  7. package/dist/client/dev-server.d.ts +59 -0
  8. package/dist/client/index.d.ts +28 -0
  9. package/dist/client/stub-services.d.ts +32 -0
  10. package/dist/client/test-session.d.ts +195 -0
  11. package/dist/client.js +16920 -0
  12. package/dist/core/asset-reference-translator.d.ts +46 -0
  13. package/dist/core/clock.d.ts +57 -0
  14. package/dist/core/faults.d.ts +65 -0
  15. package/dist/core/game-settings.d.ts +65 -0
  16. package/dist/core/history.d.ts +89 -0
  17. package/dist/core/listen-with-fallback.d.ts +30 -0
  18. package/dist/core/mutation-queue.d.ts +61 -0
  19. package/dist/core/package-scenario.d.ts +89 -0
  20. package/dist/core/participant-registry.d.ts +109 -0
  21. package/dist/core/pending-changes.d.ts +14 -0
  22. package/dist/core/protocol.d.ts +83 -0
  23. package/dist/core/scenario-drafts.d.ts +32 -0
  24. package/dist/core/session-endpoints.d.ts +108 -0
  25. package/dist/core/session-gates.d.ts +75 -0
  26. package/dist/core/session-host.d.ts +94 -0
  27. package/dist/core/session-refusals.d.ts +56 -0
  28. package/dist/core/session-rewind.d.ts +32 -0
  29. package/dist/core/session-roster.d.ts +128 -0
  30. package/dist/core/session-surfaces.d.ts +32 -0
  31. package/dist/core/session-ticks.d.ts +18 -0
  32. package/dist/core/session-timers.d.ts +84 -0
  33. package/dist/core/session-writes.d.ts +40 -0
  34. package/dist/core/session.d.ts +385 -0
  35. package/dist/core/settings-locks.d.ts +22 -0
  36. package/dist/core/team-names.d.ts +52 -0
  37. package/dist/core/team-state-store.d.ts +218 -0
  38. package/dist/core/tick-timer.d.ts +75 -0
  39. package/dist/dev/blob-links.d.ts +10 -0
  40. package/dist/dev/console/assets/index-DgFglsnA.js +204 -0
  41. package/dist/dev/console/index.html +12 -0
  42. package/dist/dev/console-path.d.ts +19 -0
  43. package/dist/dev/lockfile.d.ts +124 -0
  44. package/dist/dev/package-watcher.d.ts +21 -0
  45. package/dist/dev/routes.d.ts +67 -0
  46. package/dist/dev/shell.d.ts +260 -0
  47. package/dist/dev/surfaces.d.ts +41 -0
  48. package/dist/dev/tools-identity.d.ts +23 -0
  49. package/dist/dev/view/batch.d.ts +172 -0
  50. package/dist/dev/view/commands.d.ts +75 -0
  51. package/dist/dev/view/route.d.ts +58 -0
  52. package/dist/dev/view/view.d.ts +78 -0
  53. package/dist/dev/view/viewer.d.ts +32 -0
  54. package/dist/discovery/config.d.ts +9 -0
  55. package/dist/discovery/manifest-paths.d.ts +21 -0
  56. package/dist/discovery/removed-fields.d.ts +90 -0
  57. package/dist/docs/api/deck.md +386 -0
  58. package/dist/docs/api/index.md +67 -0
  59. package/dist/docs/api/sdk-commands.md +286 -0
  60. package/dist/docs/api/sdk-e2e.md +433 -0
  61. package/dist/docs/api/sdk-projection.md +207 -0
  62. package/dist/docs/api/sdk-testing.md +214 -0
  63. package/dist/docs/api/sdk-tools-client.md +560 -0
  64. package/dist/docs/api/sdk-ui-testing.md +231 -0
  65. package/dist/docs/api/sdk-ui.md +1269 -0
  66. package/dist/docs/api/sdk.md +2140 -0
  67. package/dist/docs/changelog-0.x.md +1156 -0
  68. package/dist/docs/changelog.md +241 -0
  69. package/dist/docs/cli-and-dev-server.md +217 -0
  70. package/dist/docs/cli-error-codes.md +156 -0
  71. package/dist/docs/cli-reference.md +2489 -0
  72. package/dist/docs/cli-simulation-testing.md +222 -0
  73. package/dist/docs/concepts/index.md +46 -0
  74. package/dist/docs/concepts/terminology.md +322 -0
  75. package/dist/docs/environment-variables.md +102 -0
  76. package/dist/docs/getting-started.md +145 -0
  77. package/dist/docs/guides/ai-facilitation.md +220 -0
  78. package/dist/docs/guides/capabilities.md +460 -0
  79. package/dist/docs/guides/endpoints.md +335 -0
  80. package/dist/docs/guides/engine-testing.md +477 -0
  81. package/dist/docs/guides/engine.md +527 -0
  82. package/dist/docs/guides/file-handling.md +405 -0
  83. package/dist/docs/guides/games.md +176 -0
  84. package/dist/docs/guides/global-settings.md +94 -0
  85. package/dist/docs/guides/guide.md +123 -0
  86. package/dist/docs/guides/hooks.md +183 -0
  87. package/dist/docs/guides/index.md +51 -0
  88. package/dist/docs/guides/internationalization.md +288 -0
  89. package/dist/docs/guides/llm-integration.md +635 -0
  90. package/dist/docs/guides/multi-team-games.md +586 -0
  91. package/dist/docs/guides/patterns.md +391 -0
  92. package/dist/docs/guides/presentation.md +196 -0
  93. package/dist/docs/guides/production-constraints.md +130 -0
  94. package/dist/docs/guides/scenario-editor.md +143 -0
  95. package/dist/docs/guides/scenarios.md +515 -0
  96. package/dist/docs/guides/session.md +972 -0
  97. package/dist/docs/guides/settings.md +458 -0
  98. package/dist/docs/guides/setup.md +193 -0
  99. package/dist/docs/guides/speech-generation.md +233 -0
  100. package/dist/docs/guides/starter-game.md +164 -0
  101. package/dist/docs/guides/testing.md +577 -0
  102. package/dist/docs/guides/tick-controller.md +569 -0
  103. package/dist/docs/guides/type-safety.md +516 -0
  104. package/dist/docs/guides/ui.md +1389 -0
  105. package/dist/docs/guides/upgrading.md +636 -0
  106. package/dist/docs/guides/validators.md +217 -0
  107. package/dist/docs/guides/waiting-room.md +244 -0
  108. package/dist/docs/index.md +41 -0
  109. package/dist/docs/recipes/act-on-the-session-from-an-endpoint.md +95 -0
  110. package/dist/docs/recipes/adapt-a-game-for-walkthroughs.md +133 -0
  111. package/dist/docs/recipes/adapt-ui-for-observers.md +74 -0
  112. package/dist/docs/recipes/call-endpoint-from-ui.md +109 -0
  113. package/dist/docs/recipes/check-a-package-from-a-script.md +117 -0
  114. package/dist/docs/recipes/create-capability.md +66 -0
  115. package/dist/docs/recipes/create-endpoint.md +127 -0
  116. package/dist/docs/recipes/create-engine.md +111 -0
  117. package/dist/docs/recipes/create-game.md +70 -0
  118. package/dist/docs/recipes/create-guide.md +75 -0
  119. package/dist/docs/recipes/create-hook.md +80 -0
  120. package/dist/docs/recipes/create-presentation.md +142 -0
  121. package/dist/docs/recipes/create-scenario-editor.md +102 -0
  122. package/dist/docs/recipes/create-scenario.md +98 -0
  123. package/dist/docs/recipes/create-session-ui.md +116 -0
  124. package/dist/docs/recipes/create-settings.md +88 -0
  125. package/dist/docs/recipes/create-setup.md +115 -0
  126. package/dist/docs/recipes/create-tick-controller.md +65 -0
  127. package/dist/docs/recipes/create-user-interface.md +96 -0
  128. package/dist/docs/recipes/create-validator.md +67 -0
  129. package/dist/docs/recipes/create-waiting-room-ui.md +109 -0
  130. package/dist/docs/recipes/declare-api-uses.md +67 -0
  131. package/dist/docs/recipes/design-state-for-concurrent-writes.md +96 -0
  132. package/dist/docs/recipes/distribute-package.md +80 -0
  133. package/dist/docs/recipes/e2e-testing.md +116 -0
  134. package/dist/docs/recipes/end-game-on-condition.md +154 -0
  135. package/dist/docs/recipes/fix-duplicate-packages.md +101 -0
  136. package/dist/docs/recipes/index.md +86 -0
  137. package/dist/docs/recipes/make-an-endpoint-external.md +101 -0
  138. package/dist/docs/recipes/negotiate-across-teams.md +164 -0
  139. package/dist/docs/recipes/observe-other-teams-state.md +119 -0
  140. package/dist/docs/recipes/publish-a-package.md +139 -0
  141. package/dist/docs/recipes/remember-a-viewers-choice.md +55 -0
  142. package/dist/docs/recipes/review-a-past-day.md +73 -0
  143. package/dist/docs/recipes/share-assets-across-scenarios.md +120 -0
  144. package/dist/docs/recipes/share-logic-between-engine-and-ui.md +130 -0
  145. package/dist/docs/recipes/show-all-teams-participants.md +109 -0
  146. package/dist/docs/recipes/start-from-an-empty-package.md +169 -0
  147. package/dist/docs/recipes/take-a-file-in-an-endpoint.md +82 -0
  148. package/dist/docs/recipes/test-a-multi-role-game.md +51 -0
  149. package/dist/docs/recipes/test-user-interface.md +95 -0
  150. package/dist/docs/recipes/test-with-engine.md +115 -0
  151. package/dist/docs/recipes/upload-and-display-files.md +148 -0
  152. package/dist/docs/recipes/use-request-play.md +65 -0
  153. package/dist/docs/recipes/use-request-tick.md +75 -0
  154. package/dist/docs/troubleshooting.md +816 -0
  155. package/dist/execution/blob-inputs.d.ts +57 -0
  156. package/dist/execution/bundle-reload.d.ts +42 -0
  157. package/dist/execution/config-resolver.d.ts +127 -0
  158. package/dist/execution/configuration-builder.d.ts +66 -0
  159. package/dist/execution/context-composition.d.ts +161 -0
  160. package/dist/execution/endpoint-actions.d.ts +9 -0
  161. package/dist/execution/endpoint-payloads.d.ts +32 -0
  162. package/dist/execution/endpoint-runner.d.ts +129 -0
  163. package/dist/execution/engine-state.d.ts +40 -0
  164. package/dist/execution/executor-callbacks.d.ts +56 -0
  165. package/dist/execution/executor.d.ts +436 -0
  166. package/dist/execution/gates.d.ts +45 -0
  167. package/dist/execution/hook-chain.d.ts +25 -0
  168. package/dist/execution/logic-calls.d.ts +190 -0
  169. package/dist/execution/logic-failure.d.ts +84 -0
  170. package/dist/execution/logic-loader.d.ts +54 -0
  171. package/dist/execution/logic-result.d.ts +76 -0
  172. package/dist/execution/tick-controller-call.d.ts +32 -0
  173. package/dist/execution/types.d.ts +48 -0
  174. package/dist/generation/ts-type.d.ts +39 -0
  175. package/dist/guide/locale.d.ts +12 -0
  176. package/dist/guide/video-registry.d.ts +42 -0
  177. package/dist/help-examples.d.ts +125 -0
  178. package/dist/host/adapter.d.ts +67 -0
  179. package/dist/host/host-channel.d.ts +222 -0
  180. package/dist/host/index.d.ts +20 -0
  181. package/dist/host/protocol.d.ts +783 -0
  182. package/dist/host/surfaces.d.ts +42 -0
  183. package/dist/host/theme.d.ts +55 -0
  184. package/dist/host.js +583 -0
  185. package/dist/prompts/index.d.ts +7 -0
  186. package/dist/prompts/schema-builder.d.ts +29 -0
  187. package/dist/prompts/types.d.ts +108 -0
  188. package/dist/prompts/validators.d.ts +32 -0
  189. package/dist/runtime/capability-scope.d.ts +6 -0
  190. package/dist/runtime/context-merge.d.ts +29 -0
  191. package/dist/runtime/context-projection.d.ts +41 -0
  192. package/dist/runtime/guarded-callbacks.d.ts +15 -0
  193. package/dist/runtime/index.d.ts +27 -0
  194. package/dist/runtime/manifest-version.d.ts +55 -0
  195. package/dist/runtime/tick-validation.d.ts +25 -0
  196. package/dist/runtime.js +346 -0
  197. package/dist/schema.json +1546 -0
  198. package/dist/server/vite-plugin.d.ts +46 -0
  199. package/dist/services/tts/client.d.ts +51 -0
  200. package/dist/services/tts/elevenlabs.d.ts +24 -0
  201. package/dist/services/tts/types.d.ts +44 -0
  202. package/dist/storage/asset-resolver.d.ts +49 -0
  203. package/dist/storage/blob-store.d.ts +41 -0
  204. package/dist/templates/endpoint/logic.test.ts.liquid +61 -0
  205. package/dist/templates/endpoint/logic.ts.liquid +25 -0
  206. package/dist/templates/engine/init.json.liquid +7 -0
  207. package/dist/templates/engine/logic-aggregate.test.ts.liquid +90 -0
  208. package/dist/templates/engine/logic-aggregate.ts.liquid +46 -0
  209. package/dist/templates/engine/logic.test.ts.liquid +61 -0
  210. package/dist/templates/engine/logic.ts.liquid +32 -0
  211. package/dist/templates/engine/run.json.liquid +7 -0
  212. package/dist/templates/guide/01-at-a-glance.md.liquid +19 -0
  213. package/dist/templates/guide/02-preparation.md.liquid +18 -0
  214. package/dist/templates/guide/03-running-the-session.md.liquid +37 -0
  215. package/dist/templates/hook/logic.test.ts.liquid +46 -0
  216. package/dist/templates/hook/logic.ts.liquid +12 -0
  217. package/dist/templates/package/CLAUDE.md.template +338 -0
  218. package/dist/templates/package/DESIGN.md.liquid +144 -0
  219. package/dist/templates/package/README.md.liquid +143 -0
  220. package/dist/templates/package/claude/rules/workflow-create-capability.md +26 -0
  221. package/dist/templates/package/claude/rules/workflow-create-endpoint.md +35 -0
  222. package/dist/templates/package/claude/rules/workflow-create-engine.md +35 -0
  223. package/dist/templates/package/claude/rules/workflow-create-guide.md +40 -0
  224. package/dist/templates/package/claude/rules/workflow-create-presentation.md +37 -0
  225. package/dist/templates/package/claude/rules/workflow-create-user-interface.md +31 -0
  226. package/dist/templates/package/claude/rules/workflow-publish-package.md +54 -0
  227. package/dist/templates/package/claude/rules/workflow-test-engine-or-ui.md +30 -0
  228. package/dist/templates/package/claude/rules/workflow-update-sdk-version.md +43 -0
  229. package/dist/templates/package/claude/settings.json.template +16 -0
  230. package/dist/templates/package/gitignore.template +15 -0
  231. package/dist/templates/package/global-setup.ts.template +21 -0
  232. package/dist/templates/package/globals.d.ts.template +2 -0
  233. package/dist/templates/package/oxfmtrc.json.template +14 -0
  234. package/dist/templates/package/oxlintrc.json.template +238 -0
  235. package/dist/templates/package/package.json.liquid +65 -0
  236. package/dist/templates/package/playwright.config.ts.liquid +19 -0
  237. package/dist/templates/package/pnpm-workspace.yaml.template +27 -0
  238. package/dist/templates/package/test-setup.ts.liquid +7 -0
  239. package/dist/templates/package/tsconfig.json.liquid +15 -0
  240. package/dist/templates/package/vite.config.ts.liquid +19 -0
  241. package/dist/templates/package/vitest.config.ts.liquid +25 -0
  242. package/dist/templates/package/vitest.engine.config.ts.liquid +12 -0
  243. package/dist/templates/presentation/index.html.liquid +20 -0
  244. package/dist/templates/presentation/src/App.tsx.liquid +49 -0
  245. package/dist/templates/presentation/src/Slides.tsx.liquid +81 -0
  246. package/dist/templates/presentation/src/i18n.ts.liquid +54 -0
  247. package/dist/templates/presentation/src/main.tsx.liquid +14 -0
  248. package/dist/templates/scenario_editor/src/App.test.tsx.liquid +30 -0
  249. package/dist/templates/scenario_editor/src/App.tsx.liquid +95 -0
  250. package/dist/templates/scenario_editor/src/i18n.ts.liquid +44 -0
  251. package/dist/templates/session/src/App.test.tsx.liquid +60 -0
  252. package/dist/templates/session/src/App.tsx.liquid +128 -0
  253. package/dist/templates/session/src/i18n.ts.liquid +53 -0
  254. package/dist/templates/session/src/main.tsx.liquid +48 -0
  255. package/dist/templates/session/src/theme.ts.liquid +46 -0
  256. package/dist/templates/settings-editor/index.html.liquid +12 -0
  257. package/dist/templates/settings-editor/src/main.tsx.liquid +48 -0
  258. package/dist/templates/settings-editor/src/theme.ts.liquid +24 -0
  259. package/dist/templates/setup/src/App.test.tsx.liquid +39 -0
  260. package/dist/templates/setup/src/App.tsx.liquid +102 -0
  261. package/dist/templates/setup/src/i18n.ts.liquid +47 -0
  262. package/dist/templates/starter-game/capabilities/briefing/capability.sdk.json +18 -0
  263. package/dist/templates/starter-game/capabilities/plan/capability.sdk.json +17 -0
  264. package/dist/templates/starter-game/capabilities/results/capability.sdk.json +61 -0
  265. package/dist/templates/starter-game/capabilities/roles/capability.sdk.json +17 -0
  266. package/dist/templates/starter-game/capabilities/weather/capability.sdk.json +23 -0
  267. package/dist/templates/starter-game/engine/defaults.ts +81 -0
  268. package/dist/templates/starter-game/engine/engine.sdk.json +12 -0
  269. package/dist/templates/starter-game/engine/logic.test.ts +221 -0
  270. package/dist/templates/starter-game/engine/logic.ts +149 -0
  271. package/dist/templates/starter-game/engine/test-helpers.ts +74 -0
  272. package/dist/templates/starter-game/engine/types.ts +139 -0
  273. package/dist/templates/starter-game/guide/content/en/01-at-a-glance.md +51 -0
  274. package/dist/templates/starter-game/guide/content/en/02-preparation.md +35 -0
  275. package/dist/templates/starter-game/guide/content/en/03-running-the-day.md +58 -0
  276. package/dist/templates/starter-game/guide/guide.sdk.json +10 -0
  277. package/dist/templates/starter-game/guide/guide.test.ts +108 -0
  278. package/dist/templates/starter-game/hooks/assignproducts/defaults.ts +37 -0
  279. package/dist/templates/starter-game/hooks/assignproducts/hook.sdk.json +13 -0
  280. package/dist/templates/starter-game/hooks/assignproducts/logic/logic.test.ts +77 -0
  281. package/dist/templates/starter-game/hooks/assignproducts/logic/logic.ts +47 -0
  282. package/dist/templates/starter-game/hooks/assignproducts/types.ts +55 -0
  283. package/dist/templates/starter-game/package.json +68 -0
  284. package/dist/templates/starter-game/playwright.config.ts +19 -0
  285. package/dist/templates/starter-game/scenarios/heatwave/scenario.sdk.json +20 -0
  286. package/dist/templates/starter-game/scenarios/ordinary_week/scenario.sdk.json +12 -0
  287. package/dist/templates/starter-game/seriousgaming.sdk.json +23 -0
  288. package/dist/templates/starter-game/session/defaults.ts +80 -0
  289. package/dist/templates/starter-game/session/index.html +12 -0
  290. package/dist/templates/starter-game/session/session.sdk.json +11 -0
  291. package/dist/templates/starter-game/session/src/App.test.tsx +307 -0
  292. package/dist/templates/starter-game/session/src/App.tsx +309 -0
  293. package/dist/templates/starter-game/session/src/Section.tsx +37 -0
  294. package/dist/templates/starter-game/session/src/TeamDoor.tsx +96 -0
  295. package/dist/templates/starter-game/session/src/format.ts +8 -0
  296. package/dist/templates/starter-game/session/src/i18n.ts +51 -0
  297. package/dist/templates/starter-game/session/src/main.tsx +48 -0
  298. package/dist/templates/starter-game/session/src/theme.ts +45 -0
  299. package/dist/templates/starter-game/session/types.ts +135 -0
  300. package/dist/templates/starter-game/settings/engine/settings.sdk.json +50 -0
  301. package/dist/templates/starter-game/settings/menu/settings.sdk.json +78 -0
  302. package/dist/templates/starter-game/settings/session/settings.sdk.json +23 -0
  303. package/dist/templates/starter-game/settings/ui/settings.sdk.json +25 -0
  304. package/dist/templates/starter-game/test-setup.ts +7 -0
  305. package/dist/templates/starter-game/testing/e2e/game.spec.ts +139 -0
  306. package/dist/templates/starter-game/testing/e2e/global-setup.ts +21 -0
  307. package/dist/templates/starter-game/testing/engine/game.engine.test.ts +171 -0
  308. package/dist/templates/starter-game/tsconfig.json +19 -0
  309. package/dist/templates/starter-game/ui/defaults.ts +60 -0
  310. package/dist/templates/starter-game/ui/index.html +12 -0
  311. package/dist/templates/starter-game/ui/src/App.test.tsx +138 -0
  312. package/dist/templates/starter-game/ui/src/App.tsx +175 -0
  313. package/dist/templates/starter-game/ui/src/DayBar.test.tsx +78 -0
  314. package/dist/templates/starter-game/ui/src/DayBar.tsx +72 -0
  315. package/dist/templates/starter-game/ui/src/HistoryPanel.test.tsx +74 -0
  316. package/dist/templates/starter-game/ui/src/HistoryPanel.tsx +120 -0
  317. package/dist/templates/starter-game/ui/src/MakeRow.tsx +129 -0
  318. package/dist/templates/starter-game/ui/src/Section.tsx +37 -0
  319. package/dist/templates/starter-game/ui/src/ThemeToggle.tsx +49 -0
  320. package/dist/templates/starter-game/ui/src/TodayPanel.test.tsx +179 -0
  321. package/dist/templates/starter-game/ui/src/TodayPanel.tsx +92 -0
  322. package/dist/templates/starter-game/ui/src/YesterdayPanel.tsx +142 -0
  323. package/dist/templates/starter-game/ui/src/format.ts +12 -0
  324. package/dist/templates/starter-game/ui/src/i18n.ts +95 -0
  325. package/dist/templates/starter-game/ui/src/main.tsx +48 -0
  326. package/dist/templates/starter-game/ui/src/menu.ts +18 -0
  327. package/dist/templates/starter-game/ui/src/test-helpers.ts +58 -0
  328. package/dist/templates/starter-game/ui/src/theme.ts +45 -0
  329. package/dist/templates/starter-game/ui/src/writes.ts +43 -0
  330. package/dist/templates/starter-game/ui/types.ts +137 -0
  331. package/dist/templates/starter-game/ui/ui.sdk.json +11 -0
  332. package/dist/templates/starter-game/validators/capacity/defaults.ts +37 -0
  333. package/dist/templates/starter-game/validators/capacity/logic/logic.test.ts +96 -0
  334. package/dist/templates/starter-game/validators/capacity/logic/logic.ts +45 -0
  335. package/dist/templates/starter-game/validators/capacity/types.ts +55 -0
  336. package/dist/templates/starter-game/validators/capacity/validator.sdk.json +11 -0
  337. package/dist/templates/starter-game/vite.config.ts +19 -0
  338. package/dist/templates/starter-game/vitest.config.ts +15 -0
  339. package/dist/templates/starter-game/vitest.engine.config.ts +19 -0
  340. package/dist/templates/starter-game/waiting_room/defaults.ts +40 -0
  341. package/dist/templates/starter-game/waiting_room/index.html +12 -0
  342. package/dist/templates/starter-game/waiting_room/src/App.test.tsx +102 -0
  343. package/dist/templates/starter-game/waiting_room/src/App.tsx +194 -0
  344. package/dist/templates/starter-game/waiting_room/src/i18n.ts +33 -0
  345. package/dist/templates/starter-game/waiting_room/src/main.tsx +48 -0
  346. package/dist/templates/starter-game/waiting_room/src/theme.ts +45 -0
  347. package/dist/templates/starter-game/waiting_room/types.ts +59 -0
  348. package/dist/templates/starter-game/waiting_room/waiting_room.sdk.json +10 -0
  349. package/dist/templates/tick-controller/logic.ts.liquid +8 -0
  350. package/dist/templates/ui/index.html.liquid +12 -0
  351. package/dist/templates/ui/src/App.test.tsx.liquid +41 -0
  352. package/dist/templates/ui/src/App.tsx.liquid +103 -0
  353. package/dist/templates/ui/src/i18n.ts.liquid +47 -0
  354. package/dist/templates/ui/src/main.tsx.liquid +48 -0
  355. package/dist/templates/ui/src/theme.ts.liquid +46 -0
  356. package/dist/templates/validator/logic.test.ts.liquid +42 -0
  357. package/dist/templates/validator/logic.ts.liquid +21 -0
  358. package/dist/templates/waiting-room/index.html.liquid +12 -0
  359. package/dist/templates/waiting-room/src/App.test.tsx.liquid +57 -0
  360. package/dist/templates/waiting-room/src/App.tsx.liquid +118 -0
  361. package/dist/templates/waiting-room/src/i18n.ts.liquid +44 -0
  362. package/dist/templates/waiting-room/src/main.tsx.liquid +48 -0
  363. package/dist/templates/waiting-room/src/theme.ts.liquid +46 -0
  364. package/dist/templating/resource-metadata.d.ts +131 -0
  365. package/dist/types.d.ts +14 -0
  366. package/dist/utils/atomic-write.d.ts +40 -0
  367. package/dist/utils/cli-error.d.ts +64 -0
  368. package/dist/utils/document-parser-client.d.ts +20 -0
  369. package/dist/utils/env-loader.d.ts +40 -0
  370. package/dist/utils/error-codes.d.ts +339 -0
  371. package/dist/utils/errors.d.ts +19 -0
  372. package/dist/utils/global-settings.d.ts +65 -0
  373. package/dist/utils/llm-client.d.ts +34 -0
  374. package/dist/utils/logger.d.ts +36 -0
  375. package/dist/utils/output.d.ts +109 -0
  376. package/dist/utils/platform.d.ts +14 -0
  377. package/dist/utils/prompt.d.ts +36 -0
  378. package/dist/utils/real-path.d.ts +6 -0
  379. package/dist/utils/schema-properties.d.ts +35 -0
  380. package/dist/validation/ajv.d.ts +17 -0
  381. package/dist/validation/facilitator-guide-validator.d.ts +27 -0
  382. package/dist/validation/findings.d.ts +40 -0
  383. package/dist/validation/game-constraints.d.ts +10 -0
  384. package/dist/validation/game-validator.d.ts +34 -0
  385. package/dist/validation/package-validation.d.ts +41 -0
  386. package/dist/validation/package-validator.d.ts +19 -0
  387. package/dist/validation/resource-validator.d.ts +79 -0
  388. package/dist/validation/schema-defaults-validator.d.ts +29 -0
  389. package/dist/validation/schema-validation.d.ts +115 -0
  390. package/dist/validation/semver.d.ts +9 -0
  391. package/package.json +123 -3
@@ -0,0 +1,139 @@
1
+ import { expect, test } from '@playwright/test';
2
+ import { DEV_SERVER, getState, reinitialiseSession, runCommand } from '@seriousgaming/sdk/e2e';
3
+
4
+ test.beforeEach(async () => {
5
+ // A session lives as long as the dev server, so start it again from the
6
+ // package rather than inheriting whatever the last test left behind: a
7
+ // reset would go back to day one but keep a team an earlier test added.
8
+ await reinitialiseSession();
9
+ // This game starts held, which is right for a real session and wrong for
10
+ // most of these tests: they are about the stall.
11
+ await runCommand({ type: 'setWaitingRoom', open: false });
12
+ });
13
+
14
+ test('a held session shows the waiting room instead of the stall', async ({ page }) => {
15
+ await runCommand({ type: 'setWaitingRoom', open: true });
16
+
17
+ // `?bare` drops the dev console's chrome, so this test is about the game.
18
+ await page.goto(DEV_SERVER.bare(DEV_SERVER.playerUrl(1)));
19
+
20
+ const frame = page.frameLocator(DEV_SERVER.iframeSelector('waiting_room'));
21
+ await expect(frame.getByText('Nearly opening time')).toBeVisible();
22
+ });
23
+
24
+ test('the stall opens on day one, with the whole day on one screen', async ({ page }) => {
25
+ await page.goto(DEV_SERVER.bare(DEV_SERVER.playerUrl(1)));
26
+
27
+ const frame = page.frameLocator(DEV_SERVER.iframeSelector('ui'));
28
+ await expect(frame.getByTestId('day')).toHaveText('Day 1');
29
+ await expect(frame.getByText('What shall we make today?')).toBeVisible();
30
+ await expect(frame.getByText('How yesterday went')).toBeVisible();
31
+ });
32
+
33
+ test('changing one item leaves the others alone', async ({ page }) => {
34
+ await page.goto(DEV_SERVER.bare(DEV_SERVER.playerUrl(1)));
35
+ const frame = page.frameLocator(DEV_SERVER.iframeSelector('ui'));
36
+
37
+ const lemonade = frame.getByTestId('made-lemonade');
38
+ const cookies = frame.getByTestId('made-cookies');
39
+ await expect(lemonade).toHaveText('28');
40
+ await expect(cookies).toHaveText('10');
41
+
42
+ await frame.getByRole('button', { name: 'Make more Lemonade' }).click();
43
+
44
+ await expect(lemonade).toHaveText('33');
45
+ await expect(cookies).toHaveText('10');
46
+ });
47
+
48
+ test('the stall refuses a plan bigger than it can make', async ({ page }) => {
49
+ await page.goto(DEV_SERVER.bare(DEV_SERVER.playerUrl(1)));
50
+ const frame = page.frameLocator(DEV_SERVER.iframeSelector('ui'));
51
+
52
+ // Two people each changing their own item can put the merged plan over the
53
+ // stall's capacity, which is what the validator is for. One participant
54
+ // walking a single item past it reaches the same rule.
55
+ await runCommand({
56
+ type: 'updateContext',
57
+ teamIndex: 0,
58
+ payload: { plan: { Make: { lemonade: 100, icedtea: 15, cookies: 10 } } },
59
+ });
60
+ await expect(frame.getByTestId('capacity')).toHaveText('125 of 120');
61
+
62
+ // The merged plan is already over, so the next change from a surface is
63
+ // refused and says why.
64
+ await frame.getByRole('button', { name: 'Make more Cookies' }).click();
65
+
66
+ await expect(frame.getByText(/can make 120 things in a day/)).toBeVisible();
67
+ });
68
+
69
+ test('the stall opens its own day, and reports what happened', async ({ page }) => {
70
+ await page.goto(DEV_SERVER.bare(DEV_SERVER.playerUrl(1)));
71
+ const frame = page.frameLocator(DEV_SERVER.iframeSelector('ui'));
72
+ await expect(frame.getByTestId('day')).toHaveText('Day 1');
73
+
74
+ // The players run the day, not the facilitator. In independent mode a tick
75
+ // requested from a player surface advances that team and nobody else.
76
+ await frame.getByTestId('open-stall').click();
77
+
78
+ await expect(frame.getByTestId('day')).toHaveText('Day 2');
79
+ await expect(frame.getByTestId('line-lemonade')).toBeVisible();
80
+ await expect(frame.getByTestId('day-1')).toBeVisible();
81
+
82
+ const state = (await getState()) as { session: { completedTicks: number } };
83
+ expect(state.session.completedTicks).toBe(1);
84
+
85
+ // A second day: day 2 is live context, and day 1 now comes back from the
86
+ // host's history through getPastState rather than from context.
87
+ await frame.getByTestId('open-stall').click();
88
+ await expect(frame.getByTestId('day')).toHaveText('Day 3');
89
+ await expect(frame.getByTestId('day-2')).toBeVisible();
90
+ await expect(frame.getByTestId('day-1')).toBeVisible();
91
+ });
92
+
93
+ test('one stall opening up leaves the others where they were', async ({ page }) => {
94
+ await runCommand({ type: 'addTeam' });
95
+
96
+ await page.goto(DEV_SERVER.bare(DEV_SERVER.playerUrl(1)));
97
+ const frame = page.frameLocator(DEV_SERVER.iframeSelector('ui'));
98
+ await frame.getByTestId('open-stall').click();
99
+ await expect(frame.getByTestId('day')).toHaveText('Day 2');
100
+
101
+ // Asserted on the session rather than on a second browser tab: the claim is
102
+ // about which teams the host advanced, and reading it from the projection
103
+ // says so directly.
104
+ const state = (await getState()) as {
105
+ teams: { index: number; completedTicks: number }[];
106
+ };
107
+ const ticks = state.teams.map((team) => `${team.index}:${team.completedTicks}`);
108
+ expect(ticks[0]).toBe('0:1');
109
+ expect(ticks.slice(1)).not.toContain('1:1');
110
+ });
111
+
112
+ test('the stall can be switched to dark and back', async ({ page }) => {
113
+ await page.goto(DEV_SERVER.bare(DEV_SERVER.playerUrl(1)));
114
+ const frame = page.frameLocator(DEV_SERVER.iframeSelector('ui'));
115
+
116
+ await frame.getByRole('button', { name: 'Dark' }).click();
117
+ await expect(frame.getByRole('button', { name: 'Dark' })).toHaveAttribute(
118
+ 'aria-pressed',
119
+ 'true'
120
+ );
121
+
122
+ await frame.getByRole('button', { name: 'Light' }).click();
123
+ await expect(frame.getByRole('button', { name: 'Light' })).toHaveAttribute(
124
+ 'aria-pressed',
125
+ 'true'
126
+ );
127
+ });
128
+
129
+ test('the facilitator can reach a stall without joining it', async ({ page }) => {
130
+ await page.goto(DEV_SERVER.bare(DEV_SERVER.consoleUrl()));
131
+ const frame = page.frameLocator(DEV_SERVER.iframeSelector('session'));
132
+
133
+ const row = frame.getByTestId('team-team-1');
134
+ await expect(row.getByRole('link', { name: 'Observe' })).toHaveAttribute('target', '_blank');
135
+ await expect(row.getByRole('link', { name: 'Join' })).toHaveAttribute('target', '_blank');
136
+
137
+ // The facilitator watches; the stalls run their own days.
138
+ await expect(row.getByRole('button', { name: /Run/ })).toHaveCount(0);
139
+ });
@@ -0,0 +1,21 @@
1
+ import { dirname } from 'path';
2
+
3
+ import type { FullConfig } from '@playwright/test';
4
+ import { startDevServer } from '@seriousgaming/sdk-tools/client';
5
+
6
+ /**
7
+ * Start the dev server the browser tests run against, and stop it after them.
8
+ *
9
+ * It is the server `sdk dev --isolated --port auto` runs: on free ports, with
10
+ * no lock file, and configured from the package alone, so it starts beside
11
+ * your own dev server and every run begins from the same place. Running it in
12
+ * this process rather than spawning `sdk dev` means stopping it is a call,
13
+ * which a run cannot hang on.
14
+ */
15
+ export default async function globalSetup(config: FullConfig): Promise<() => Promise<void>> {
16
+ const server = await startDevServer({
17
+ packagePath: config.configFile ? dirname(config.configFile) : process.cwd(),
18
+ });
19
+ process.env.SERIOUSGAMING_DEV_URL = server.url;
20
+ return () => server.stop();
21
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Whole-game tests for Lemonade Stand.
3
+ *
4
+ * These run the game in-process through `createTestSession`: no dev server, no
5
+ * browser, no network. The session is the one `sdk dev` runs, so a rule about
6
+ * how the game plays over several days belongs here, as opposed to
7
+ * `engine/logic.test.ts`, which is about one day's arithmetic.
8
+ *
9
+ * When a browser test fails, run these first. If they pass, the fault is in the
10
+ * surface or the channel to it; if they fail too, it is in the game.
11
+ *
12
+ * Run with: pnpm test:engine
13
+ */
14
+
15
+ import { fileURLToPath } from 'url';
16
+
17
+ import { createTestSession, type TestSession } from '@seriousgaming/sdk-tools/client';
18
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
19
+
20
+ const PACKAGE_PATH = fileURLToPath(new URL('../..', import.meta.url));
21
+
22
+ type Context = Record<string, unknown>;
23
+
24
+ /** The context is untyped here, so each read says what it expects to find. */
25
+ const cap = (context: Context, key: string) => context[key] as Record<string, unknown>;
26
+
27
+ const numberField = (context: Context, key: string, field: string) =>
28
+ cap(context, key)[field] as number;
29
+
30
+ const mapField = (context: Context, key: string, field: string) =>
31
+ cap(context, key)[field] as Record<string, number>;
32
+
33
+ describe('Lemonade Stand', () => {
34
+ let game: TestSession;
35
+
36
+ beforeEach(async () => {
37
+ game = await createTestSession({ packagePath: PACKAGE_PATH });
38
+ });
39
+
40
+ afterEach(async () => {
41
+ await game.close();
42
+ });
43
+
44
+ /** The first stall's context, which these tests play. */
45
+ const stall = (): Context => {
46
+ const team = game.state().teams.find((candidate) => candidate.index === 0);
47
+ if (!team) throw new Error('The session has no first stall');
48
+ return team.context;
49
+ };
50
+
51
+ const day = () => game.state().session.completedTicks;
52
+
53
+ /** Open the stall for a day, and fail loudly if the session refused. */
54
+ const tick = async (): Promise<Context> => {
55
+ const result = await game.send({ type: 'tick' });
56
+ if (!result.ok) throw new Error(`The day was refused: ${JSON.stringify(result.outcome)}`);
57
+ return stall();
58
+ };
59
+
60
+ /** Decide what to make, as the stall's own surface would write it. */
61
+ const make = async (counts: Record<string, number>) => {
62
+ const result = await game.send({
63
+ type: 'updateContext',
64
+ teamIndex: 0,
65
+ payload: { plan: { Make: counts } },
66
+ });
67
+ expect(result.ok).toBe(true);
68
+ };
69
+
70
+ const lines = (context: Context) =>
71
+ cap(context, 'results').Lines as Record<string, Record<string, number>>;
72
+
73
+ it('starts every item on the menu at a number the stall can make', () => {
74
+ const plan = mapField(stall(), 'plan', 'Make');
75
+
76
+ expect(Object.keys(plan).sort()).toEqual(['cookies', 'icedtea', 'lemonade']);
77
+ expect(Object.values(plan).reduce((sum, count) => sum + count, 0)).toBeLessThanOrEqual(120);
78
+ });
79
+
80
+ it('opens with nothing traded and a forecast for the first day', () => {
81
+ expect(day()).toBe(0);
82
+ expect(numberField(stall(), 'results', 'Day')).toBe(0);
83
+ expect(['sunny', 'cloudy', 'rainy']).toContain(cap(stall(), 'weather').Today);
84
+ });
85
+
86
+ it('sells what it made, and turns the rest away', async () => {
87
+ await make({ lemonade: 1, icedtea: 0, cookies: 0 });
88
+ const context = await tick();
89
+
90
+ expect(lines(context).lemonade!.Sold).toBe(1);
91
+ expect(lines(context).lemonade!.PouredAway).toBe(0);
92
+ expect(lines(context).lemonade!.TurnedAway).toBeGreaterThan(0);
93
+ });
94
+
95
+ it('pours away what nobody wanted, having already paid for it', async () => {
96
+ await make({ lemonade: 0, icedtea: 0, cookies: 100 });
97
+ const context = await tick();
98
+
99
+ expect(lines(context).cookies!.PouredAway).toBeGreaterThan(0);
100
+ // 100 cookies at 25p, whatever the day did.
101
+ expect(numberField(context, 'results', 'Spent')).toBe(100 * 25);
102
+ expect(numberField(context, 'results', 'Profit')).toBeLessThan(0);
103
+ });
104
+
105
+ it('makes more money matching the forecast than ignoring it', async () => {
106
+ await make(mapField(stall(), 'weather', 'Expected'));
107
+ for (let round = 0; round < 5; round += 1) await tick();
108
+ const matched = numberField(stall(), 'results', 'Purse');
109
+
110
+ expect((await game.send({ type: 'reinitialise' })).ok).toBe(true);
111
+ await make({ lemonade: 0, icedtea: 0, cookies: 0 });
112
+ for (let round = 0; round < 5; round += 1) await tick();
113
+ const nothing = numberField(stall(), 'results', 'Purse');
114
+
115
+ expect(matched).toBeGreaterThan(nothing);
116
+ });
117
+
118
+ it('keeps a running purse across days', async () => {
119
+ await make(mapField(stall(), 'weather', 'Expected'));
120
+
121
+ const first = await tick();
122
+ const second = await tick();
123
+
124
+ expect(numberField(second, 'results', 'Purse')).toBe(
125
+ numberField(first, 'results', 'Profit') + numberField(second, 'results', 'Profit')
126
+ );
127
+ });
128
+
129
+ it('labels each day with its own number', async () => {
130
+ await tick();
131
+ await tick();
132
+ await tick();
133
+
134
+ expect(numberField(stall(), 'results', 'Day')).toBe(3);
135
+ });
136
+
137
+ it('plays the day it forecast, not a different one', async () => {
138
+ const forecastForDayOne = cap(stall(), 'weather').Today;
139
+
140
+ const context = await tick();
141
+
142
+ expect(cap(context, 'results').Weather).toBe(forecastForDayOne);
143
+ // And it has moved on to forecasting day two.
144
+ expect(['sunny', 'cloudy', 'rainy']).toContain(cap(context, 'weather').Today);
145
+ });
146
+
147
+ it('leaves the plan alone, so yesterday carries into today', async () => {
148
+ await make({ lemonade: 44, icedtea: 0, cookies: 0 });
149
+ const context = await tick();
150
+
151
+ expect(cap(context, 'plan').Make).toEqual({ lemonade: 44, icedtea: 0, cookies: 0 });
152
+ });
153
+
154
+ it('trades its ten days', async () => {
155
+ for (let round = 0; round < 10; round += 1) await tick();
156
+
157
+ expect(day()).toBe(10);
158
+ expect(numberField(stall(), 'results', 'Day')).toBe(10);
159
+ });
160
+
161
+ it('undo takes back the last day', async () => {
162
+ await tick();
163
+ expect(day()).toBe(1);
164
+
165
+ expect((await game.send({ type: 'undo' })).ok).toBe(true);
166
+
167
+ expect(day()).toBe(0);
168
+ expect(numberField(stall(), 'results', 'Purse')).toBe(0);
169
+ expect(numberField(stall(), 'results', 'Day')).toBe(0);
170
+ });
171
+ });
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "esnext",
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "esModuleInterop": true,
8
+ "noImplicitAny": true,
9
+ "sourceMap": true,
10
+ "noEmit": true,
11
+ "moduleResolution": "bundler",
12
+ "noUncheckedSideEffectImports": false,
13
+ "resolveJsonModule": true,
14
+ "jsx": "react-jsx",
15
+ "paths": {
16
+ "@seriousgaming/sdk": ["../../sdk"]
17
+ }
18
+ }
19
+ }
@@ -0,0 +1,60 @@
1
+ // This file is auto-generated from the schemas the resource declares: the values
2
+ // a session starts with, for use as test fixtures.
3
+ // Do not edit this file directly. It will be regenerated on build.
4
+
5
+ import type { Context, Settings } from './types';
6
+
7
+ export const defaultContext: Context = {
8
+ plan: {
9
+ Make: {},
10
+ },
11
+ weather: {
12
+ Today: 'sunny',
13
+ Expected: {},
14
+ },
15
+ results: {
16
+ Day: 0,
17
+ Weather: 'sunny',
18
+ Lines: {},
19
+ Takings: 0,
20
+ Spent: 0,
21
+ Profit: 0,
22
+ Purse: 0,
23
+ },
24
+ roles: {
25
+ LooksAfter: {},
26
+ },
27
+ briefing: {
28
+ Message: '',
29
+ },
30
+ };
31
+
32
+ export const defaultSettings: Settings = {
33
+ menu: {
34
+ Capacity: 120,
35
+ Menu: {
36
+ lemonade: {
37
+ Name: 'Lemonade',
38
+ Order: 1,
39
+ Cost: 15,
40
+ Price: 50,
41
+ },
42
+ icedtea: {
43
+ Name: 'Iced tea',
44
+ Order: 2,
45
+ Cost: 20,
46
+ Price: 60,
47
+ },
48
+ cookies: {
49
+ Name: 'Cookies',
50
+ Order: 3,
51
+ Cost: 25,
52
+ Price: 70,
53
+ },
54
+ },
55
+ },
56
+ ui: {
57
+ Step: 5,
58
+ CurrencySymbol: '£',
59
+ },
60
+ };
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Lemonade Stand</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="./src/main.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,138 @@
1
+ import { createMockParticipant, renderUserInterface } from '@seriousgaming/sdk/ui/testing';
2
+ import { fireEvent, screen } from '@testing-library/react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+
5
+ import App from './App';
6
+ import './i18n';
7
+ import { baseSettings, baseContext, playedDay } from './test-helpers';
8
+
9
+ const ada = createMockParticipant({ id: 'participant-1', name: 'Ada' });
10
+
11
+ const render = (options: Parameters<typeof renderUserInterface>[1] = {}) =>
12
+ renderUserInterface(<App />, {
13
+ data: baseContext,
14
+ settings: baseSettings,
15
+ participant: ada,
16
+ ...options,
17
+ });
18
+
19
+ describe('the screen', () => {
20
+ // One screen, top to bottom: what we are making, how yesterday went, every
21
+ // day so far. No tabs, because there is nothing here worth hiding behind one.
22
+ it('shows today, yesterday and the days before it at once', () => {
23
+ render({ data: playedDay });
24
+
25
+ expect(screen.getByText('What shall we make today?')).toBeInTheDocument();
26
+ expect(screen.getByText('How yesterday went')).toBeInTheDocument();
27
+ expect(screen.getByText('Every day so far')).toBeInTheDocument();
28
+ });
29
+
30
+ it('leaves the history out until there is any', () => {
31
+ render();
32
+
33
+ expect(screen.queryByText('Every day so far')).not.toBeInTheDocument();
34
+ });
35
+
36
+ it('counts the day the stall is about to trade, not the one it finished', () => {
37
+ render({ completedTicks: 3 });
38
+
39
+ expect(screen.getByTestId('day')).toHaveTextContent('Day 4');
40
+ });
41
+
42
+ it('says what the weather is doing', () => {
43
+ render();
44
+
45
+ expect(screen.getByText(/Sunny today/)).toBeInTheDocument();
46
+ });
47
+
48
+ it('says who looks after what', () => {
49
+ render({
50
+ data: { ...baseContext, roles: { LooksAfter: { 'participant-1': 'icedtea' } } },
51
+ });
52
+
53
+ expect(screen.getByText('You look after the Iced tea')).toBeInTheDocument();
54
+ });
55
+
56
+ it('says when a day is being played out', () => {
57
+ render({ isRoundProcessing: true });
58
+
59
+ expect(screen.getByText('The day is being played out')).toBeInTheDocument();
60
+ });
61
+
62
+ it('carries the facilitator’s notice', () => {
63
+ render({ data: { ...baseContext, briefing: { Message: 'Ten minutes left' } } });
64
+
65
+ expect(screen.getByText('Ten minutes left')).toBeInTheDocument();
66
+ });
67
+ });
68
+
69
+ describe('yesterday', () => {
70
+ it('waits for the first day rather than showing zeroes', () => {
71
+ render();
72
+
73
+ expect(
74
+ screen.getByText(/the stall opens when the facilitator says so/)
75
+ ).toBeInTheDocument();
76
+ });
77
+
78
+ it('reports what was poured away and who was turned away', () => {
79
+ render({ data: playedDay });
80
+
81
+ // Five iced teas made and poured away; forty-two people who wanted
82
+ // something and did not get it. The two mistakes look nothing alike and
83
+ // cost differently, so the screen names them separately.
84
+ expect(screen.getByTestId('stat-pouredaway')).toHaveTextContent('5');
85
+ expect(screen.getByTestId('stat-turnedaway')).toHaveTextContent('42');
86
+ expect(screen.getByTestId('stat-profit')).toHaveTextContent('£23.50');
87
+ });
88
+ });
89
+
90
+ describe('light and dark', () => {
91
+ it('asks the host, so the whole session follows rather than one frame', () => {
92
+ const setThemePreference = vi.fn();
93
+ render({ themePreference: 'light', callbacks: { setThemePreference } });
94
+
95
+ fireEvent.click(screen.getByRole('button', { name: 'Dark' }));
96
+
97
+ expect(setThemePreference).toHaveBeenCalledWith('dark');
98
+ });
99
+
100
+ it('shows which one is on', () => {
101
+ render({ themePreference: 'dark' });
102
+
103
+ expect(screen.getByRole('button', { name: 'Dark' })).toHaveAttribute(
104
+ 'aria-pressed',
105
+ 'true'
106
+ );
107
+ });
108
+
109
+ it('does not leave the control in no state at all', () => {
110
+ const setThemePreference = vi.fn();
111
+ render({ themePreference: 'dark', callbacks: { setThemePreference } });
112
+
113
+ // Pressing the one already selected hands MUI a null value.
114
+ fireEvent.click(screen.getByRole('button', { name: 'Dark' }));
115
+
116
+ expect(setThemePreference).not.toHaveBeenCalled();
117
+ });
118
+ });
119
+
120
+ describe('when a write is refused', () => {
121
+ /**
122
+ * A validator refusal arrives twice: as a mutation outcome, and through the
123
+ * rejection channel, which carries the validator's own message. Only the
124
+ * second is worth showing, and this is what holds that choice in place.
125
+ */
126
+ it('shows the validator’s own reason', () => {
127
+ render({
128
+ validationRejections: [
129
+ {
130
+ validatorIdentifier: 'capacity',
131
+ message: 'The stall can make 120 things in a day, and that plan asks for 150.',
132
+ },
133
+ ],
134
+ });
135
+
136
+ expect(screen.getByText(/that plan asks for 150/)).toBeInTheDocument();
137
+ });
138
+ });