@polderlabs/bizar 6.3.0 → 7.0.1

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 (264) hide show
  1. package/.claude/hooks/sessionend-recall.mjs +3 -3
  2. package/.claude/hooks/worker-suggest.mjs +110 -0
  3. package/.claude/settings.json +9 -0
  4. package/README.md +125 -47
  5. package/bizar-dash/dist/assets/{EnvVarsSection-B58aiJiE.js → EnvVarsSection-weVnLz3Z.js} +3 -3
  6. package/bizar-dash/dist/assets/EnvVarsSection-weVnLz3Z.js.map +1 -0
  7. package/bizar-dash/dist/assets/MobileChat-BRFjer8p.js +1 -0
  8. package/bizar-dash/dist/assets/{MobileChat-BJrqwVDd.js.map → MobileChat-BRFjer8p.js.map} +1 -1
  9. package/bizar-dash/dist/assets/MobileSettings-Dw5jyJWi.js +1 -0
  10. package/bizar-dash/dist/assets/MobileSettings-Dw5jyJWi.js.map +1 -0
  11. package/bizar-dash/dist/assets/{Toast-Cpl6-E63.js → Toast-WTxc3m2g.js} +1 -1
  12. package/bizar-dash/dist/assets/{Toast-Cpl6-E63.js.map → Toast-WTxc3m2g.js.map} +1 -1
  13. package/bizar-dash/dist/assets/flow-BpU0-gK2.js +1 -0
  14. package/bizar-dash/dist/assets/flow-BpU0-gK2.js.map +1 -0
  15. package/bizar-dash/dist/assets/fuzzy-l0sNRNKZ.js +1 -0
  16. package/bizar-dash/dist/assets/fuzzy-l0sNRNKZ.js.map +1 -0
  17. package/bizar-dash/dist/assets/{icons-Do5N2jmX.js → icons-B0AKnYI3.js} +98 -103
  18. package/bizar-dash/dist/assets/icons-B0AKnYI3.js.map +1 -0
  19. package/bizar-dash/dist/assets/main-BE4Uh9im.css +1 -0
  20. package/bizar-dash/dist/assets/main-DqC58FEZ.js +18 -0
  21. package/bizar-dash/dist/assets/main-DqC58FEZ.js.map +1 -0
  22. package/bizar-dash/dist/assets/markdown-w2bDDkAH.js +1 -0
  23. package/bizar-dash/dist/assets/markdown-w2bDDkAH.js.map +1 -0
  24. package/bizar-dash/dist/assets/mobile-DRZsK3ic.js +1 -0
  25. package/bizar-dash/dist/assets/{mobile-DYCHcUpq.js.map → mobile-DRZsK3ic.js.map} +1 -1
  26. package/bizar-dash/dist/assets/mobile-layout-BWLtwtfQ.css +1 -0
  27. package/bizar-dash/dist/assets/mobile-layout-Ckru3HKJ.js +2 -0
  28. package/bizar-dash/dist/assets/mobile-layout-Ckru3HKJ.js.map +1 -0
  29. package/bizar-dash/dist/assets/react-vendor-BnuhLJ6X.css +1 -0
  30. package/bizar-dash/dist/assets/react-vendor-D3pra6w-.js +46 -0
  31. package/bizar-dash/dist/assets/react-vendor-D3pra6w-.js.map +1 -0
  32. package/bizar-dash/dist/assets/useSlashCommands-BAxVOU_a.js +2 -0
  33. package/bizar-dash/dist/assets/useSlashCommands-BAxVOU_a.js.map +1 -0
  34. package/bizar-dash/dist/assets/vendor-Di7Cp-rw.js +45 -0
  35. package/bizar-dash/dist/assets/vendor-Di7Cp-rw.js.map +1 -0
  36. package/bizar-dash/dist/index.html +13 -11
  37. package/bizar-dash/dist/mobile.html +8 -5
  38. package/bizar-dash/src/server/api.mjs +11 -0
  39. package/bizar-dash/src/server/memory-consolidator.mjs +225 -0
  40. package/bizar-dash/src/server/memory-distillation.mjs +296 -0
  41. package/bizar-dash/src/server/routes/distill.mjs +129 -0
  42. package/bizar-dash/src/server/routes/goal-planner.mjs +236 -0
  43. package/bizar-dash/src/web/App.tsx +4 -0
  44. package/bizar-dash/src/web/components/Tag.tsx +18 -5
  45. package/bizar-dash/src/web/components/Topbar.tsx +3 -0
  46. package/bizar-dash/src/web/components/agents/CommunicationLog.tsx +126 -0
  47. package/bizar-dash/src/web/components/agents/DependencyGraph.tsx +163 -0
  48. package/bizar-dash/src/web/components/agents/QualityGates.tsx +123 -0
  49. package/bizar-dash/src/web/components/agents/RealTimeEventLog.tsx +127 -0
  50. package/bizar-dash/src/web/components/agents/RoutingDecisions.test.tsx +148 -0
  51. package/bizar-dash/src/web/components/agents/RoutingDecisions.tsx +160 -0
  52. package/bizar-dash/src/web/components/goals/GoalInput.tsx +86 -0
  53. package/bizar-dash/src/web/components/goals/PlanVisualization.tsx +111 -0
  54. package/bizar-dash/src/web/lib/goapPlanner.ts +614 -0
  55. package/bizar-dash/src/web/lib/types.ts +25 -0
  56. package/bizar-dash/src/web/mobile/MobileApp.tsx +33 -2
  57. package/bizar-dash/src/web/mobile/MobileBottomNav.tsx +2 -2
  58. package/bizar-dash/src/web/mobile/MobileEval.tsx +4 -4
  59. package/bizar-dash/src/web/mobile/MobileMemory.tsx +2 -2
  60. package/bizar-dash/src/web/mobile/MobileSettings.tsx +3 -3
  61. package/bizar-dash/src/web/mobile/MobileTasks.tsx +2 -2
  62. package/bizar-dash/src/web/mobile/components/MobileListItem.tsx +3 -3
  63. package/bizar-dash/src/web/styles/agents-redesign.css +311 -0
  64. package/bizar-dash/src/web/styles/main.css +251 -0
  65. package/bizar-dash/src/web/styles/mobile.css +41 -0
  66. package/bizar-dash/src/web/styles/tasks-redesign.css +253 -0
  67. package/bizar-dash/src/web/ui/controls/Button.tsx +77 -0
  68. package/bizar-dash/src/web/ui/controls/Checkbox.tsx +95 -0
  69. package/bizar-dash/src/web/ui/controls/IconButton.tsx +66 -0
  70. package/bizar-dash/src/web/ui/controls/Kbd.tsx +24 -0
  71. package/bizar-dash/src/web/ui/controls/NumberInput.tsx +77 -0
  72. package/bizar-dash/src/web/ui/controls/RadioGroup.tsx +109 -0
  73. package/bizar-dash/src/web/ui/controls/SearchInput.tsx +91 -0
  74. package/bizar-dash/src/web/ui/controls/Select.tsx +92 -0
  75. package/bizar-dash/src/web/ui/controls/Slider.tsx +70 -0
  76. package/bizar-dash/src/web/ui/controls/TextInput.tsx +67 -0
  77. package/bizar-dash/src/web/ui/controls/Textarea.tsx +56 -0
  78. package/bizar-dash/src/web/ui/controls/Toggle.tsx +79 -0
  79. package/bizar-dash/src/web/ui/controls/controls.css +476 -0
  80. package/bizar-dash/src/web/ui/controls/index.ts +35 -0
  81. package/bizar-dash/src/web/ui/data/BarChart.tsx +60 -0
  82. package/bizar-dash/src/web/ui/data/DataTable.tsx +216 -0
  83. package/bizar-dash/src/web/ui/data/EmptyState.tsx +60 -0
  84. package/bizar-dash/src/web/ui/data/ErrorState.tsx +56 -0
  85. package/bizar-dash/src/web/ui/data/KeyValueList.tsx +106 -0
  86. package/bizar-dash/src/web/ui/data/LoadingState.tsx +65 -0
  87. package/bizar-dash/src/web/ui/data/Sparkline.tsx +87 -0
  88. package/bizar-dash/src/web/ui/data/StatTile.tsx +87 -0
  89. package/bizar-dash/src/web/ui/data/data.css +535 -0
  90. package/bizar-dash/src/web/ui/data/index.ts +31 -0
  91. package/bizar-dash/src/web/ui/feedback/Badge.tsx +49 -0
  92. package/bizar-dash/src/web/ui/feedback/Dialog.tsx +209 -0
  93. package/bizar-dash/src/web/ui/feedback/ProgressBar.tsx +69 -0
  94. package/bizar-dash/src/web/ui/feedback/StatusDot.tsx +50 -0
  95. package/bizar-dash/src/web/ui/feedback/Toast.tsx +200 -0
  96. package/bizar-dash/src/web/ui/feedback/Tooltip.tsx +126 -0
  97. package/bizar-dash/src/web/ui/feedback/feedback.css +314 -0
  98. package/bizar-dash/src/web/ui/feedback/index.ts +43 -0
  99. package/bizar-dash/src/web/ui/index.ts +56 -0
  100. package/bizar-dash/src/web/ui/layout/AppShell.tsx +51 -0
  101. package/bizar-dash/src/web/ui/layout/Breadcrumbs.tsx +97 -0
  102. package/bizar-dash/src/web/ui/layout/Panel.tsx +73 -0
  103. package/bizar-dash/src/web/ui/layout/PanelHeader.tsx +39 -0
  104. package/bizar-dash/src/web/ui/layout/Sidebar.tsx +131 -0
  105. package/bizar-dash/src/web/ui/layout/Tabs.tsx +133 -0
  106. package/bizar-dash/src/web/ui/layout/Topbar.tsx +42 -0
  107. package/bizar-dash/src/web/ui/layout/ViewHeader.tsx +48 -0
  108. package/bizar-dash/src/web/ui/layout/index.ts +34 -0
  109. package/bizar-dash/src/web/ui/layout/layout.css +464 -0
  110. package/bizar-dash/src/web/ui/navigation/CommandPalette.tsx +199 -0
  111. package/bizar-dash/src/web/ui/navigation/NavGroup.tsx +95 -0
  112. package/bizar-dash/src/web/ui/navigation/NavLink.tsx +93 -0
  113. package/bizar-dash/src/web/ui/navigation/index.ts +17 -0
  114. package/bizar-dash/src/web/ui/navigation/navigation.css +343 -0
  115. package/bizar-dash/src/web/ui/primitives/Box.tsx +91 -0
  116. package/bizar-dash/src/web/ui/primitives/Grid.tsx +49 -0
  117. package/bizar-dash/src/web/ui/primitives/Inline.tsx +55 -0
  118. package/bizar-dash/src/web/ui/primitives/Separator.tsx +46 -0
  119. package/bizar-dash/src/web/ui/primitives/Stack.tsx +54 -0
  120. package/bizar-dash/src/web/ui/primitives/VisuallyHidden.tsx +26 -0
  121. package/bizar-dash/src/web/ui/primitives/index.ts +23 -0
  122. package/bizar-dash/src/web/ui/primitives/primitives.css +183 -0
  123. package/bizar-dash/src/web/ui/styles/globals.css +86 -0
  124. package/bizar-dash/src/web/ui/styles/reset.css +125 -0
  125. package/bizar-dash/src/web/ui/styles/tokens.css +208 -0
  126. package/bizar-dash/src/web/ui/theme/ThemeProvider.tsx +142 -0
  127. package/bizar-dash/src/web/ui/theme/tokens.ts +131 -0
  128. package/bizar-dash/src/web/ui/theme/useTheme.ts +22 -0
  129. package/bizar-dash/src/web/ui/utils/cx.ts +42 -0
  130. package/bizar-dash/src/web/views/Agents.tsx +516 -268
  131. package/bizar-dash/src/web/views/GoalPlanner.tsx +158 -0
  132. package/bizar-dash/src/web/views/Overview.tsx +985 -448
  133. package/bizar-dash/src/web/views/Tasks.tsx +271 -273
  134. package/bizar-dash/tests/communication-log.test.tsx +67 -0
  135. package/bizar-dash/tests/dep-graph.test.tsx +40 -0
  136. package/bizar-dash/tests/event-log.test.tsx +61 -0
  137. package/bizar-dash/tests/goal-input.test.tsx +72 -0
  138. package/bizar-dash/tests/goap-planner.test.ts +68 -0
  139. package/bizar-dash/tests/quality-gates.test.tsx +59 -0
  140. package/bizar-dash/tests/setup.ts +31 -1
  141. package/bizar-dash/tests/ui/controls/Button.test.tsx +50 -0
  142. package/bizar-dash/tests/ui/controls/Checkbox.test.tsx +44 -0
  143. package/bizar-dash/tests/ui/controls/IconButton.test.tsx +37 -0
  144. package/bizar-dash/tests/ui/controls/Kbd.test.tsx +39 -0
  145. package/bizar-dash/tests/ui/controls/NumberInput.test.tsx +56 -0
  146. package/bizar-dash/tests/ui/controls/RadioGroup.test.tsx +58 -0
  147. package/bizar-dash/tests/ui/controls/SearchInput.test.tsx +52 -0
  148. package/bizar-dash/tests/ui/controls/Select.test.tsx +66 -0
  149. package/bizar-dash/tests/ui/controls/Slider.test.tsx +41 -0
  150. package/bizar-dash/tests/ui/controls/TextInput.test.tsx +44 -0
  151. package/bizar-dash/tests/ui/controls/Textarea.test.tsx +56 -0
  152. package/bizar-dash/tests/ui/controls/Toggle.test.tsx +39 -0
  153. package/bizar-dash/tests/ui/data/BarChart.test.tsx +55 -0
  154. package/bizar-dash/tests/ui/data/DataTable.test.tsx +112 -0
  155. package/bizar-dash/tests/ui/data/EmptyState.test.tsx +57 -0
  156. package/bizar-dash/tests/ui/data/ErrorState.test.tsx +47 -0
  157. package/bizar-dash/tests/ui/data/KeyValueList.test.tsx +59 -0
  158. package/bizar-dash/tests/ui/data/LoadingState.test.tsx +37 -0
  159. package/bizar-dash/tests/ui/data/Sparkline.test.tsx +56 -0
  160. package/bizar-dash/tests/ui/data/StatTile.test.tsx +69 -0
  161. package/bizar-dash/tests/ui/feedback/Badge.test.tsx +41 -0
  162. package/bizar-dash/tests/ui/feedback/Dialog.test.tsx +99 -0
  163. package/bizar-dash/tests/ui/feedback/ProgressBar.test.tsx +66 -0
  164. package/bizar-dash/tests/ui/feedback/StatusDot.test.tsx +41 -0
  165. package/bizar-dash/tests/ui/feedback/Toast.test.tsx +124 -0
  166. package/bizar-dash/tests/ui/feedback/Tooltip.test.tsx +102 -0
  167. package/bizar-dash/tests/ui/layout/AppShell.test.tsx +50 -0
  168. package/bizar-dash/tests/ui/layout/Breadcrumbs.test.tsx +85 -0
  169. package/bizar-dash/tests/ui/layout/Panel.test.tsx +52 -0
  170. package/bizar-dash/tests/ui/layout/PanelHeader.test.tsx +32 -0
  171. package/bizar-dash/tests/ui/layout/Sidebar.test.tsx +95 -0
  172. package/bizar-dash/tests/ui/layout/Tabs.test.tsx +87 -0
  173. package/bizar-dash/tests/ui/layout/Topbar.test.tsx +41 -0
  174. package/bizar-dash/tests/ui/layout/ViewHeader.test.tsx +47 -0
  175. package/bizar-dash/tests/ui/navigation/CommandPalette.test.tsx +105 -0
  176. package/bizar-dash/tests/ui/navigation/NavGroup.test.tsx +55 -0
  177. package/bizar-dash/tests/ui/navigation/NavLink.test.tsx +61 -0
  178. package/bizar-dash/tests/ui/primitives/Box.test.tsx +49 -0
  179. package/bizar-dash/tests/ui/primitives/Grid.test.tsx +37 -0
  180. package/bizar-dash/tests/ui/primitives/Inline.test.tsx +33 -0
  181. package/bizar-dash/tests/ui/primitives/Separator.test.tsx +34 -0
  182. package/bizar-dash/tests/ui/primitives/Stack.test.tsx +45 -0
  183. package/bizar-dash/tests/ui/primitives/VisuallyHidden.test.tsx +27 -0
  184. package/bizar-dash/tests/views/Agents.test.tsx +359 -0
  185. package/bizar-dash/tests/views/Overview.test.tsx +413 -0
  186. package/bizar-dash/tests/views/Tasks.test.tsx +250 -0
  187. package/bizar-dash/vitest.config.ts +3 -1
  188. package/cli/__tests__/cost-gate.test.mjs +301 -0
  189. package/cli/__tests__/feature-list-bridge.test.mjs +371 -0
  190. package/cli/bin.mjs +38 -0
  191. package/cli/commands/claim.mjs +282 -0
  192. package/cli/commands/cost.mjs +279 -0
  193. package/cli/cost-gate.mjs +501 -0
  194. package/cli/feature-list-bridge.mjs +400 -0
  195. package/cli/memory-constants.mjs +17 -0
  196. package/cli/provision-claude.mjs +94 -0
  197. package/cli/worker-dispatcher.mjs +259 -0
  198. package/cli/worker-dispatcher.test.mjs +204 -0
  199. package/config/trigger-patterns.json +186 -0
  200. package/package.json +2 -2
  201. package/packages/sdk/package.json +1 -1
  202. package/packages/sdk/src/agent-registry.ts +304 -0
  203. package/packages/sdk/src/consensus/byzantine.ts +587 -0
  204. package/packages/sdk/src/consensus/index.ts +147 -0
  205. package/packages/sdk/src/consensus/queen.ts +173 -0
  206. package/packages/sdk/src/consensus/types.ts +199 -0
  207. package/packages/sdk/src/federation/audit.ts +156 -0
  208. package/packages/sdk/src/federation/budget.ts +269 -0
  209. package/packages/sdk/src/federation/envelope.ts +144 -0
  210. package/packages/sdk/src/federation/hmac.ts +160 -0
  211. package/packages/sdk/src/federation/index.ts +443 -0
  212. package/packages/sdk/src/federation/pii.ts +241 -0
  213. package/packages/sdk/src/federation/policy.ts +112 -0
  214. package/packages/sdk/src/federation/trust.ts +146 -0
  215. package/packages/sdk/src/index.ts +67 -0
  216. package/packages/sdk/src/mcp/server.ts +351 -1
  217. package/packages/sdk/src/router/codemod-intent.ts +125 -0
  218. package/packages/sdk/src/router/index.ts +247 -0
  219. package/packages/sdk/src/router/memory-distillation-shim.mjs +18 -0
  220. package/packages/sdk/src/router/memory-distillation.ts +249 -0
  221. package/packages/sdk/src/router/model-router.ts +235 -0
  222. package/packages/sdk/src/router/q-learning-router.ts +304 -0
  223. package/packages/sdk/src/swarm-topology.ts +279 -0
  224. package/packages/sdk/tests/agent-registry.test.ts +285 -0
  225. package/packages/sdk/tests/consensus.test.ts +376 -0
  226. package/packages/sdk/tests/federation/audit.test.ts +164 -0
  227. package/packages/sdk/tests/federation/budget.test.ts +189 -0
  228. package/packages/sdk/tests/federation/envelope.test.ts +120 -0
  229. package/packages/sdk/tests/federation/hmac.test.ts +194 -0
  230. package/packages/sdk/tests/federation/orchestrator.test.ts +176 -0
  231. package/packages/sdk/tests/federation/pii.test.ts +106 -0
  232. package/packages/sdk/tests/federation/policy.test.ts +140 -0
  233. package/packages/sdk/tests/federation/trust.test.ts +124 -0
  234. package/packages/sdk/tests/mcp-tools.test.ts +204 -0
  235. package/packages/sdk/tests/memory-distillation.test.mjs +186 -0
  236. package/packages/sdk/tests/model-router.test.mjs +108 -0
  237. package/packages/sdk/tests/q-learning-router.test.mjs +100 -0
  238. package/packages/sdk/tests/router-orchestrator.test.mjs +172 -0
  239. package/packages/sdk/tests/router.test.mjs +91 -0
  240. package/packages/sdk/tests/sdk.test.mjs +26 -2
  241. package/packages/sdk/tests/swarm-topology.test.ts +258 -0
  242. package/scripts/bh-full-e2e.mjs +11 -15
  243. package/bizar-dash/dist/assets/EnvVarsSection-B58aiJiE.js.map +0 -1
  244. package/bizar-dash/dist/assets/MobileChat-BJrqwVDd.js +0 -1
  245. package/bizar-dash/dist/assets/MobileSettings-CEQNJNLJ.js +0 -1
  246. package/bizar-dash/dist/assets/MobileSettings-CEQNJNLJ.js.map +0 -1
  247. package/bizar-dash/dist/assets/icons-Do5N2jmX.js.map +0 -1
  248. package/bizar-dash/dist/assets/main-IvfQAOfy.js +0 -18
  249. package/bizar-dash/dist/assets/main-IvfQAOfy.js.map +0 -1
  250. package/bizar-dash/dist/assets/main-ietCEg_R.css +0 -1
  251. package/bizar-dash/dist/assets/markdown-tOLaD6nm.js +0 -1
  252. package/bizar-dash/dist/assets/markdown-tOLaD6nm.js.map +0 -1
  253. package/bizar-dash/dist/assets/mobile-DYCHcUpq.js +0 -1
  254. package/bizar-dash/dist/assets/mobile-layout-CBHjpwsb.js +0 -2
  255. package/bizar-dash/dist/assets/mobile-layout-CBHjpwsb.js.map +0 -1
  256. package/bizar-dash/dist/assets/mobile-layout-CJnZNLy3.css +0 -1
  257. package/bizar-dash/dist/assets/react-vendor-Dn4wqh4Z.js +0 -40
  258. package/bizar-dash/dist/assets/react-vendor-Dn4wqh4Z.js.map +0 -1
  259. package/bizar-dash/dist/assets/useSlashCommands-Bd7_FA6U.js +0 -2
  260. package/bizar-dash/dist/assets/useSlashCommands-Bd7_FA6U.js.map +0 -1
  261. package/bizar-dash/dist/assets/vendor-C843201K.js +0 -29
  262. package/bizar-dash/dist/assets/vendor-C843201K.js.map +0 -1
  263. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  264. package/packages/sdk/package-lock.json +0 -32
