@happyvertical/smrt-svelte 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +317 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +7 -0
- package/README.md +185 -0
- package/dist/Provider.svelte +204 -0
- package/dist/Provider.svelte.d.ts +73 -0
- package/dist/Provider.svelte.d.ts.map +1 -0
- package/dist/__tests__/app-state.test.js +156 -0
- package/dist/__tests__/warm-clients.test.js +186 -0
- package/dist/browser-ai/adapters/llm/factory.d.ts +38 -0
- package/dist/browser-ai/adapters/llm/factory.d.ts.map +1 -0
- package/dist/browser-ai/adapters/llm/factory.js +91 -0
- package/dist/browser-ai/adapters/llm/index.d.ts +7 -0
- package/dist/browser-ai/adapters/llm/index.d.ts.map +1 -0
- package/dist/browser-ai/adapters/llm/index.js +6 -0
- package/dist/browser-ai/adapters/llm/types.d.ts +182 -0
- package/dist/browser-ai/adapters/llm/types.d.ts.map +1 -0
- package/dist/browser-ai/adapters/llm/types.js +43 -0
- package/dist/browser-ai/adapters/llm/webllm.d.ts +33 -0
- package/dist/browser-ai/adapters/llm/webllm.d.ts.map +1 -0
- package/dist/browser-ai/adapters/llm/webllm.js +225 -0
- package/dist/browser-ai/adapters/stt/browser-speech.d.ts +31 -0
- package/dist/browser-ai/adapters/stt/browser-speech.d.ts.map +1 -0
- package/dist/browser-ai/adapters/stt/browser-speech.js +217 -0
- package/dist/browser-ai/adapters/stt/factory.d.ts +49 -0
- package/dist/browser-ai/adapters/stt/factory.d.ts.map +1 -0
- package/dist/browser-ai/adapters/stt/factory.js +110 -0
- package/dist/browser-ai/adapters/stt/index.d.ts +9 -0
- package/dist/browser-ai/adapters/stt/index.d.ts.map +1 -0
- package/dist/browser-ai/adapters/stt/index.js +8 -0
- package/dist/browser-ai/adapters/stt/types.d.ts +154 -0
- package/dist/browser-ai/adapters/stt/types.d.ts.map +1 -0
- package/dist/browser-ai/adapters/stt/types.js +4 -0
- package/dist/browser-ai/adapters/stt/whisper-cpp.d.ts +46 -0
- package/dist/browser-ai/adapters/stt/whisper-cpp.d.ts.map +1 -0
- package/dist/browser-ai/adapters/stt/whisper-cpp.js +348 -0
- package/dist/browser-ai/adapters/stt/whisper-wasm.d.ts +51 -0
- package/dist/browser-ai/adapters/stt/whisper-wasm.d.ts.map +1 -0
- package/dist/browser-ai/adapters/stt/whisper-wasm.js +380 -0
- package/dist/browser-ai/adapters/tts/browser-synthesis.d.ts +42 -0
- package/dist/browser-ai/adapters/tts/browser-synthesis.d.ts.map +1 -0
- package/dist/browser-ai/adapters/tts/browser-synthesis.js +235 -0
- package/dist/browser-ai/adapters/tts/factory.d.ts +53 -0
- package/dist/browser-ai/adapters/tts/factory.d.ts.map +1 -0
- package/dist/browser-ai/adapters/tts/factory.js +92 -0
- package/dist/browser-ai/adapters/tts/index.d.ts +7 -0
- package/dist/browser-ai/adapters/tts/index.d.ts.map +1 -0
- package/dist/browser-ai/adapters/tts/index.js +6 -0
- package/dist/browser-ai/adapters/tts/types.d.ts +140 -0
- package/dist/browser-ai/adapters/tts/types.d.ts.map +1 -0
- package/dist/browser-ai/adapters/tts/types.js +4 -0
- package/dist/browser-ai/capabilities/detector.d.ts +38 -0
- package/dist/browser-ai/capabilities/detector.d.ts.map +1 -0
- package/dist/browser-ai/capabilities/detector.js +211 -0
- package/dist/browser-ai/core/errors.d.ts +62 -0
- package/dist/browser-ai/core/errors.d.ts.map +1 -0
- package/dist/browser-ai/core/errors.js +92 -0
- package/dist/browser-ai/core/index.d.ts +6 -0
- package/dist/browser-ai/core/index.d.ts.map +1 -0
- package/dist/browser-ai/core/index.js +5 -0
- package/dist/browser-ai/core/types.d.ts +115 -0
- package/dist/browser-ai/core/types.d.ts.map +1 -0
- package/dist/browser-ai/core/types.js +34 -0
- package/dist/browser-ai/index.d.ts +12 -0
- package/dist/browser-ai/index.d.ts.map +1 -0
- package/dist/browser-ai/index.js +16 -0
- package/dist/browser-ai/svelte/components/AILoadingOverlay.svelte +77 -0
- package/dist/browser-ai/svelte/components/AILoadingOverlay.svelte.d.ts +16 -0
- package/dist/browser-ai/svelte/components/AILoadingOverlay.svelte.d.ts.map +1 -0
- package/dist/browser-ai/svelte/components/CapabilityGate.svelte +57 -0
- package/dist/browser-ai/svelte/components/CapabilityGate.svelte.d.ts +15 -0
- package/dist/browser-ai/svelte/components/CapabilityGate.svelte.d.ts.map +1 -0
- package/dist/browser-ai/svelte/components/DownloadProgress.svelte +141 -0
- package/dist/browser-ai/svelte/components/DownloadProgress.svelte.d.ts +15 -0
- package/dist/browser-ai/svelte/components/DownloadProgress.svelte.d.ts.map +1 -0
- package/dist/browser-ai/svelte/components/STTTest.svelte +379 -0
- package/dist/browser-ai/svelte/components/STTTest.svelte.d.ts +9 -0
- package/dist/browser-ai/svelte/components/STTTest.svelte.d.ts.map +1 -0
- package/dist/browser-ai/svelte/components/VoiceInput.svelte +200 -0
- package/dist/browser-ai/svelte/components/VoiceInput.svelte.d.ts +16 -0
- package/dist/browser-ai/svelte/components/VoiceInput.svelte.d.ts.map +1 -0
- package/dist/browser-ai/svelte/index.d.ts +15 -0
- package/dist/browser-ai/svelte/index.d.ts.map +1 -0
- package/dist/browser-ai/svelte/index.js +28 -0
- package/dist/browser-ai/ui.d.ts +16 -0
- package/dist/browser-ai/ui.d.ts.map +1 -0
- package/dist/browser-ai/ui.js +67 -0
- package/dist/components/admin/AgentAdminPanel.svelte +111 -0
- package/dist/components/admin/AgentAdminPanel.svelte.d.ts +25 -0
- package/dist/components/admin/AgentAdminPanel.svelte.d.ts.map +1 -0
- package/dist/components/admin/AgentAdminTabs.svelte +280 -0
- package/dist/components/admin/AgentAdminTabs.svelte.d.ts +23 -0
- package/dist/components/admin/AgentAdminTabs.svelte.d.ts.map +1 -0
- package/dist/components/admin/AgentSettingsShell.svelte +257 -0
- package/dist/components/admin/AgentSettingsShell.svelte.d.ts +33 -0
- package/dist/components/admin/AgentSettingsShell.svelte.d.ts.map +1 -0
- package/dist/components/admin/index.d.ts +5 -0
- package/dist/components/admin/index.d.ts.map +1 -0
- package/dist/components/admin/index.js +6 -0
- package/dist/components/forms/AddressInput.svelte +500 -0
- package/dist/components/forms/AddressInput.svelte.d.ts +36 -0
- package/dist/components/forms/AddressInput.svelte.d.ts.map +1 -0
- package/dist/components/forms/CheckboxInput.svelte +208 -0
- package/dist/components/forms/CheckboxInput.svelte.d.ts +20 -0
- package/dist/components/forms/CheckboxInput.svelte.d.ts.map +1 -0
- package/dist/components/forms/DateRangeInput.svelte +628 -0
- package/dist/components/forms/DateRangeInput.svelte.d.ts +33 -0
- package/dist/components/forms/DateRangeInput.svelte.d.ts.map +1 -0
- package/dist/components/forms/DateTimeInput.svelte +521 -0
- package/dist/components/forms/DateTimeInput.svelte.d.ts +24 -0
- package/dist/components/forms/DateTimeInput.svelte.d.ts.map +1 -0
- package/dist/components/forms/FileUpload.svelte +358 -0
- package/dist/components/forms/FileUpload.svelte.d.ts +22 -0
- package/dist/components/forms/FileUpload.svelte.d.ts.map +1 -0
- package/dist/components/forms/Form.svelte +771 -0
- package/dist/components/forms/Form.svelte.d.ts +26 -0
- package/dist/components/forms/Form.svelte.d.ts.map +1 -0
- package/dist/components/forms/FormGroup.svelte +86 -0
- package/dist/components/forms/FormGroup.svelte.d.ts +13 -0
- package/dist/components/forms/FormGroup.svelte.d.ts.map +1 -0
- package/dist/components/forms/FormMicButton.svelte +179 -0
- package/dist/components/forms/FormMicButton.svelte.d.ts +10 -0
- package/dist/components/forms/FormMicButton.svelte.d.ts.map +1 -0
- package/dist/components/forms/Input.svelte +83 -0
- package/dist/components/forms/Input.svelte.d.ts +9 -0
- package/dist/components/forms/Input.svelte.d.ts.map +1 -0
- package/dist/components/forms/MeasurementInput.svelte +505 -0
- package/dist/components/forms/MeasurementInput.svelte.d.ts +35 -0
- package/dist/components/forms/MeasurementInput.svelte.d.ts.map +1 -0
- package/dist/components/forms/MoneyInput.svelte +412 -0
- package/dist/components/forms/MoneyInput.svelte.d.ts +30 -0
- package/dist/components/forms/MoneyInput.svelte.d.ts.map +1 -0
- package/dist/components/forms/NumberInput.svelte +310 -0
- package/dist/components/forms/NumberInput.svelte.d.ts +28 -0
- package/dist/components/forms/NumberInput.svelte.d.ts.map +1 -0
- package/dist/components/forms/PhoneInput.svelte +530 -0
- package/dist/components/forms/PhoneInput.svelte.d.ts +22 -0
- package/dist/components/forms/PhoneInput.svelte.d.ts.map +1 -0
- package/dist/components/forms/SearchInput.svelte +358 -0
- package/dist/components/forms/SearchInput.svelte.d.ts +33 -0
- package/dist/components/forms/SearchInput.svelte.d.ts.map +1 -0
- package/dist/components/forms/Select.svelte +83 -0
- package/dist/components/forms/Select.svelte.d.ts +11 -0
- package/dist/components/forms/Select.svelte.d.ts.map +1 -0
- package/dist/components/forms/SelectInput.svelte +254 -0
- package/dist/components/forms/SelectInput.svelte.d.ts +25 -0
- package/dist/components/forms/SelectInput.svelte.d.ts.map +1 -0
- package/dist/components/forms/TextInput.svelte +415 -0
- package/dist/components/forms/TextInput.svelte.d.ts +26 -0
- package/dist/components/forms/TextInput.svelte.d.ts.map +1 -0
- package/dist/components/forms/Textarea.svelte +85 -0
- package/dist/components/forms/Textarea.svelte.d.ts +10 -0
- package/dist/components/forms/Textarea.svelte.d.ts.map +1 -0
- package/dist/components/forms/TextareaInput.svelte +386 -0
- package/dist/components/forms/TextareaInput.svelte.d.ts +26 -0
- package/dist/components/forms/TextareaInput.svelte.d.ts.map +1 -0
- package/dist/components/forms/Toggle.svelte +217 -0
- package/dist/components/forms/Toggle.svelte.d.ts +37 -0
- package/dist/components/forms/Toggle.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/AddressInput.behavior.test.js +122 -0
- package/dist/components/forms/__tests__/CheckboxInput.test.js +92 -0
- package/dist/components/forms/__tests__/DateRangeInput.behavior.test.js +135 -0
- package/dist/components/forms/__tests__/DateTimeInput.behavior.test.js +103 -0
- package/dist/components/forms/__tests__/FileUpload.test.js +90 -0
- package/dist/components/forms/__tests__/Form.behavior.test.js +137 -0
- package/dist/components/forms/__tests__/Form.test.js +58 -0
- package/dist/components/forms/__tests__/FormGroup.test.js +48 -0
- package/dist/components/forms/__tests__/FormMicButton.test.js +86 -0
- package/dist/components/forms/__tests__/Input.test.js +49 -0
- package/dist/components/forms/__tests__/MeasurementInput.behavior.test.js +129 -0
- package/dist/components/forms/__tests__/MoneyInput.behavior.test.js +124 -0
- package/dist/components/forms/__tests__/NumberInput.behavior.test.js +141 -0
- package/dist/components/forms/__tests__/PhoneInput.behavior.test.js +96 -0
- package/dist/components/forms/__tests__/SearchInput.test.js +79 -0
- package/dist/components/forms/__tests__/Select.test.js +37 -0
- package/dist/components/forms/__tests__/SelectInput.behavior.test.js +132 -0
- package/dist/components/forms/__tests__/TextInput.behavior.test.js +131 -0
- package/dist/components/forms/__tests__/Textarea.test.js +39 -0
- package/dist/components/forms/__tests__/TextareaInput.behavior.test.js +96 -0
- package/dist/components/forms/__tests__/Toggle.test.js +87 -0
- package/dist/components/forms/__tests__/composite-inputs-a11y.test.js +69 -0
- package/dist/components/forms/__tests__/form-group-input.fixture.svelte +16 -0
- package/dist/components/forms/__tests__/form-group-input.fixture.svelte.d.ts +9 -0
- package/dist/components/forms/__tests__/form-group-input.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/form-with-fields.fixture.svelte +33 -0
- package/dist/components/forms/__tests__/form-with-fields.fixture.svelte.d.ts +12 -0
- package/dist/components/forms/__tests__/form-with-fields.fixture.svelte.d.ts.map +1 -0
- package/dist/components/forms/__tests__/rich-inputs-a11y.test.js +87 -0
- package/dist/components/forms/index.d.ts +25 -0
- package/dist/components/forms/index.d.ts.map +1 -0
- package/dist/components/forms/index.js +25 -0
- package/dist/components/forms/types.d.ts +33 -0
- package/dist/components/forms/types.d.ts.map +1 -0
- package/dist/components/forms/types.js +4 -0
- package/dist/components/module/ModulePanel.svelte +134 -0
- package/dist/components/module/ModulePanel.svelte.d.ts +22 -0
- package/dist/components/module/ModulePanel.svelte.d.ts.map +1 -0
- package/dist/components/module/index.d.ts +5 -0
- package/dist/components/module/index.d.ts.map +1 -0
- package/dist/components/module/index.js +4 -0
- package/dist/components/workspace/Breadcrumbs.svelte +141 -0
- package/dist/components/workspace/Breadcrumbs.svelte.d.ts +21 -0
- package/dist/components/workspace/Breadcrumbs.svelte.d.ts.map +1 -0
- package/dist/components/workspace/NavTree.svelte +354 -0
- package/dist/components/workspace/NavTree.svelte.d.ts +45 -0
- package/dist/components/workspace/NavTree.svelte.d.ts.map +1 -0
- package/dist/components/workspace/README.md +34 -0
- package/dist/components/workspace/RoleShell.svelte +309 -0
- package/dist/components/workspace/RoleShell.svelte.d.ts +91 -0
- package/dist/components/workspace/RoleShell.svelte.d.ts.map +1 -0
- package/dist/components/workspace/WorkspaceShell.svelte +951 -0
- package/dist/components/workspace/WorkspaceShell.svelte.d.ts +112 -0
- package/dist/components/workspace/WorkspaceShell.svelte.d.ts.map +1 -0
- package/dist/components/workspace/__tests__/RoleShell.test.js +772 -0
- package/dist/components/workspace/__tests__/WorkspaceShell.test.js +630 -0
- package/dist/components/workspace/__tests__/breadcrumbs-helpers.test.js +141 -0
- package/dist/components/workspace/__tests__/context-forwarding-harness.svelte +45 -0
- package/dist/components/workspace/__tests__/context-forwarding-harness.svelte.d.ts +21 -0
- package/dist/components/workspace/__tests__/context-forwarding-harness.svelte.d.ts.map +1 -0
- package/dist/components/workspace/__tests__/define-tools-dock.test.js +1010 -0
- package/dist/components/workspace/__tests__/harness.svelte +25 -0
- package/dist/components/workspace/__tests__/harness.svelte.d.ts +14 -0
- package/dist/components/workspace/__tests__/harness.svelte.d.ts.map +1 -0
- package/dist/components/workspace/__tests__/index.test.js +37 -0
- package/dist/components/workspace/__tests__/manifest-nav-helpers.test.js +24 -0
- package/dist/components/workspace/__tests__/manifest-nav.test.js +599 -0
- package/dist/components/workspace/__tests__/nav-helpers.test.js +95 -0
- package/dist/components/workspace/__tests__/render-harness.svelte +66 -0
- package/dist/components/workspace/__tests__/render-harness.svelte.d.ts +32 -0
- package/dist/components/workspace/__tests__/render-harness.svelte.d.ts.map +1 -0
- package/dist/components/workspace/__tests__/render-tools-dock.test.js +243 -0
- package/dist/components/workspace/__tests__/role-shell-bind-harness.svelte +58 -0
- package/dist/components/workspace/__tests__/role-shell-bind-harness.svelte.d.ts +16 -0
- package/dist/components/workspace/__tests__/role-shell-bind-harness.svelte.d.ts.map +1 -0
- package/dist/components/workspace/__tests__/role-shell-switch-harness.svelte +41 -0
- package/dist/components/workspace/__tests__/role-shell-switch-harness.svelte.d.ts +13 -0
- package/dist/components/workspace/__tests__/role-shell-switch-harness.svelte.d.ts.map +1 -0
- package/dist/components/workspace/__tests__/test-icon.svelte +17 -0
- package/dist/components/workspace/__tests__/test-icon.svelte.d.ts +19 -0
- package/dist/components/workspace/__tests__/test-icon.svelte.d.ts.map +1 -0
- package/dist/components/workspace/__tests__/typed-tool-fixture/TypedTool.svelte +38 -0
- package/dist/components/workspace/__tests__/typed-tool-fixture/TypedTool.svelte.d.ts +22 -0
- package/dist/components/workspace/__tests__/typed-tool-fixture/TypedTool.svelte.d.ts.map +1 -0
- package/dist/components/workspace/__tests__/typed-tool-fixture/register-typed-tool.d.ts +65 -0
- package/dist/components/workspace/__tests__/typed-tool-fixture/register-typed-tool.d.ts.map +1 -0
- package/dist/components/workspace/__tests__/typed-tool-fixture/register-typed-tool.js +115 -0
- package/dist/components/workspace/__tests__/typed-tool-fixture/typed-tool-types.d.ts +15 -0
- package/dist/components/workspace/__tests__/typed-tool-fixture/typed-tool-types.d.ts.map +1 -0
- package/dist/components/workspace/__tests__/typed-tool-fixture/typed-tool-types.js +7 -0
- package/dist/components/workspace/__tests__/typed-tool-fixture.test.js +115 -0
- package/dist/components/workspace/__tests__/use-harness-orphan.svelte +9 -0
- package/dist/components/workspace/__tests__/use-harness-orphan.svelte.d.ts +19 -0
- package/dist/components/workspace/__tests__/use-harness-orphan.svelte.d.ts.map +1 -0
- package/dist/components/workspace/__tests__/use-harness.svelte +23 -0
- package/dist/components/workspace/__tests__/use-harness.svelte.d.ts +8 -0
- package/dist/components/workspace/__tests__/use-harness.svelte.d.ts.map +1 -0
- package/dist/components/workspace/__tests__/use-tools-dock.test.js +33 -0
- package/dist/components/workspace/__tests__/workspace-shell-bind-harness.svelte +43 -0
- package/dist/components/workspace/__tests__/workspace-shell-bind-harness.svelte.d.ts +11 -0
- package/dist/components/workspace/__tests__/workspace-shell-bind-harness.svelte.d.ts.map +1 -0
- package/dist/components/workspace/breadcrumbs-helpers.d.ts +44 -0
- package/dist/components/workspace/breadcrumbs-helpers.d.ts.map +1 -0
- package/dist/components/workspace/breadcrumbs-helpers.js +88 -0
- package/dist/components/workspace/index.d.ts +16 -0
- package/dist/components/workspace/index.d.ts.map +1 -0
- package/dist/components/workspace/index.js +14 -0
- package/dist/components/workspace/manifest-nav.d.ts +200 -0
- package/dist/components/workspace/manifest-nav.d.ts.map +1 -0
- package/dist/components/workspace/manifest-nav.js +408 -0
- package/dist/components/workspace/nav-helpers.d.ts +36 -0
- package/dist/components/workspace/nav-helpers.d.ts.map +1 -0
- package/dist/components/workspace/nav-helpers.js +60 -0
- package/dist/components/workspace/server/__tests__/compose-availability.test.js +383 -0
- package/dist/components/workspace/server/__tests__/typed-context-fixture.d.ts +78 -0
- package/dist/components/workspace/server/__tests__/typed-context-fixture.d.ts.map +1 -0
- package/dist/components/workspace/server/__tests__/typed-context-fixture.js +104 -0
- package/dist/components/workspace/server/compose-availability.d.ts +73 -0
- package/dist/components/workspace/server/compose-availability.d.ts.map +1 -0
- package/dist/components/workspace/server/compose-availability.js +114 -0
- package/dist/components/workspace/server/index.d.ts +13 -0
- package/dist/components/workspace/server/index.d.ts.map +1 -0
- package/dist/components/workspace/server/index.js +11 -0
- package/dist/components/workspace/server/types.d.ts +108 -0
- package/dist/components/workspace/server/types.d.ts.map +1 -0
- package/dist/components/workspace/server/types.js +11 -0
- package/dist/components/workspace/tools-dock/ToolsDock.svelte +565 -0
- package/dist/components/workspace/tools-dock/ToolsDock.svelte.d.ts +14 -0
- package/dist/components/workspace/tools-dock/ToolsDock.svelte.d.ts.map +1 -0
- package/dist/components/workspace/tools-dock/define-tools-dock.svelte.d.ts +143 -0
- package/dist/components/workspace/tools-dock/define-tools-dock.svelte.d.ts.map +1 -0
- package/dist/components/workspace/tools-dock/define-tools-dock.svelte.js +487 -0
- package/dist/components/workspace/tools-dock/use-tools-dock.d.ts +41 -0
- package/dist/components/workspace/tools-dock/use-tools-dock.d.ts.map +1 -0
- package/dist/components/workspace/tools-dock/use-tools-dock.js +50 -0
- package/dist/components/workspace/types.d.ts +372 -0
- package/dist/components/workspace/types.d.ts.map +1 -0
- package/dist/components/workspace/types.js +6 -0
- package/dist/hooks/index.d.ts +11 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +10 -0
- package/dist/hooks/useAppState.svelte.d.ts +46 -0
- package/dist/hooks/useAppState.svelte.d.ts.map +1 -0
- package/dist/hooks/useAppState.svelte.js +59 -0
- package/dist/hooks/useAuth.svelte.d.ts +41 -0
- package/dist/hooks/useAuth.svelte.d.ts.map +1 -0
- package/dist/hooks/useAuth.svelte.js +43 -0
- package/dist/hooks/useLLM.svelte.d.ts +69 -0
- package/dist/hooks/useLLM.svelte.d.ts.map +1 -0
- package/dist/hooks/useLLM.svelte.js +85 -0
- package/dist/hooks/useSTT.svelte.d.ts +68 -0
- package/dist/hooks/useSTT.svelte.d.ts.map +1 -0
- package/dist/hooks/useSTT.svelte.js +97 -0
- package/dist/hooks/useSocket.svelte.d.ts +45 -0
- package/dist/hooks/useSocket.svelte.d.ts.map +1 -0
- package/dist/hooks/useSocket.svelte.js +54 -0
- package/dist/hooks/useTTS.svelte.d.ts +65 -0
- package/dist/hooks/useTTS.svelte.d.ts.map +1 -0
- package/dist/hooks/useTTS.svelte.js +93 -0
- package/dist/hooks/useTheme.d.ts +13 -0
- package/dist/hooks/useTheme.d.ts.map +1 -0
- package/dist/hooks/useTheme.js +16 -0
- package/dist/i18n/__tests__/server.spec.js +50 -0
- package/dist/i18n/server.d.ts +47 -0
- package/dist/i18n/server.d.ts.map +1 -0
- package/dist/i18n/server.js +58 -0
- package/dist/i18n/strings.forms.d.ts +33 -0
- package/dist/i18n/strings.forms.d.ts.map +1 -0
- package/dist/i18n/strings.forms.js +54 -0
- package/dist/i18n/strings.workspace.d.ts +34 -0
- package/dist/i18n/strings.workspace.d.ts.map +1 -0
- package/dist/i18n/strings.workspace.js +40 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/state/__tests__/warm-clients.test.js +40 -0
- package/dist/state/app-state.d.ts +308 -0
- package/dist/state/app-state.d.ts.map +1 -0
- package/dist/state/app-state.js +64 -0
- package/dist/state/app-state.svelte.d.ts +196 -0
- package/dist/state/app-state.svelte.d.ts.map +1 -0
- package/dist/state/app-state.svelte.js +774 -0
- package/dist/state/context.d.ts +23 -0
- package/dist/state/context.d.ts.map +1 -0
- package/dist/state/context.js +32 -0
- package/dist/state/form-context.d.ts +59 -0
- package/dist/state/form-context.d.ts.map +1 -0
- package/dist/state/form-context.js +31 -0
- package/dist/state/form-group-context.d.ts +13 -0
- package/dist/state/form-group-context.d.ts.map +1 -0
- package/dist/state/form-group-context.js +28 -0
- package/dist/state/index.d.ts +9 -0
- package/dist/state/index.d.ts.map +1 -0
- package/dist/state/index.js +8 -0
- package/dist/state/warm-clients.d.ts +136 -0
- package/dist/state/warm-clients.d.ts.map +1 -0
- package/dist/state/warm-clients.js +231 -0
- package/package.json +137 -0
|
@@ -0,0 +1,1010 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for defineToolsDock factory.
|
|
3
|
+
*
|
|
4
|
+
* These tests exercise the reactive API surface directly. Svelte `setContext`
|
|
5
|
+
* is called inside the factory, but the returned `ToolsDockInstance` is the
|
|
6
|
+
* subject under test — we don't need a full component tree to verify the
|
|
7
|
+
* state machine.
|
|
8
|
+
*
|
|
9
|
+
* `setContext` only works inside a component init scope, so we invoke the
|
|
10
|
+
* factory inside Svelte's `mount` (via a thin host component) so that all
|
|
11
|
+
* `$state` runes wire up correctly.
|
|
12
|
+
*/
|
|
13
|
+
import { flushSync, mount, unmount } from 'svelte';
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
15
|
+
import HostHarness from './harness.svelte';
|
|
16
|
+
const noopTool = {};
|
|
17
|
+
function mountDock(options) {
|
|
18
|
+
const target = document.createElement('div');
|
|
19
|
+
document.body.appendChild(target);
|
|
20
|
+
const exposed = {
|
|
21
|
+
dock: undefined,
|
|
22
|
+
};
|
|
23
|
+
const component = mount(HostHarness, {
|
|
24
|
+
target,
|
|
25
|
+
props: {
|
|
26
|
+
options,
|
|
27
|
+
onReady: (dock) => {
|
|
28
|
+
exposed.dock = dock;
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
return {
|
|
33
|
+
dock: exposed.dock,
|
|
34
|
+
teardown: () => {
|
|
35
|
+
unmount(component);
|
|
36
|
+
target.remove();
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
describe('defineToolsDock', () => {
|
|
41
|
+
let cleanup = [];
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
cleanup = [];
|
|
44
|
+
if (typeof window !== 'undefined') {
|
|
45
|
+
window.localStorage.clear();
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
for (const fn of cleanup.splice(0))
|
|
50
|
+
fn();
|
|
51
|
+
});
|
|
52
|
+
function track(value) {
|
|
53
|
+
cleanup.push(value.teardown);
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
it('creates an API with sensible defaults', () => {
|
|
57
|
+
const { dock } = track(mountDock({
|
|
58
|
+
tools: [
|
|
59
|
+
{ id: 'chat', label: 'Chat', component: noopTool },
|
|
60
|
+
{ id: 'jobs', label: 'Jobs', component: noopTool },
|
|
61
|
+
],
|
|
62
|
+
}));
|
|
63
|
+
expect(dock.isOpen).toBe(false);
|
|
64
|
+
expect(dock.activeTool).toBeNull();
|
|
65
|
+
expect(dock.layout).toBe('rail');
|
|
66
|
+
expect(dock.storageKey).toBeNull();
|
|
67
|
+
expect(dock.availableTools.map((t) => t.id)).toEqual(['chat', 'jobs']);
|
|
68
|
+
});
|
|
69
|
+
it('open() / close() / toggle() update isOpen and activeTool', () => {
|
|
70
|
+
const { dock } = track(mountDock({
|
|
71
|
+
tools: [
|
|
72
|
+
{ id: 'chat', label: 'Chat', component: noopTool },
|
|
73
|
+
{ id: 'jobs', label: 'Jobs', component: noopTool },
|
|
74
|
+
],
|
|
75
|
+
}));
|
|
76
|
+
dock.open('chat');
|
|
77
|
+
flushSync();
|
|
78
|
+
expect(dock.isOpen).toBe(true);
|
|
79
|
+
expect(dock.activeTool).toBe('chat');
|
|
80
|
+
dock.close();
|
|
81
|
+
flushSync();
|
|
82
|
+
expect(dock.isOpen).toBe(false);
|
|
83
|
+
// activeTool persists so re-opening lands on the same tool.
|
|
84
|
+
expect(dock.activeTool).toBe('chat');
|
|
85
|
+
dock.toggle('jobs');
|
|
86
|
+
flushSync();
|
|
87
|
+
expect(dock.isOpen).toBe(true);
|
|
88
|
+
expect(dock.activeTool).toBe('jobs');
|
|
89
|
+
// toggle on the same id closes.
|
|
90
|
+
dock.toggle('jobs');
|
|
91
|
+
flushSync();
|
|
92
|
+
expect(dock.isOpen).toBe(false);
|
|
93
|
+
// toggle without id flips just isOpen.
|
|
94
|
+
dock.toggle();
|
|
95
|
+
flushSync();
|
|
96
|
+
expect(dock.isOpen).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
it('open() on unknown id is a no-op (does not crash, does not open)', () => {
|
|
99
|
+
const { dock } = track(mountDock({
|
|
100
|
+
tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
|
|
101
|
+
}));
|
|
102
|
+
dock.open('does-not-exist');
|
|
103
|
+
flushSync();
|
|
104
|
+
expect(dock.isOpen).toBe(false);
|
|
105
|
+
expect(dock.activeTool).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
it('respects initialOpen and picks first tool when toggled', () => {
|
|
108
|
+
const { dock } = track(mountDock({
|
|
109
|
+
tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
|
|
110
|
+
initialOpen: true,
|
|
111
|
+
}));
|
|
112
|
+
expect(dock.isOpen).toBe(true);
|
|
113
|
+
expect(dock.activeTool).toBeNull();
|
|
114
|
+
dock.toggle();
|
|
115
|
+
flushSync();
|
|
116
|
+
// First toggle while no tool active selects the first available.
|
|
117
|
+
// (Was open → now closed; activeTool now set on the next open.)
|
|
118
|
+
expect(dock.isOpen).toBe(false);
|
|
119
|
+
dock.toggle();
|
|
120
|
+
flushSync();
|
|
121
|
+
expect(dock.isOpen).toBe(true);
|
|
122
|
+
expect(dock.activeTool).toBe('chat');
|
|
123
|
+
});
|
|
124
|
+
it('setContext updates context and triggers fetchAvailability', async () => {
|
|
125
|
+
const fetchAvailability = vi
|
|
126
|
+
.fn()
|
|
127
|
+
.mockResolvedValueOnce([{ id: 'chat' }])
|
|
128
|
+
.mockResolvedValueOnce([{ id: 'chat' }, { id: 'jobs' }]);
|
|
129
|
+
const { dock } = track(mountDock({
|
|
130
|
+
tools: [
|
|
131
|
+
{ id: 'chat', label: 'Chat', component: noopTool },
|
|
132
|
+
{ id: 'jobs', label: 'Jobs', component: noopTool },
|
|
133
|
+
{ id: 'hidden', label: 'Hidden', component: noopTool },
|
|
134
|
+
],
|
|
135
|
+
fetchAvailability,
|
|
136
|
+
}));
|
|
137
|
+
// No call yet — fetchAvailability only fires on setContext.
|
|
138
|
+
expect(fetchAvailability).not.toHaveBeenCalled();
|
|
139
|
+
dock.setContext({ type: 'thing', title: 'A' });
|
|
140
|
+
// setContext invokes fetchAvailability synchronously; the promise resolves
|
|
141
|
+
// on the next microtask, so flushSync after a few microtasks is required
|
|
142
|
+
// for Svelte to settle the resulting state update.
|
|
143
|
+
expect(fetchAvailability).toHaveBeenCalledTimes(1);
|
|
144
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
145
|
+
flushSync();
|
|
146
|
+
expect(dock.availableTools.map((t) => t.id)).toEqual(['chat']);
|
|
147
|
+
dock.setContext({ type: 'thing', title: 'B' });
|
|
148
|
+
expect(fetchAvailability).toHaveBeenCalledTimes(2);
|
|
149
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
150
|
+
flushSync();
|
|
151
|
+
expect(dock.availableTools.map((t) => t.id)).toEqual(['chat', 'jobs']);
|
|
152
|
+
});
|
|
153
|
+
it('drops stale fetchAvailability results (race-safety)', async () => {
|
|
154
|
+
let resolveFirst = null;
|
|
155
|
+
let resolveSecond = null;
|
|
156
|
+
const fetchAvailability = vi
|
|
157
|
+
.fn()
|
|
158
|
+
.mockImplementationOnce(() => new Promise((r) => {
|
|
159
|
+
resolveFirst = r;
|
|
160
|
+
}))
|
|
161
|
+
.mockImplementationOnce(() => new Promise((r) => {
|
|
162
|
+
resolveSecond = r;
|
|
163
|
+
}));
|
|
164
|
+
const { dock } = track(mountDock({
|
|
165
|
+
tools: [
|
|
166
|
+
{ id: 'chat', label: 'Chat', component: noopTool },
|
|
167
|
+
{ id: 'jobs', label: 'Jobs', component: noopTool },
|
|
168
|
+
],
|
|
169
|
+
fetchAvailability,
|
|
170
|
+
}));
|
|
171
|
+
dock.setContext({ type: 'A' });
|
|
172
|
+
await Promise.resolve();
|
|
173
|
+
dock.setContext({ type: 'B' });
|
|
174
|
+
await Promise.resolve();
|
|
175
|
+
// Resolve the *stale* (first) call after the newer one. Then resolve the
|
|
176
|
+
// newer one. The stale value must not stomp the fresh value.
|
|
177
|
+
resolveFirst?.([{ id: 'chat' }]);
|
|
178
|
+
resolveSecond?.([{ id: 'jobs' }]);
|
|
179
|
+
await Promise.resolve();
|
|
180
|
+
await Promise.resolve();
|
|
181
|
+
flushSync();
|
|
182
|
+
expect(dock.availableTools.map((t) => t.id)).toEqual(['jobs']);
|
|
183
|
+
});
|
|
184
|
+
it('emit / on / unsubscribe', () => {
|
|
185
|
+
const { dock } = track(mountDock({
|
|
186
|
+
tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
|
|
187
|
+
}));
|
|
188
|
+
const handler = vi.fn();
|
|
189
|
+
const off = dock.on('test', handler);
|
|
190
|
+
dock.emit('test', { ok: true });
|
|
191
|
+
expect(handler).toHaveBeenCalledWith({ ok: true });
|
|
192
|
+
off();
|
|
193
|
+
dock.emit('test', { ok: false });
|
|
194
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
195
|
+
});
|
|
196
|
+
it('emit catches handler errors without dropping later handlers', () => {
|
|
197
|
+
const { dock } = track(mountDock({
|
|
198
|
+
tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
|
|
199
|
+
}));
|
|
200
|
+
const throwing = vi.fn(() => {
|
|
201
|
+
throw new Error('boom');
|
|
202
|
+
});
|
|
203
|
+
const after = vi.fn();
|
|
204
|
+
dock.on('test', throwing);
|
|
205
|
+
dock.on('test', after);
|
|
206
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
207
|
+
expect(() => dock.emit('test', null)).not.toThrow();
|
|
208
|
+
expect(throwing).toHaveBeenCalled();
|
|
209
|
+
expect(after).toHaveBeenCalled();
|
|
210
|
+
errSpy.mockRestore();
|
|
211
|
+
});
|
|
212
|
+
it('persists state to localStorage when storageKey provided', () => {
|
|
213
|
+
const { dock } = track(mountDock({
|
|
214
|
+
tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
|
|
215
|
+
storageKey: 'tdtest:v1',
|
|
216
|
+
}));
|
|
217
|
+
dock.open('chat');
|
|
218
|
+
flushSync();
|
|
219
|
+
const raw = window.localStorage.getItem('tdtest:v1');
|
|
220
|
+
expect(raw).not.toBeNull();
|
|
221
|
+
const parsed = JSON.parse(raw);
|
|
222
|
+
expect(parsed).toEqual({ isOpen: true, activeTool: 'chat' });
|
|
223
|
+
});
|
|
224
|
+
it('hydrates state from localStorage on hydrate()', () => {
|
|
225
|
+
window.localStorage.setItem('tdtest:v2', JSON.stringify({ isOpen: true, activeTool: 'jobs' }));
|
|
226
|
+
const { dock } = track(mountDock({
|
|
227
|
+
tools: [
|
|
228
|
+
{ id: 'chat', label: 'Chat', component: noopTool },
|
|
229
|
+
{ id: 'jobs', label: 'Jobs', component: noopTool },
|
|
230
|
+
],
|
|
231
|
+
storageKey: 'tdtest:v2',
|
|
232
|
+
}));
|
|
233
|
+
dock.hydrate();
|
|
234
|
+
flushSync();
|
|
235
|
+
expect(dock.isOpen).toBe(true);
|
|
236
|
+
expect(dock.activeTool).toBe('jobs');
|
|
237
|
+
});
|
|
238
|
+
it('hydrate() ignores activeTool that no longer matches a registered tool', () => {
|
|
239
|
+
window.localStorage.setItem('tdtest:v3', JSON.stringify({ isOpen: true, activeTool: 'gone' }));
|
|
240
|
+
const { dock } = track(mountDock({
|
|
241
|
+
tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
|
|
242
|
+
storageKey: 'tdtest:v3',
|
|
243
|
+
}));
|
|
244
|
+
dock.hydrate();
|
|
245
|
+
flushSync();
|
|
246
|
+
expect(dock.isOpen).toBe(true);
|
|
247
|
+
expect(dock.activeTool).toBeNull();
|
|
248
|
+
});
|
|
249
|
+
it('exposes context on the public ToolsDockApi surface', () => {
|
|
250
|
+
const { dock } = track(mountDock({
|
|
251
|
+
tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
|
|
252
|
+
}));
|
|
253
|
+
expect(dock.context).toBeNull();
|
|
254
|
+
dock.setContext({ type: 'route', title: 'Home' });
|
|
255
|
+
flushSync();
|
|
256
|
+
expect(dock.context).toEqual({ type: 'route', title: 'Home' });
|
|
257
|
+
dock.setContext(null);
|
|
258
|
+
flushSync();
|
|
259
|
+
expect(dock.context).toBeNull();
|
|
260
|
+
});
|
|
261
|
+
it('badge: null in availability clears a registered default (not falls back)', async () => {
|
|
262
|
+
const fetchAvailability = vi
|
|
263
|
+
.fn()
|
|
264
|
+
.mockResolvedValueOnce([{ id: 'chat', badge: null }]);
|
|
265
|
+
const { dock } = track(mountDock({
|
|
266
|
+
tools: [{ id: 'chat', label: 'Chat', badge: 3, component: noopTool }],
|
|
267
|
+
fetchAvailability,
|
|
268
|
+
}));
|
|
269
|
+
dock.setContext({ type: 'a' });
|
|
270
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
271
|
+
flushSync();
|
|
272
|
+
// `badge: null` is explicit ("clear it"), not a fallback signal.
|
|
273
|
+
expect(dock.availableTools[0].badge).toBeNull();
|
|
274
|
+
});
|
|
275
|
+
it('handles synchronous throws from fetchAvailability without surfacing them', async () => {
|
|
276
|
+
const fetchAvailability = vi.fn(() => {
|
|
277
|
+
throw new Error('sync boom');
|
|
278
|
+
});
|
|
279
|
+
const { dock } = track(mountDock({
|
|
280
|
+
tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
|
|
281
|
+
fetchAvailability: fetchAvailability,
|
|
282
|
+
}));
|
|
283
|
+
// The sync throw must be caught by the wrapped promise chain, not
|
|
284
|
+
// propagate out of setContext to the caller.
|
|
285
|
+
expect(() => dock.setContext({ type: 'a' })).not.toThrow();
|
|
286
|
+
// The internal `.catch` applies an empty-availability result.
|
|
287
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
288
|
+
flushSync();
|
|
289
|
+
expect(dock.availableTools).toEqual([]);
|
|
290
|
+
});
|
|
291
|
+
describe('refreshAvailability()', () => {
|
|
292
|
+
it('re-runs fetchAvailability with the current context', async () => {
|
|
293
|
+
let availability = [{ id: 'chat' }];
|
|
294
|
+
const fetchAvailability = vi.fn(async () => availability);
|
|
295
|
+
const { dock } = track(mountDock({
|
|
296
|
+
tools: [
|
|
297
|
+
{ id: 'chat', label: 'Chat', component: noopTool },
|
|
298
|
+
{ id: 'jobs', label: 'Jobs', component: noopTool },
|
|
299
|
+
],
|
|
300
|
+
fetchAvailability,
|
|
301
|
+
}));
|
|
302
|
+
const ctx = { type: 'thing', title: 'A' };
|
|
303
|
+
dock.setContext(ctx);
|
|
304
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
305
|
+
flushSync();
|
|
306
|
+
expect(fetchAvailability).toHaveBeenCalledTimes(1);
|
|
307
|
+
expect(dock.availableTools.map((t) => t.id)).toEqual(['chat']);
|
|
308
|
+
// Side-channel signal: availability changed without a context change.
|
|
309
|
+
// setContext(ctx) would short-circuit; refreshAvailability() forces it.
|
|
310
|
+
availability = [{ id: 'chat' }, { id: 'jobs' }];
|
|
311
|
+
dock.refreshAvailability();
|
|
312
|
+
expect(fetchAvailability).toHaveBeenCalledTimes(2);
|
|
313
|
+
// Called with the same (unchanged) context.
|
|
314
|
+
expect(fetchAvailability).toHaveBeenLastCalledWith(ctx);
|
|
315
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
316
|
+
flushSync();
|
|
317
|
+
// Second fetch's result is reflected in availableTools.
|
|
318
|
+
expect(dock.availableTools.map((t) => t.id)).toEqual(['chat', 'jobs']);
|
|
319
|
+
});
|
|
320
|
+
it('resets to all registered tools when no fetchAvailability is configured', () => {
|
|
321
|
+
const { dock } = track(mountDock({
|
|
322
|
+
tools: [
|
|
323
|
+
{ id: 'chat', label: 'Chat', component: noopTool },
|
|
324
|
+
{ id: 'jobs', label: 'Jobs', component: noopTool },
|
|
325
|
+
],
|
|
326
|
+
}));
|
|
327
|
+
expect(dock.availableTools.map((t) => t.id)).toEqual(['chat', 'jobs']);
|
|
328
|
+
// No-op semantically (all tools were already available), but the
|
|
329
|
+
// method should not crash without a fetchAvailability callback.
|
|
330
|
+
expect(() => dock.refreshAvailability()).not.toThrow();
|
|
331
|
+
flushSync();
|
|
332
|
+
expect(dock.availableTools.map((t) => t.id)).toEqual(['chat', 'jobs']);
|
|
333
|
+
});
|
|
334
|
+
it("emits 'dock:change' when refresh changes only badge values", async () => {
|
|
335
|
+
// Regression for the badge-only refresh case: a side-channel signal
|
|
336
|
+
// fires (e.g. a Jobs-count websocket message) and the consumer calls
|
|
337
|
+
// `refreshAvailability()` to repaint badges. The tool ids haven't
|
|
338
|
+
// changed and the active tool stays put, but `availableTools` now
|
|
339
|
+
// carries a different badge — consumers mirroring this into a topbar
|
|
340
|
+
// count must see a `'dock:change'` event to repaint without polling.
|
|
341
|
+
let availability = [
|
|
342
|
+
{ id: 'chat', badge: 0 },
|
|
343
|
+
{ id: 'jobs', badge: 0 },
|
|
344
|
+
];
|
|
345
|
+
const fetchAvailability = vi.fn(async () => availability);
|
|
346
|
+
const { dock } = track(mountDock({
|
|
347
|
+
tools: [
|
|
348
|
+
{ id: 'chat', label: 'Chat', component: noopTool },
|
|
349
|
+
{ id: 'jobs', label: 'Jobs', component: noopTool },
|
|
350
|
+
],
|
|
351
|
+
fetchAvailability,
|
|
352
|
+
}));
|
|
353
|
+
dock.setContext({ type: 'thing' });
|
|
354
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
355
|
+
flushSync();
|
|
356
|
+
const handler = vi.fn();
|
|
357
|
+
dock.on('dock:change', handler);
|
|
358
|
+
// Side-channel update: same tool ids, different badge values.
|
|
359
|
+
availability = [
|
|
360
|
+
{ id: 'chat', badge: 0 },
|
|
361
|
+
{ id: 'jobs', badge: 7 },
|
|
362
|
+
];
|
|
363
|
+
dock.refreshAvailability();
|
|
364
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
365
|
+
flushSync();
|
|
366
|
+
// Exactly one emit reflecting the new badge.
|
|
367
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
368
|
+
expect(handler).toHaveBeenLastCalledWith({
|
|
369
|
+
isOpen: false,
|
|
370
|
+
activeTool: null,
|
|
371
|
+
context: { type: 'thing' },
|
|
372
|
+
});
|
|
373
|
+
const jobsAfter = dock.availableTools.find((t) => t.id === 'jobs');
|
|
374
|
+
expect(jobsAfter?.badge).toBe(7);
|
|
375
|
+
// Calling refreshAvailability again with byte-identical results must
|
|
376
|
+
// NOT emit a second time. The discriminating shape check keeps no-op
|
|
377
|
+
// refreshes silent so consumer mirrors don't see spurious updates.
|
|
378
|
+
dock.refreshAvailability();
|
|
379
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
380
|
+
flushSync();
|
|
381
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
382
|
+
});
|
|
383
|
+
it("emits 'dock:change' when refresh clears the active tool", async () => {
|
|
384
|
+
// Direct coverage for the active-tool-clear branch of
|
|
385
|
+
// `applyAvailability` reached via `refreshAvailability()` (rather
|
|
386
|
+
// than via a `setContext()` → refresh chain).
|
|
387
|
+
let availability = [{ id: 'chat' }, { id: 'jobs' }];
|
|
388
|
+
const fetchAvailability = vi.fn(async () => availability);
|
|
389
|
+
const { dock } = track(mountDock({
|
|
390
|
+
tools: [
|
|
391
|
+
{ id: 'chat', label: 'Chat', component: noopTool },
|
|
392
|
+
{ id: 'jobs', label: 'Jobs', component: noopTool },
|
|
393
|
+
],
|
|
394
|
+
fetchAvailability,
|
|
395
|
+
}));
|
|
396
|
+
dock.setContext({ type: 'thing' });
|
|
397
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
398
|
+
flushSync();
|
|
399
|
+
dock.open('jobs');
|
|
400
|
+
flushSync();
|
|
401
|
+
expect(dock.activeTool).toBe('jobs');
|
|
402
|
+
const handler = vi.fn();
|
|
403
|
+
dock.on('dock:change', handler);
|
|
404
|
+
// Side-channel availability shrink — 'jobs' is no longer available.
|
|
405
|
+
availability = [{ id: 'chat' }];
|
|
406
|
+
dock.refreshAvailability();
|
|
407
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
408
|
+
flushSync();
|
|
409
|
+
expect(dock.activeTool).toBeNull();
|
|
410
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
411
|
+
expect(handler).toHaveBeenLastCalledWith({
|
|
412
|
+
isOpen: true,
|
|
413
|
+
activeTool: null,
|
|
414
|
+
context: { type: 'thing' },
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
it('drops stale results from a prior refreshAvailability call (race-safety)', async () => {
|
|
418
|
+
let resolveFirst = null;
|
|
419
|
+
let resolveSecond = null;
|
|
420
|
+
const fetchAvailability = vi
|
|
421
|
+
.fn()
|
|
422
|
+
.mockImplementationOnce(() => new Promise((r) => {
|
|
423
|
+
resolveFirst = r;
|
|
424
|
+
}))
|
|
425
|
+
.mockImplementationOnce(() => new Promise((r) => {
|
|
426
|
+
resolveSecond = r;
|
|
427
|
+
}));
|
|
428
|
+
const { dock } = track(mountDock({
|
|
429
|
+
tools: [
|
|
430
|
+
{ id: 'chat', label: 'Chat', component: noopTool },
|
|
431
|
+
{ id: 'jobs', label: 'Jobs', component: noopTool },
|
|
432
|
+
],
|
|
433
|
+
fetchAvailability,
|
|
434
|
+
}));
|
|
435
|
+
// First call via refreshAvailability (no prior setContext).
|
|
436
|
+
dock.refreshAvailability();
|
|
437
|
+
await Promise.resolve();
|
|
438
|
+
// Second call before the first resolves.
|
|
439
|
+
dock.refreshAvailability();
|
|
440
|
+
await Promise.resolve();
|
|
441
|
+
// Resolve stale first AFTER fresh second was requested.
|
|
442
|
+
resolveFirst?.([{ id: 'chat' }]);
|
|
443
|
+
resolveSecond?.([{ id: 'jobs' }]);
|
|
444
|
+
await Promise.resolve();
|
|
445
|
+
await Promise.resolve();
|
|
446
|
+
flushSync();
|
|
447
|
+
// Only the most recent result applies.
|
|
448
|
+
expect(dock.availableTools.map((t) => t.id)).toEqual(['jobs']);
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
it('clears activeTool when availability removes the active tool', async () => {
|
|
452
|
+
let availability = [{ id: 'chat' }, { id: 'jobs' }];
|
|
453
|
+
const fetchAvailability = vi.fn(async () => availability);
|
|
454
|
+
const { dock } = track(mountDock({
|
|
455
|
+
tools: [
|
|
456
|
+
{ id: 'chat', label: 'Chat', component: noopTool },
|
|
457
|
+
{ id: 'jobs', label: 'Jobs', component: noopTool },
|
|
458
|
+
],
|
|
459
|
+
fetchAvailability,
|
|
460
|
+
}));
|
|
461
|
+
dock.setContext({ type: 'a' });
|
|
462
|
+
await Promise.resolve();
|
|
463
|
+
await Promise.resolve();
|
|
464
|
+
flushSync();
|
|
465
|
+
dock.open('jobs');
|
|
466
|
+
flushSync();
|
|
467
|
+
expect(dock.activeTool).toBe('jobs');
|
|
468
|
+
availability = [{ id: 'chat' }];
|
|
469
|
+
dock.setContext({ type: 'b' });
|
|
470
|
+
await Promise.resolve();
|
|
471
|
+
await Promise.resolve();
|
|
472
|
+
flushSync();
|
|
473
|
+
expect(dock.availableTools.map((t) => t.id)).toEqual(['chat']);
|
|
474
|
+
expect(dock.activeTool).toBeNull();
|
|
475
|
+
});
|
|
476
|
+
describe("'dock:change' event", () => {
|
|
477
|
+
it('does not fire during factory construction', () => {
|
|
478
|
+
// Subscribing in a separate tick would miss any event fired during the
|
|
479
|
+
// factory body. To assert "no construction-time emit" we count via a
|
|
480
|
+
// listener added *before* the factory runs — but the factory's
|
|
481
|
+
// listener bus is internal, so instead we rely on the fact that
|
|
482
|
+
// `'dock:change'` is gated by the `ready` flag and verify
|
|
483
|
+
// post-construction state is clean (no recursive errors etc.) here.
|
|
484
|
+
const { dock } = track(mountDock({
|
|
485
|
+
tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
|
|
486
|
+
initialOpen: true,
|
|
487
|
+
}));
|
|
488
|
+
// Sanity: initial state reflects options without any emit having fired.
|
|
489
|
+
expect(dock.isOpen).toBe(true);
|
|
490
|
+
expect(dock.activeTool).toBeNull();
|
|
491
|
+
});
|
|
492
|
+
it('fires on open() with the post-update state', () => {
|
|
493
|
+
const { dock } = track(mountDock({
|
|
494
|
+
tools: [
|
|
495
|
+
{ id: 'chat', label: 'Chat', component: noopTool },
|
|
496
|
+
{ id: 'jobs', label: 'Jobs', component: noopTool },
|
|
497
|
+
],
|
|
498
|
+
}));
|
|
499
|
+
const handler = vi.fn();
|
|
500
|
+
dock.on('dock:change', handler);
|
|
501
|
+
dock.open('chat');
|
|
502
|
+
flushSync();
|
|
503
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
504
|
+
expect(handler).toHaveBeenLastCalledWith({
|
|
505
|
+
isOpen: true,
|
|
506
|
+
activeTool: 'chat',
|
|
507
|
+
context: null,
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
it('fires on close() with the post-update state', () => {
|
|
511
|
+
const { dock } = track(mountDock({
|
|
512
|
+
tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
|
|
513
|
+
}));
|
|
514
|
+
dock.open('chat');
|
|
515
|
+
flushSync();
|
|
516
|
+
const handler = vi.fn();
|
|
517
|
+
dock.on('dock:change', handler);
|
|
518
|
+
dock.close();
|
|
519
|
+
flushSync();
|
|
520
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
521
|
+
expect(handler).toHaveBeenLastCalledWith({
|
|
522
|
+
// close() leaves activeTool intact so re-opens land on the same tool.
|
|
523
|
+
isOpen: false,
|
|
524
|
+
activeTool: 'chat',
|
|
525
|
+
context: null,
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
it('fires on toggle() with the post-update state', () => {
|
|
529
|
+
const { dock } = track(mountDock({
|
|
530
|
+
tools: [
|
|
531
|
+
{ id: 'chat', label: 'Chat', component: noopTool },
|
|
532
|
+
{ id: 'jobs', label: 'Jobs', component: noopTool },
|
|
533
|
+
],
|
|
534
|
+
}));
|
|
535
|
+
const handler = vi.fn();
|
|
536
|
+
dock.on('dock:change', handler);
|
|
537
|
+
dock.toggle('chat');
|
|
538
|
+
flushSync();
|
|
539
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
540
|
+
expect(handler).toHaveBeenLastCalledWith({
|
|
541
|
+
isOpen: true,
|
|
542
|
+
activeTool: 'chat',
|
|
543
|
+
context: null,
|
|
544
|
+
});
|
|
545
|
+
dock.toggle('chat');
|
|
546
|
+
flushSync();
|
|
547
|
+
expect(handler).toHaveBeenCalledTimes(2);
|
|
548
|
+
expect(handler).toHaveBeenLastCalledWith({
|
|
549
|
+
isOpen: false,
|
|
550
|
+
activeTool: 'chat',
|
|
551
|
+
context: null,
|
|
552
|
+
});
|
|
553
|
+
dock.toggle();
|
|
554
|
+
flushSync();
|
|
555
|
+
expect(handler).toHaveBeenCalledTimes(3);
|
|
556
|
+
expect(handler).toHaveBeenLastCalledWith({
|
|
557
|
+
isOpen: true,
|
|
558
|
+
activeTool: 'chat',
|
|
559
|
+
context: null,
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
it('fires on setContext() with the new context', () => {
|
|
563
|
+
const { dock } = track(mountDock({
|
|
564
|
+
tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
|
|
565
|
+
}));
|
|
566
|
+
const handler = vi.fn();
|
|
567
|
+
dock.on('dock:change', handler);
|
|
568
|
+
dock.setContext({ type: 'route', title: 'Home' });
|
|
569
|
+
flushSync();
|
|
570
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
571
|
+
expect(handler).toHaveBeenLastCalledWith({
|
|
572
|
+
isOpen: false,
|
|
573
|
+
activeTool: null,
|
|
574
|
+
context: { type: 'route', title: 'Home' },
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
it('fires once with cleared state when availability removes the active tool', async () => {
|
|
578
|
+
let availability = [{ id: 'chat' }, { id: 'jobs' }];
|
|
579
|
+
const fetchAvailability = vi.fn(async () => availability);
|
|
580
|
+
const { dock } = track(mountDock({
|
|
581
|
+
tools: [
|
|
582
|
+
{ id: 'chat', label: 'Chat', component: noopTool },
|
|
583
|
+
{ id: 'jobs', label: 'Jobs', component: noopTool },
|
|
584
|
+
],
|
|
585
|
+
fetchAvailability,
|
|
586
|
+
}));
|
|
587
|
+
dock.setContext({ type: 'a' });
|
|
588
|
+
await Promise.resolve();
|
|
589
|
+
await Promise.resolve();
|
|
590
|
+
flushSync();
|
|
591
|
+
dock.open('jobs');
|
|
592
|
+
flushSync();
|
|
593
|
+
expect(dock.activeTool).toBe('jobs');
|
|
594
|
+
const handler = vi.fn();
|
|
595
|
+
dock.on('dock:change', handler);
|
|
596
|
+
availability = [{ id: 'chat' }];
|
|
597
|
+
dock.setContext({ type: 'b' });
|
|
598
|
+
// setContext emits synchronously …
|
|
599
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
600
|
+
// … then the async availability refresh resolves, clears the active
|
|
601
|
+
// tool, and emits a second 'dock:change' with the cleared state.
|
|
602
|
+
await Promise.resolve();
|
|
603
|
+
await Promise.resolve();
|
|
604
|
+
flushSync();
|
|
605
|
+
expect(handler).toHaveBeenCalledTimes(2);
|
|
606
|
+
expect(handler).toHaveBeenLastCalledWith({
|
|
607
|
+
isOpen: true,
|
|
608
|
+
activeTool: null,
|
|
609
|
+
context: { type: 'b' },
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
it('stops calling a handler after its unsubscribe function runs', () => {
|
|
613
|
+
const { dock } = track(mountDock({
|
|
614
|
+
tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
|
|
615
|
+
}));
|
|
616
|
+
const handler = vi.fn();
|
|
617
|
+
const off = dock.on('dock:change', handler);
|
|
618
|
+
dock.open('chat');
|
|
619
|
+
flushSync();
|
|
620
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
621
|
+
off();
|
|
622
|
+
dock.close();
|
|
623
|
+
flushSync();
|
|
624
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
625
|
+
});
|
|
626
|
+
it('payload type is inferred from ToolsDockEvents', () => {
|
|
627
|
+
const { dock } = track(mountDock({
|
|
628
|
+
tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
|
|
629
|
+
}));
|
|
630
|
+
// Compile-time check: handler arg should be the `dock:change` payload
|
|
631
|
+
// shape without an explicit generic.
|
|
632
|
+
dock.on('dock:change', (e) => {
|
|
633
|
+
const _isOpen = e.isOpen;
|
|
634
|
+
const _activeTool = e.activeTool;
|
|
635
|
+
void _isOpen;
|
|
636
|
+
void _activeTool;
|
|
637
|
+
});
|
|
638
|
+
// Runtime sanity check that the test compiles & wires.
|
|
639
|
+
dock.open('chat');
|
|
640
|
+
flushSync();
|
|
641
|
+
expect(dock.isOpen).toBe(true);
|
|
642
|
+
});
|
|
643
|
+
it('non-"dock:*" event names use the stringly-typed overload (e.g. "change" is not reserved)', () => {
|
|
644
|
+
// After namespacing built-ins under `'dock:*'`, the literal `'change'`
|
|
645
|
+
// is no longer a key of ToolsDockEvents, so consumers may use it
|
|
646
|
+
// freely with an explicit payload generic via the stringly-typed
|
|
647
|
+
// overload.
|
|
648
|
+
const { dock } = track(mountDock({
|
|
649
|
+
tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
|
|
650
|
+
}));
|
|
651
|
+
const customHandler = vi.fn();
|
|
652
|
+
const off = dock.on('change', customHandler);
|
|
653
|
+
dock.emit('change', { selected: 'row-1' });
|
|
654
|
+
expect(customHandler).toHaveBeenCalledWith({ selected: 'row-1' });
|
|
655
|
+
// Also confirm a namespaced consumer event works.
|
|
656
|
+
const appHandler = vi.fn();
|
|
657
|
+
dock.on('app:custom', appHandler);
|
|
658
|
+
dock.emit('app:custom', { id: 42 });
|
|
659
|
+
expect(appHandler).toHaveBeenCalledWith({ id: 42 });
|
|
660
|
+
// Built-in 'dock:change' must NOT trigger the custom 'change' listener.
|
|
661
|
+
dock.open('chat');
|
|
662
|
+
flushSync();
|
|
663
|
+
expect(customHandler).toHaveBeenCalledTimes(1);
|
|
664
|
+
off();
|
|
665
|
+
});
|
|
666
|
+
// ─────────────────────────────────────────────────────────────
|
|
667
|
+
// Regression tests: no-op state changes must NOT fire 'dock:change'.
|
|
668
|
+
//
|
|
669
|
+
// Before this guard, idempotent calls like `dock.open(activeId)` when
|
|
670
|
+
// already open emitted spurious events. Combined with effects that
|
|
671
|
+
// call setContext on every prop change, that produced redundant work
|
|
672
|
+
// (availability refetch) and could amplify into noisy loops when
|
|
673
|
+
// consumers wired the 'dock:change' event back into upstream state.
|
|
674
|
+
// ─────────────────────────────────────────────────────────────
|
|
675
|
+
it('does NOT fire on open() when already at that state', () => {
|
|
676
|
+
const { dock } = track(mountDock({
|
|
677
|
+
tools: [
|
|
678
|
+
{ id: 'chat', label: 'Chat', component: noopTool },
|
|
679
|
+
{ id: 'jobs', label: 'Jobs', component: noopTool },
|
|
680
|
+
],
|
|
681
|
+
}));
|
|
682
|
+
dock.open('chat');
|
|
683
|
+
flushSync();
|
|
684
|
+
const handler = vi.fn();
|
|
685
|
+
dock.on('dock:change', handler);
|
|
686
|
+
// Same id, already open — no observable state change.
|
|
687
|
+
dock.open('chat');
|
|
688
|
+
flushSync();
|
|
689
|
+
expect(handler).not.toHaveBeenCalled();
|
|
690
|
+
// open() with no id, already open with an active tool — still a no-op.
|
|
691
|
+
dock.open();
|
|
692
|
+
flushSync();
|
|
693
|
+
expect(handler).not.toHaveBeenCalled();
|
|
694
|
+
});
|
|
695
|
+
it('does NOT fire on close() when already closed', () => {
|
|
696
|
+
const { dock } = track(mountDock({
|
|
697
|
+
tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
|
|
698
|
+
}));
|
|
699
|
+
const handler = vi.fn();
|
|
700
|
+
dock.on('dock:change', handler);
|
|
701
|
+
// Initial state is closed; calling close again must not emit.
|
|
702
|
+
dock.close();
|
|
703
|
+
flushSync();
|
|
704
|
+
expect(handler).not.toHaveBeenCalled();
|
|
705
|
+
});
|
|
706
|
+
it('does NOT fire on setContext() with the same reference', async () => {
|
|
707
|
+
const fetchAvailability = vi.fn().mockResolvedValue([{ id: 'chat' }]);
|
|
708
|
+
const { dock } = track(mountDock({
|
|
709
|
+
tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
|
|
710
|
+
fetchAvailability,
|
|
711
|
+
}));
|
|
712
|
+
const ctx = { type: 'route', title: 'Home' };
|
|
713
|
+
dock.setContext(ctx);
|
|
714
|
+
// Initial emit + initial fetchAvailability call.
|
|
715
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
716
|
+
flushSync();
|
|
717
|
+
expect(fetchAvailability).toHaveBeenCalledTimes(1);
|
|
718
|
+
const handler = vi.fn();
|
|
719
|
+
dock.on('dock:change', handler);
|
|
720
|
+
// Same reference — must short-circuit (no emit, no refetch).
|
|
721
|
+
dock.setContext(ctx);
|
|
722
|
+
flushSync();
|
|
723
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
724
|
+
flushSync();
|
|
725
|
+
expect(handler).not.toHaveBeenCalled();
|
|
726
|
+
expect(fetchAvailability).toHaveBeenCalledTimes(1);
|
|
727
|
+
});
|
|
728
|
+
it('does NOT fire on toggle(id) that resolves to the same state', () => {
|
|
729
|
+
// toggle(sameId) when open+active flips to closed; toggle again
|
|
730
|
+
// flips back to open+active. Each transition is real, so each emits.
|
|
731
|
+
// The "no observable change" branch for toggle is hard to trigger
|
|
732
|
+
// through the API surface alone (toggle always flips at minimum
|
|
733
|
+
// `isOpen`), so the assertion here is the symmetric one: the emit
|
|
734
|
+
// counts match the number of real state transitions.
|
|
735
|
+
const { dock } = track(mountDock({
|
|
736
|
+
tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
|
|
737
|
+
}));
|
|
738
|
+
const handler = vi.fn();
|
|
739
|
+
dock.on('dock:change', handler);
|
|
740
|
+
dock.toggle('chat'); // open+active
|
|
741
|
+
flushSync();
|
|
742
|
+
dock.toggle('chat'); // close
|
|
743
|
+
flushSync();
|
|
744
|
+
expect(handler).toHaveBeenCalledTimes(2);
|
|
745
|
+
});
|
|
746
|
+
it('does not retrigger context-forwarding $effect when state mutates', async () => {
|
|
747
|
+
// Regression guard for the reactive-read leak in emitChange. Without
|
|
748
|
+
// `untrack` around the $state reads, an $effect calling
|
|
749
|
+
// dock.setContext would also depend on isOpen/activeTool/context
|
|
750
|
+
// (read inside the emitChange invoked by setContext). A subsequent
|
|
751
|
+
// dock.open() would mutate those, re-run the effect, call setContext
|
|
752
|
+
// again, etc. Here we verify open() does NOT cause the forwarding
|
|
753
|
+
// effect's tracked count to grow.
|
|
754
|
+
const fetchAvailability = vi.fn().mockResolvedValue([{ id: 'chat' }]);
|
|
755
|
+
const { dock } = track(mountDock({
|
|
756
|
+
tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
|
|
757
|
+
fetchAvailability,
|
|
758
|
+
}));
|
|
759
|
+
// Mount a child component whose $effect mirrors a `currentCtx` $state
|
|
760
|
+
// into dock.setContext — same pattern <ToolsDock>'s context prop uses.
|
|
761
|
+
const target = document.createElement('div');
|
|
762
|
+
document.body.appendChild(target);
|
|
763
|
+
cleanup.push(() => target.remove());
|
|
764
|
+
// We use the harness pattern: a tiny inline component would require
|
|
765
|
+
// its own file. Instead, count how many times fetchAvailability runs
|
|
766
|
+
// after the initial setContext — open() / close() must not cause it
|
|
767
|
+
// to re-run via a re-triggered forwarding effect.
|
|
768
|
+
dock.setContext({ type: 'a' });
|
|
769
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
770
|
+
flushSync();
|
|
771
|
+
const initialFetchCount = fetchAvailability.mock.calls.length;
|
|
772
|
+
const handler = vi.fn();
|
|
773
|
+
dock.on('dock:change', handler);
|
|
774
|
+
dock.open('chat');
|
|
775
|
+
flushSync();
|
|
776
|
+
dock.close();
|
|
777
|
+
flushSync();
|
|
778
|
+
// Two real state transitions → two emits, but the context-forwarding
|
|
779
|
+
// chain stays quiet: no extra fetchAvailability calls beyond the
|
|
780
|
+
// initial setContext.
|
|
781
|
+
expect(handler).toHaveBeenCalledTimes(2);
|
|
782
|
+
expect(fetchAvailability.mock.calls.length).toBe(initialFetchCount);
|
|
783
|
+
});
|
|
784
|
+
});
|
|
785
|
+
// ─────────────────────────────────────────────────────────────
|
|
786
|
+
// Granular events: 'dock:state-changed' and 'dock:context-changed'
|
|
787
|
+
// split the legacy 'dock:change' so consumers can subscribe to just
|
|
788
|
+
// the slice they care about. The legacy event still fires (back-compat).
|
|
789
|
+
// ─────────────────────────────────────────────────────────────
|
|
790
|
+
describe("'dock:state-changed' event", () => {
|
|
791
|
+
it('fires on open() / close() / toggle() with state-only payload', () => {
|
|
792
|
+
const { dock } = track(mountDock({
|
|
793
|
+
tools: [
|
|
794
|
+
{ id: 'chat', label: 'Chat', component: noopTool },
|
|
795
|
+
{ id: 'jobs', label: 'Jobs', component: noopTool },
|
|
796
|
+
],
|
|
797
|
+
}));
|
|
798
|
+
const handler = vi.fn();
|
|
799
|
+
dock.on('dock:state-changed', handler);
|
|
800
|
+
dock.open('chat');
|
|
801
|
+
flushSync();
|
|
802
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
803
|
+
expect(handler).toHaveBeenLastCalledWith({
|
|
804
|
+
isOpen: true,
|
|
805
|
+
activeTool: 'chat',
|
|
806
|
+
});
|
|
807
|
+
dock.close();
|
|
808
|
+
flushSync();
|
|
809
|
+
expect(handler).toHaveBeenCalledTimes(2);
|
|
810
|
+
expect(handler).toHaveBeenLastCalledWith({
|
|
811
|
+
isOpen: false,
|
|
812
|
+
activeTool: 'chat',
|
|
813
|
+
});
|
|
814
|
+
dock.toggle('jobs');
|
|
815
|
+
flushSync();
|
|
816
|
+
expect(handler).toHaveBeenCalledTimes(3);
|
|
817
|
+
expect(handler).toHaveBeenLastCalledWith({
|
|
818
|
+
isOpen: true,
|
|
819
|
+
activeTool: 'jobs',
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
it('does NOT fire on setContext() (context-only mutation)', () => {
|
|
823
|
+
const { dock } = track(mountDock({
|
|
824
|
+
tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
|
|
825
|
+
}));
|
|
826
|
+
const handler = vi.fn();
|
|
827
|
+
dock.on('dock:state-changed', handler);
|
|
828
|
+
dock.setContext({ type: 'route', title: 'Home' });
|
|
829
|
+
flushSync();
|
|
830
|
+
// setContext alone does not change isOpen/activeTool — must stay silent.
|
|
831
|
+
expect(handler).not.toHaveBeenCalled();
|
|
832
|
+
});
|
|
833
|
+
it('fires when availability clears the active tool', async () => {
|
|
834
|
+
let availability = [{ id: 'chat' }, { id: 'jobs' }];
|
|
835
|
+
const fetchAvailability = vi.fn(async () => availability);
|
|
836
|
+
const { dock } = track(mountDock({
|
|
837
|
+
tools: [
|
|
838
|
+
{ id: 'chat', label: 'Chat', component: noopTool },
|
|
839
|
+
{ id: 'jobs', label: 'Jobs', component: noopTool },
|
|
840
|
+
],
|
|
841
|
+
fetchAvailability,
|
|
842
|
+
}));
|
|
843
|
+
dock.setContext({ type: 'a' });
|
|
844
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
845
|
+
flushSync();
|
|
846
|
+
dock.open('jobs');
|
|
847
|
+
flushSync();
|
|
848
|
+
const handler = vi.fn();
|
|
849
|
+
dock.on('dock:state-changed', handler);
|
|
850
|
+
// Availability shrink → 'jobs' is no longer available, activeTool clears.
|
|
851
|
+
availability = [{ id: 'chat' }];
|
|
852
|
+
dock.refreshAvailability();
|
|
853
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
854
|
+
flushSync();
|
|
855
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
856
|
+
expect(handler).toHaveBeenLastCalledWith({
|
|
857
|
+
isOpen: true,
|
|
858
|
+
activeTool: null,
|
|
859
|
+
});
|
|
860
|
+
});
|
|
861
|
+
it('does NOT fire on no-op open() / close() / toggle()', () => {
|
|
862
|
+
const { dock } = track(mountDock({
|
|
863
|
+
tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
|
|
864
|
+
}));
|
|
865
|
+
dock.open('chat');
|
|
866
|
+
flushSync();
|
|
867
|
+
const handler = vi.fn();
|
|
868
|
+
dock.on('dock:state-changed', handler);
|
|
869
|
+
dock.open('chat'); // already open + active
|
|
870
|
+
flushSync();
|
|
871
|
+
dock.close();
|
|
872
|
+
flushSync();
|
|
873
|
+
dock.close(); // already closed
|
|
874
|
+
flushSync();
|
|
875
|
+
// One observable transition (open → close), so exactly one emit.
|
|
876
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
877
|
+
});
|
|
878
|
+
it('does NOT fire on badge-only availability refresh', async () => {
|
|
879
|
+
// Pure badge churn: no state change, no context change. The legacy
|
|
880
|
+
// 'dock:change' event still fires (for back-compat consumers
|
|
881
|
+
// mirroring availableTools), but 'dock:state-changed' stays silent.
|
|
882
|
+
let availability = [
|
|
883
|
+
{ id: 'chat', badge: 0 },
|
|
884
|
+
];
|
|
885
|
+
const fetchAvailability = vi.fn(async () => availability);
|
|
886
|
+
const { dock } = track(mountDock({
|
|
887
|
+
tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
|
|
888
|
+
fetchAvailability,
|
|
889
|
+
}));
|
|
890
|
+
dock.setContext({ type: 'a' });
|
|
891
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
892
|
+
flushSync();
|
|
893
|
+
const stateHandler = vi.fn();
|
|
894
|
+
dock.on('dock:state-changed', stateHandler);
|
|
895
|
+
availability = [{ id: 'chat', badge: 7 }];
|
|
896
|
+
dock.refreshAvailability();
|
|
897
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
898
|
+
flushSync();
|
|
899
|
+
expect(stateHandler).not.toHaveBeenCalled();
|
|
900
|
+
});
|
|
901
|
+
});
|
|
902
|
+
describe("'dock:context-changed' event", () => {
|
|
903
|
+
it('fires on setContext() with a different reference', () => {
|
|
904
|
+
const { dock } = track(mountDock({
|
|
905
|
+
tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
|
|
906
|
+
}));
|
|
907
|
+
const handler = vi.fn();
|
|
908
|
+
dock.on('dock:context-changed', handler);
|
|
909
|
+
const ctx = { type: 'route', title: 'Home' };
|
|
910
|
+
dock.setContext(ctx);
|
|
911
|
+
flushSync();
|
|
912
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
913
|
+
expect(handler).toHaveBeenLastCalledWith({ context: ctx });
|
|
914
|
+
dock.setContext(null);
|
|
915
|
+
flushSync();
|
|
916
|
+
expect(handler).toHaveBeenCalledTimes(2);
|
|
917
|
+
expect(handler).toHaveBeenLastCalledWith({ context: null });
|
|
918
|
+
});
|
|
919
|
+
it('does NOT fire on open() / close() / toggle() (state-only mutations)', () => {
|
|
920
|
+
const { dock } = track(mountDock({
|
|
921
|
+
tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
|
|
922
|
+
}));
|
|
923
|
+
const handler = vi.fn();
|
|
924
|
+
dock.on('dock:context-changed', handler);
|
|
925
|
+
dock.open('chat');
|
|
926
|
+
flushSync();
|
|
927
|
+
dock.close();
|
|
928
|
+
flushSync();
|
|
929
|
+
dock.toggle('chat');
|
|
930
|
+
flushSync();
|
|
931
|
+
expect(handler).not.toHaveBeenCalled();
|
|
932
|
+
});
|
|
933
|
+
it('does NOT fire on setContext() with the same reference', () => {
|
|
934
|
+
const { dock } = track(mountDock({
|
|
935
|
+
tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
|
|
936
|
+
}));
|
|
937
|
+
const ctx = { type: 'route' };
|
|
938
|
+
dock.setContext(ctx);
|
|
939
|
+
flushSync();
|
|
940
|
+
const handler = vi.fn();
|
|
941
|
+
dock.on('dock:context-changed', handler);
|
|
942
|
+
dock.setContext(ctx); // same reference — no-op-guarded
|
|
943
|
+
flushSync();
|
|
944
|
+
expect(handler).not.toHaveBeenCalled();
|
|
945
|
+
});
|
|
946
|
+
it('does NOT fire on a badge-only availability refresh', async () => {
|
|
947
|
+
let availability = [
|
|
948
|
+
{ id: 'chat', badge: 0 },
|
|
949
|
+
];
|
|
950
|
+
const fetchAvailability = vi.fn(async () => availability);
|
|
951
|
+
const { dock } = track(mountDock({
|
|
952
|
+
tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
|
|
953
|
+
fetchAvailability,
|
|
954
|
+
}));
|
|
955
|
+
dock.setContext({ type: 'a' });
|
|
956
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
957
|
+
flushSync();
|
|
958
|
+
const handler = vi.fn();
|
|
959
|
+
dock.on('dock:context-changed', handler);
|
|
960
|
+
availability = [{ id: 'chat', badge: 7 }];
|
|
961
|
+
dock.refreshAvailability();
|
|
962
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
963
|
+
flushSync();
|
|
964
|
+
// Context didn't change — badge refresh stays silent on this event.
|
|
965
|
+
expect(handler).not.toHaveBeenCalled();
|
|
966
|
+
});
|
|
967
|
+
});
|
|
968
|
+
describe("'dock:change' event continues to fire (back-compat)", () => {
|
|
969
|
+
it('fires on state, context, and badge-only availability transitions', async () => {
|
|
970
|
+
// Use a fetchAvailability that yields the same shape the dock seeded
|
|
971
|
+
// from the registered tools (no badge), so the post-setContext
|
|
972
|
+
// availability application is a no-op and we can count emits cleanly.
|
|
973
|
+
const fetchAvailability = vi
|
|
974
|
+
.fn()
|
|
975
|
+
.mockResolvedValue([{ id: 'chat' }]);
|
|
976
|
+
const { dock } = track(mountDock({
|
|
977
|
+
tools: [{ id: 'chat', label: 'Chat', component: noopTool }],
|
|
978
|
+
fetchAvailability,
|
|
979
|
+
}));
|
|
980
|
+
const legacyHandler = vi.fn();
|
|
981
|
+
dock.on('dock:change', legacyHandler);
|
|
982
|
+
// 1. setContext → 1 synchronous emit. The async availability fetch
|
|
983
|
+
// resolves to the same shape the registered tools already had
|
|
984
|
+
// (id='chat', no badge), so applyAvailability sees no change and
|
|
985
|
+
// does NOT emit a second time.
|
|
986
|
+
dock.setContext({ type: 'a' });
|
|
987
|
+
flushSync();
|
|
988
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
989
|
+
flushSync();
|
|
990
|
+
expect(legacyHandler).toHaveBeenCalledTimes(1);
|
|
991
|
+
// 2. open() → 1 more emit (state change).
|
|
992
|
+
dock.open('chat');
|
|
993
|
+
flushSync();
|
|
994
|
+
expect(legacyHandler).toHaveBeenCalledTimes(2);
|
|
995
|
+
// 3. badge-only availability refresh → 1 more emit. This is the
|
|
996
|
+
// back-compat slice: granular 'dock:state-changed' and
|
|
997
|
+
// 'dock:context-changed' stay silent, but legacy mirrors of
|
|
998
|
+
// availableTools still see the update via 'dock:change'.
|
|
999
|
+
fetchAvailability.mockResolvedValueOnce([{ id: 'chat', badge: 5 }]);
|
|
1000
|
+
dock.refreshAvailability();
|
|
1001
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
1002
|
+
flushSync();
|
|
1003
|
+
expect(legacyHandler).toHaveBeenCalledTimes(3);
|
|
1004
|
+
// 4. close() → 1 more emit (state change).
|
|
1005
|
+
dock.close();
|
|
1006
|
+
flushSync();
|
|
1007
|
+
expect(legacyHandler).toHaveBeenCalledTimes(4);
|
|
1008
|
+
});
|
|
1009
|
+
});
|
|
1010
|
+
});
|