@@ -0,0 +1,67 @@
1
+ // tests/communication-log.test.tsx — F-036 Goal Planner UI.
2
+ // Renders the CommunicationLog component with a mocked Ws class so
3
+ // we can drive agent:* events through the handler without opening a
4
+ // real socket.
5
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
6
+ import { render, screen, act } from '@testing-library/react';
7
+ import { ToastProvider } from '../src/web/components/Toast';
8
+ import { CommunicationLog } from '../src/web/components/agents/CommunicationLog';
9
+
10
+ type Handler = (msg: { type: string; ts?: number } & Record<string, unknown>) => void;
11
+
12
+ let registeredHandler: Handler | null = null;
13
+
14
+ vi.mock('../src/web/lib/ws', () => ({
15
+ Ws: class {
16
+ constructor() {}
17
+ on(handler: Handler) {
18
+ registeredHandler = handler;
19
+ return () => { registeredHandler = null; };
20
+ }
21
+ close() {}
22
+ },
23
+ }));
24
+
25
+ function wrap(node: React.ReactNode) {
26
+ return <ToastProvider>{node}</ToastProvider>;
27
+ }
28
+
29
+ function emit(type: string, payload: Record<string, unknown> = {}) {
30
+ if (!registeredHandler) throw new Error('no ws handler registered');
31
+ act(() => {
32
+ registeredHandler!({ type, ts: Date.now(), ...payload });
33
+ });
34
+ }
35
+
36
+ beforeEach(() => {
37
+ registeredHandler = null;
38
+ });
39
+
40
+ describe('CommunicationLog', () => {
41
+ it('renders empty state when no messages have arrived', () => {
42
+ render(wrap(<CommunicationLog />));
43
+ expect(screen.getByTestId('communication-log-empty')).toBeInTheDocument();
44
+ expect(screen.getByTestId('communication-log-count').textContent?.trim()).toBe('0');
45
+ });
46
+
47
+ it('renders a single agent:status message', () => {
48
+ render(wrap(<CommunicationLog />));
49
+ emit('agent:status', { agent: { name: 'Odin', status: 'working' } });
50
+ expect(screen.getByTestId('communication-log-count').textContent?.trim()).toBe('1');
51
+ const rows = screen.getAllByTestId('communication-log-row');
52
+ expect(rows.length).toBe(1);
53
+ expect(rows[0].getAttribute('data-kind')).toBe('status');
54
+ });
55
+
56
+ it('renders multiple agent:* messages in reverse order', () => {
57
+ render(wrap(<CommunicationLog />));
58
+ emit('agent:status', { agent: { name: 'Odin', status: 'working' } });
59
+ emit('agent:restarted', { agent: { name: 'Thor' } });
60
+ emit('agent:message', { message: { from: 'Thor', to: 'Odin', text: 'done' } });
61
+ const rows = screen.getAllByTestId('communication-log-row');
62
+ expect(rows.length).toBe(3);
63
+ expect(rows[0].getAttribute('data-kind')).toBe('message');
64
+ expect(rows[1].getAttribute('data-kind')).toBe('restarted');
65
+ expect(rows[2].getAttribute('data-kind')).toBe('status');
66
+ });
67
+ });
@@ -0,0 +1,40 @@
1
+ // tests/dep-graph.test.tsx — F-036 Goal Planner UI.
2
+ // Renders the DependencyGraph component with and without a plan.
3
+ import { describe, it, expect } from 'vitest';
4
+ import { render, screen } from '@testing-library/react';
5
+ import { DependencyGraph } from '../src/web/components/agents/DependencyGraph';
6
+ import type { Plan } from '../src/web/lib/goapPlanner';
7
+
8
+ function makePlan(): Plan {
9
+ return {
10
+ id: 'plan_x',
11
+ goal: 'Build it, test it, deploy it.',
12
+ steps: [
13
+ { id: 's1', action: 'build', title: 'Build the artifact', agent: 'Thor', description: 'Construct the artifact end-to-end.', effects: ['artifact_built'], deps: [], status: 'pending', estimatedCost: 3, estimatedDurationMs: 120000 },
14
+ { id: 's2', action: 'test', title: 'Run the tests', agent: 'Forseti', description: 'Run the test suite.', effects: ['tests_passing'], deps: ['s1'], status: 'pending', estimatedCost: 2, estimatedDurationMs: 60000 },
15
+ { id: 's3', action: 'deploy', title: 'Deploy to production', agent: 'Heimdall', description: 'Promote the artifact to production.', effects: ['deployed'], deps: ['s2'], status: 'pending', estimatedCost: 2, estimatedDurationMs: 60000 },
16
+ ],
17
+ totalCost: 7,
18
+ totalDurationMs: 240000,
19
+ goalEffects: ['artifact_built', 'tests_passing', 'deployed'],
20
+ };
21
+ }
22
+
23
+ describe('DependencyGraph', () => {
24
+ it('renders an empty-state card when no plan is supplied', () => {
25
+ render(<DependencyGraph plan={null} />);
26
+ expect(screen.getByTestId('dep-graph')).toBeInTheDocument();
27
+ expect(screen.getByText(/Submit a goal/i)).toBeInTheDocument();
28
+ });
29
+
30
+ it('renders the canvas when a plan is supplied', () => {
31
+ render(<DependencyGraph plan={makePlan()} />);
32
+ expect(screen.getByTestId('dep-graph-canvas')).toBeInTheDocument();
33
+ });
34
+
35
+ it('renders three step nodes when a three-step plan is supplied', () => {
36
+ render(<DependencyGraph plan={makePlan()} />);
37
+ const nodes = document.querySelectorAll('.dep-node');
38
+ expect(nodes.length).toBe(3);
39
+ });
40
+ });
@@ -0,0 +1,61 @@
1
+ // tests/event-log.test.tsx — F-036 Goal Planner UI.
2
+ // Renders the RealTimeEventLog with a mocked Ws class.
3
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
4
+ import { render, screen, act } from '@testing-library/react';
5
+ import { ToastProvider } from '../src/web/components/Toast';
6
+ import { RealTimeEventLog } from '../src/web/components/agents/RealTimeEventLog';
7
+
8
+ type Handler = (msg: { type: string; ts?: number } & Record<string, unknown>) => void;
9
+ let registeredHandler: Handler | null = null;
10
+
11
+ vi.mock('../src/web/lib/ws', () => ({
12
+ Ws: class {
13
+ constructor() {}
14
+ on(handler: Handler) {
15
+ registeredHandler = handler;
16
+ return () => { registeredHandler = null; };
17
+ }
18
+ close() {}
19
+ },
20
+ }));
21
+
22
+ function wrap(node: React.ReactNode) {
23
+ return <ToastProvider>{node}</ToastProvider>;
24
+ }
25
+
26
+ function emit(type: string, payload: Record<string, unknown> = {}) {
27
+ if (!registeredHandler) throw new Error('no ws handler registered');
28
+ act(() => {
29
+ registeredHandler!({ type, ts: Date.now(), ...payload });
30
+ });
31
+ }
32
+
33
+ beforeEach(() => {
34
+ registeredHandler = null;
35
+ });
36
+
37
+ describe('RealTimeEventLog', () => {
38
+ it('renders empty state when no events arrived', () => {
39
+ render(wrap(<RealTimeEventLog />));
40
+ expect(screen.getByTestId('event-log-empty')).toBeInTheDocument();
41
+ expect(screen.getByTestId('event-log-count').textContent?.trim()).toBe('0');
42
+ });
43
+
44
+ it('renders a single info event', () => {
45
+ render(wrap(<RealTimeEventLog />));
46
+ emit('tasks:change', { task: { id: 't1' } });
47
+ const rows = screen.getAllByTestId('event-log-row');
48
+ expect(rows.length).toBe(1);
49
+ expect(rows[0].getAttribute('data-severity')).toBe('info');
50
+ });
51
+
52
+ it('renders multiple events with severity coding', () => {
53
+ render(wrap(<RealTimeEventLog />));
54
+ emit('task:progress', { taskId: 't1', progress: 50 });
55
+ emit('agent:stuck', { agents: [{ name: 'Odin' }] });
56
+ emit('goal:planned', { planId: 'p1' });
57
+ const rows = screen.getAllByTestId('event-log-row');
58
+ expect(rows.length).toBe(3);
59
+ expect(screen.getByTestId('event-log-count').textContent?.trim()).toBe('3');
60
+ });
61
+ });
@@ -0,0 +1,72 @@
1
+ // tests/goal-input.test.tsx — F-036 Goal Planner UI.
2
+ // Renders the GoalInput textarea + submit, mocks the API client,
3
+ // and verifies the planner endpoint is called with the trimmed
4
+ // goal text.
5
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
6
+ import { render, screen, waitFor } from '@testing-library/react';
7
+ import userEvent from '@testing-library/user-event';
8
+ import { ToastProvider } from '../src/web/components/Toast';
9
+ import { GoalInput } from '../src/web/components/goals/GoalInput';
10
+ import type { Plan } from '../src/web/lib/goapPlanner';
11
+
12
+ const mockApiPost = vi.fn();
13
+
14
+ vi.mock('../src/web/lib/api', () => ({
15
+ api: {
16
+ post: (...args: unknown[]) => mockApiPost(...args),
17
+ },
18
+ }));
19
+
20
+ function makePlan(): Plan {
21
+ return {
22
+ id: 'plan_test',
23
+ goal: 'Build it',
24
+ steps: [
25
+ { id: 's1', action: 'build', title: 'Build', agent: 'Thor', description: 'Build.', effects: ['artifact_built'], deps: [], status: 'pending', estimatedCost: 3, estimatedDurationMs: 120000 },
26
+ ],
27
+ totalCost: 3,
28
+ totalDurationMs: 120000,
29
+ goalEffects: ['artifact_built'],
30
+ };
31
+ }
32
+
33
+ function wrap(node: React.ReactNode) {
34
+ return <ToastProvider>{node}</ToastProvider>;
35
+ }
36
+
37
+ beforeEach(() => {
38
+ mockApiPost.mockReset();
39
+ mockApiPost.mockResolvedValue(makePlan());
40
+ });
41
+
42
+ describe('GoalInput', () => {
43
+ it('renders a textarea and a submit button', () => {
44
+ render(wrap(<GoalInput />));
45
+ expect(screen.getByTestId('goal-input-textarea')).toBeInTheDocument();
46
+ expect(screen.getByTestId('goal-input-submit')).toBeInTheDocument();
47
+ expect(screen.getByTestId('goal-input-counter').textContent).toMatch(/0 \/ 4000/);
48
+ });
49
+
50
+ it('submits the trimmed goal to the planner endpoint and emits the plan', async () => {
51
+ const user = userEvent.setup();
52
+ const onPlan = vi.fn();
53
+ render(wrap(<GoalInput onPlan={onPlan} />));
54
+ const textarea = screen.getByTestId('goal-input-textarea');
55
+ await user.type(textarea, ' Research quantum. ');
56
+ const submit = screen.getByTestId('goal-input-submit');
57
+ await user.click(submit);
58
+ await waitFor(() => expect(mockApiPost).toHaveBeenCalledTimes(1));
59
+ expect(mockApiPost).toHaveBeenCalledWith('/goal-planner/plan', { goal: 'Research quantum.' });
60
+ await waitFor(() => expect(onPlan).toHaveBeenCalledTimes(1));
61
+ expect(onPlan.mock.calls[0][0].id).toBe('plan_test');
62
+ });
63
+
64
+ it('does not submit when the textarea is empty', async () => {
65
+ const user = userEvent.setup();
66
+ render(wrap(<GoalInput />));
67
+ const submit = screen.getByTestId('goal-input-submit');
68
+ expect(submit).toBeDisabled();
69
+ await user.click(submit).catch(() => undefined);
70
+ expect(mockApiPost).not.toHaveBeenCalled();
71
+ });
72
+ });
@@ -0,0 +1,68 @@
1
+ // tests/goap-planner.test.ts — F-036 Goal Planner UI.
2
+ // Verifies the pure-TS A* planner: clause split, verb mapping,
3
+ // dependency link, total cost rollup.
4
+ import { describe, it, expect } from 'vitest';
5
+ import {
6
+ planGoal,
7
+ splitGoalClauses,
8
+ extractVerb,
9
+ clauseToAction,
10
+ planDependencyEdges,
11
+ type Plan,
12
+ } from '../src/web/lib/goapPlanner';
13
+
14
+ describe('goapPlanner — clauses', () => {
15
+ it('splits a multi-clause goal on sentence boundaries', () => {
16
+ const clauses = splitGoalClauses('Research quantum. Then build a demo. Finally deploy it.');
17
+ expect(clauses.length).toBe(3);
18
+ });
19
+
20
+ it('extracts the leading verb after stripping polite prefixes', () => {
21
+ expect(extractVerb('Research the topic')).toBe('research');
22
+ expect(extractVerb('Please build it')).toBe('build');
23
+ expect(extractVerb('We should test the change')).toBe('test');
24
+ });
25
+
26
+ it('maps known verbs to action templates with effects', () => {
27
+ const t = clauseToAction('Deploy the artifact');
28
+ expect(t.name).toBe('deploy');
29
+ expect(t.effect).toBe('deployed');
30
+ });
31
+ });
32
+
33
+ describe('goapPlanner — planGoal', () => {
34
+ it('produces a plan with at least one step for a simple goal', () => {
35
+ const plan = planGoal('Research quantum computing.');
36
+ expect(plan.id.length).toBeGreaterThan(0);
37
+ expect(plan.goal).toBe('Research quantum computing.');
38
+ expect(plan.steps.length).toBeGreaterThan(0);
39
+ expect(plan.totalCost).toBeGreaterThan(0);
40
+ expect(plan.totalDurationMs).toBeGreaterThan(0);
41
+ });
42
+
43
+ it('links dep steps via effect-to-id resolution', () => {
44
+ const plan = planGoal('Design, build, test, and deploy the API.');
45
+ // Build depends on design_drafted (from the design step).
46
+ const build = plan.steps.find((s) => s.action === 'build' || s.action === 'implement');
47
+ expect(build).toBeDefined();
48
+ expect(build!.deps.length).toBeGreaterThan(0);
49
+ });
50
+
51
+ it('returns a plan whose dependency edges cover the dep list', () => {
52
+ const plan: Plan = planGoal('Plan it, build it, test it.');
53
+ const edges = planDependencyEdges(plan);
54
+ expect(edges.length).toBeGreaterThan(0);
55
+ // Every edge must reference a known step id.
56
+ const ids = new Set(plan.steps.map((s) => s.id));
57
+ for (const e of edges) {
58
+ expect(ids.has(e.from)).toBe(true);
59
+ expect(ids.has(e.to)).toBe(true);
60
+ }
61
+ });
62
+
63
+ it('caps step count at 8 to keep A* tractable', () => {
64
+ const goal = 'Research. Investigate. Analyze. Plan. Design. Architect. Build. Implement. Create. Add. Test. Review. Document. Deploy. Commit. Publish.';
65
+ const plan = planGoal(goal);
66
+ expect(plan.steps.length).toBeLessThanOrEqual(8);
67
+ });
68
+ });
@@ -0,0 +1,59 @@
1
+ // tests/quality-gates.test.tsx — F-036 Goal Planner UI.
2
+ // Renders the QualityGates panel with empty, single, and multi
3
+ // metrics so the pass/warn/fail logic is observable.
4
+ import { describe, it, expect } from 'vitest';
5
+ import { render, screen } from '@testing-library/react';
6
+ import { QualityGates } from '../src/web/components/agents/QualityGates';
7
+
8
+ const baseMetrics = {
9
+ compileCheck: false,
10
+ testCoverage: 0,
11
+ archScore: 0,
12
+ evidencePresent: false,
13
+ };
14
+
15
+ describe('QualityGates', () => {
16
+ it('renders four gate rows when metrics are empty', () => {
17
+ render(<QualityGates plan={null} metrics={baseMetrics} />);
18
+ const rows = screen.getAllByTestId('quality-gates-row');
19
+ expect(rows.length).toBe(4);
20
+ // None of the gates should be passing on a fresh install.
21
+ expect(rows.every((r) => r.getAttribute('data-status') !== 'passed')).toBe(true);
22
+ });
23
+
24
+ it('renders all gates as passing when metrics are at threshold', () => {
25
+ render(
26
+ <QualityGates
27
+ plan={null}
28
+ metrics={{
29
+ compileCheck: true,
30
+ testCoverage: 85,
31
+ archScore: 92,
32
+ evidencePresent: true,
33
+ }}
34
+ />,
35
+ );
36
+ const rows = screen.getAllByTestId('quality-gates-row');
37
+ expect(rows.length).toBe(4);
38
+ expect(rows.every((r) => r.getAttribute('data-status') === 'passed')).toBe(true);
39
+ });
40
+
41
+ it('renders mixed pass/warn/fail when metrics are heterogeneous', () => {
42
+ render(
43
+ <QualityGates
44
+ plan={null}
45
+ metrics={{
46
+ compileCheck: true,
47
+ testCoverage: 65, // warning
48
+ archScore: 50, // failed
49
+ evidencePresent: false,
50
+ }}
51
+ />,
52
+ );
53
+ const rows = screen.getAllByTestId('quality-gates-row');
54
+ const statuses = rows.map((r) => r.getAttribute('data-status'));
55
+ expect(statuses).toContain('passed');
56
+ expect(statuses).toContain('warning');
57
+ expect(statuses).toContain('failed');
58
+ });
59
+ });
@@ -1,6 +1,36 @@
1
1
  import '@testing-library/jest-dom/vitest';
2
2
  import { cleanup } from '@testing-library/react';
3
- import { afterEach } from 'vitest';
3
+ import { afterEach, vi } from 'vitest';
4
+
5
+ // jsdom doesn't ship ResizeObserver, but @xyflow/react (and other
6
+ // layout libs) expect it to be present. Stub a no-op so React renders
7
+ // don't crash when a component mounts a graph.
8
+ if (typeof globalThis.ResizeObserver === 'undefined') {
9
+ globalThis.ResizeObserver = class {
10
+ observe() {}
11
+ unobserve() {}
12
+ disconnect() {}
13
+ } as unknown as typeof ResizeObserver;
14
+ }
15
+
16
+ // Similarly for matchMedia — used by some component libs that the
17
+ // dashboard pulls in. jsdom ships an empty stub but it lacks the
18
+ // addEventListener/removeEventListener pair that React 18 expects.
19
+ if (typeof window !== 'undefined' && !window.matchMedia) {
20
+ Object.defineProperty(window, 'matchMedia', {
21
+ writable: true,
22
+ value: vi.fn().mockImplementation((query: string) => ({
23
+ matches: false,
24
+ media: query,
25
+ onchange: null,
26
+ addListener: vi.fn(),
27
+ removeListener: vi.fn(),
28
+ addEventListener: vi.fn(),
29
+ removeEventListener: vi.fn(),
30
+ dispatchEvent: vi.fn(),
31
+ })),
32
+ });
33
+ }
4
34
 
5
35
  afterEach(() => {
6
36
  cleanup();
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { Button } from '../../../src/web/ui/controls/Button';
5
+
6
+ describe('Button', () => {
7
+ it('renders its children', () => {
8
+ render(<Button>Click me</Button>);
9
+ expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
10
+ });
11
+
12
+ it('fires onClick when clicked', async () => {
13
+ const onClick = vi.fn();
14
+ const user = userEvent.setup();
15
+ render(<Button onClick={onClick}>Go</Button>);
16
+ await user.click(screen.getByRole('button'));
17
+ expect(onClick).toHaveBeenCalledTimes(1);
18
+ });
19
+
20
+ it('is disabled when disabled prop is set', () => {
21
+ render(<Button disabled>Off</Button>);
22
+ expect(screen.getByRole('button')).toBeDisabled();
23
+ });
24
+
25
+ it('shows the spinner and disables clicks when loading', async () => {
26
+ const onClick = vi.fn();
27
+ const user = userEvent.setup();
28
+ render(<Button loading onClick={onClick}>Saving</Button>);
29
+ const btn = screen.getByRole('button');
30
+ expect(btn).toBeDisabled();
31
+ expect(btn.querySelector('.btn-spinner')).toBeInTheDocument();
32
+ await user.click(btn);
33
+ expect(onClick).not.toHaveBeenCalled();
34
+ });
35
+
36
+ it('applies variant and size classes', () => {
37
+ const { container } = render(
38
+ <Button variant="primary" size="lg">
39
+ Primary
40
+ </Button>,
41
+ );
42
+ const btn = container.firstChild as HTMLElement;
43
+ expect(btn).toHaveClass('btn-primary', 'btn-size-lg');
44
+ });
45
+
46
+ it('renders as full-width when fullWidth is true', () => {
47
+ render(<Button fullWidth>Wide</Button>);
48
+ expect(screen.getByRole('button')).toHaveClass('btn-full');
49
+ });
50
+ });
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { Checkbox } from '../../../src/web/ui/controls/Checkbox';
5
+
6
+ describe('Checkbox', () => {
7
+ it('renders with the supplied label', () => {
8
+ render(<Checkbox checked={false} onChange={() => {}} label="Accept" />);
9
+ expect(screen.getByRole('checkbox', { name: 'Accept' })).toBeInTheDocument();
10
+ });
11
+
12
+ it('fires onChange(true) when clicked', async () => {
13
+ const onChange = vi.fn();
14
+ const user = userEvent.setup();
15
+ render(<Checkbox checked={false} onChange={onChange} label="X" />);
16
+ await user.click(screen.getByRole('checkbox'));
17
+ expect(onChange).toHaveBeenCalledWith(true);
18
+ });
19
+
20
+ it('toggles checked visually when checked is true', () => {
21
+ render(<Checkbox checked onChange={() => {}} label="X" data-testid="c" />);
22
+ expect(screen.getByTestId('c')).toHaveClass('checkbox-checked');
23
+ });
24
+
25
+ it('renders indeterminate state class', () => {
26
+ render(
27
+ <Checkbox
28
+ checked={false}
29
+ indeterminate
30
+ onChange={() => {}}
31
+ label="X"
32
+ data-testid="c"
33
+ />,
34
+ );
35
+ expect(screen.getByTestId('c')).toHaveClass('checkbox-indeterminate');
36
+ });
37
+
38
+ it('is disabled when disabled is set', () => {
39
+ render(
40
+ <Checkbox checked={false} onChange={() => {}} label="X" disabled />,
41
+ );
42
+ expect(screen.getByRole('checkbox')).toBeDisabled();
43
+ });
44
+ });
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { Star } from 'lucide-react';
5
+ import { IconButton } from '../../../src/web/ui/controls/IconButton';
6
+
7
+ describe('IconButton', () => {
8
+ it('renders an accessible button with the supplied label', () => {
9
+ render(<IconButton aria-label="Star" icon={<Star data-testid="i" />} />);
10
+ expect(screen.getByRole('button', { name: 'Star' })).toBeInTheDocument();
11
+ });
12
+
13
+ it('forwards onClick', async () => {
14
+ const onClick = vi.fn();
15
+ const user = userEvent.setup();
16
+ render(
17
+ <IconButton aria-label="X" icon={<Star />} onClick={onClick} />,
18
+ );
19
+ await user.click(screen.getByRole('button'));
20
+ expect(onClick).toHaveBeenCalledTimes(1);
21
+ });
22
+
23
+ it('applies variant + size classes', () => {
24
+ const { container } = render(
25
+ <IconButton aria-label="X" icon={<Star />} variant="danger" size="lg" />,
26
+ );
27
+ const btn = container.firstChild as HTMLElement;
28
+ expect(btn).toHaveClass('icon-btn-danger', 'icon-btn-size-lg');
29
+ });
30
+
31
+ it('is disabled when disabled is set', () => {
32
+ render(
33
+ <IconButton aria-label="X" icon={<Star />} disabled />,
34
+ );
35
+ expect(screen.getByRole('button')).toBeDisabled();
36
+ });
37
+ });
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { Kbd } from '../../../src/web/ui/controls/Kbd';
4
+
5
+ describe('Kbd', () => {
6
+ it('renders its children as text content', () => {
7
+ render(<Kbd>K</Kbd>);
8
+ expect(screen.getByText('K')).toBeInTheDocument();
9
+ });
10
+
11
+ it('renders a <kbd> element with the kbd class', () => {
12
+ const { container } = render(<Kbd>X</Kbd>);
13
+ const el = container.querySelector('kbd');
14
+ expect(el).toBeInTheDocument();
15
+ expect(el).toHaveClass('kbd');
16
+ });
17
+
18
+ it('passes through HTML attributes', () => {
19
+ render(
20
+ <Kbd id="shortcut-1" data-testid="k">
21
+ ⌘
22
+ </Kbd>,
23
+ );
24
+ const el = screen.getByTestId('k');
25
+ expect(el.id).toBe('shortcut-1');
26
+ expect(el.tagName).toBe('KBD');
27
+ });
28
+
29
+ it('combines user className with the kbd class via cx', () => {
30
+ render(
31
+ <Kbd data-testid="k" className="extra">
32
+ K
33
+ </Kbd>,
34
+ );
35
+ const el = screen.getByTestId('k');
36
+ expect(el).toHaveClass('kbd');
37
+ expect(el).toHaveClass('extra');
38
+ });
39
+ });
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { NumberInput } from '../../../src/web/ui/controls/NumberInput';
5
+
6
+ describe('NumberInput', () => {
7
+ it('renders a spinbox with the numeric value', () => {
8
+ render(<NumberInput value={5} onChange={() => {}} />);
9
+ const input = screen.getByRole('spinbutton');
10
+ expect(input).toHaveValue(5);
11
+ });
12
+
13
+ it('accepts a string value', () => {
14
+ render(<NumberInput value="12" onChange={() => {}} />);
15
+ const input = screen.getByRole('spinbutton');
16
+ expect(input).toHaveValue(12);
17
+ });
18
+
19
+ it('fires onChange with a number when typing digits', async () => {
20
+ const onChange = vi.fn();
21
+ const user = userEvent.setup();
22
+ render(<NumberInput value={0} onChange={onChange} />);
23
+ const input = screen.getByRole('spinbutton');
24
+ input.focus();
25
+ await user.type(input, '7');
26
+ expect(onChange).toHaveBeenCalled();
27
+ const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]?.[0];
28
+ expect(typeof lastCall === 'number' || lastCall === '07').toBe(true);
29
+ });
30
+
31
+ it('forwards min/max/step', () => {
32
+ render(
33
+ <NumberInput
34
+ value={1}
35
+ onChange={() => {}}
36
+ min={0}
37
+ max={10}
38
+ step={0.5}
39
+ />,
40
+ );
41
+ const input = screen.getByRole('spinbutton') as HTMLInputElement;
42
+ expect(input.min).toBe('0');
43
+ expect(input.max).toBe('10');
44
+ expect(input.step).toBe('0.5');
45
+ });
46
+
47
+ it('applies error class when error is set', () => {
48
+ render(<NumberInput value={1} onChange={() => {}} error="bad" />);
49
+ expect(screen.getByRole('spinbutton')).toHaveClass('field-error');
50
+ });
51
+
52
+ it('is disabled when disabled is set', () => {
53
+ render(<NumberInput value={1} onChange={() => {}} disabled />);
54
+ expect(screen.getByRole('spinbutton')).toBeDisabled();
55
+ });
56
+ });