pixelweaver 0.1.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/.env.development +1 -0
- package/.github/workflows/ci.yml +22 -0
- package/.github/workflows/publish.yml +18 -0
- package/.prettierignore +5 -0
- package/.prettierrc +16 -0
- package/.python-version +1 -0
- package/.rlsbl/bases/.github/workflows/ci.yml +21 -0
- package/.rlsbl/bases/.github/workflows/publish.yml +18 -0
- package/.rlsbl/bases/.rlsbl/changes/unreleased.jsonl +0 -0
- package/.rlsbl/bases/.rlsbl/hooks/post-release.sh +8 -0
- package/.rlsbl/bases/.rlsbl/hooks/pre-checks.sh +5 -0
- package/.rlsbl/bases/.rlsbl/hooks/pre-release.sh +8 -0
- package/.rlsbl/bases/.rlsbl/lint/go.toml +17 -0
- package/.rlsbl/bases/.rlsbl/lint/npm.toml +19 -0
- package/.rlsbl/bases/.rlsbl/lint/python.toml +25 -0
- package/.rlsbl/bases/CHANGELOG.md +5 -0
- package/.rlsbl/bases/LICENSE +21 -0
- package/.rlsbl/changes/.validated +1 -0
- package/.rlsbl/changes/0.1.0.jsonl +85 -0
- package/.rlsbl/changes/0.1.0.md +13 -0
- package/.rlsbl/changes/unreleased.jsonl +0 -0
- package/.rlsbl/config.json +6 -0
- package/.rlsbl/hashes.json +14 -0
- package/.rlsbl/hooks/post-release.sh +8 -0
- package/.rlsbl/hooks/pre-checks.sh +5 -0
- package/.rlsbl/hooks/pre-release.sh +8 -0
- package/.rlsbl/lint/go.toml +17 -0
- package/.rlsbl/lint/npm.toml +19 -0
- package/.rlsbl/lint/python.toml +25 -0
- package/.rlsbl/releases/unreleased.toml +0 -0
- package/.rlsbl/releases/v0.1.0.toml +3 -0
- package/.rlsbl/version +1 -0
- package/.selfdoc/hashes/hashes.json +146 -0
- package/.strictcli/schema.json +227 -0
- package/CHANGELOG.md +17 -0
- package/CLAUDE.md +100 -0
- package/LICENSE +21 -0
- package/README.md +116 -0
- package/assets/icon.png +0 -0
- package/docs/_README.md +117 -0
- package/docs/cli-config.md +35 -0
- package/docs/cli-dev.md +21 -0
- package/docs/cli-diagnose.md +12 -0
- package/docs/cli-index.md +30 -0
- package/docs/cli-list.md +18 -0
- package/docs/cli-mcp.md +18 -0
- package/docs/cli-new.md +26 -0
- package/docs/cli-open.md +21 -0
- package/docs/cli-serve.md +21 -0
- package/docs/cli-stop.md +12 -0
- package/docs/gen-index.md +36 -0
- package/docs/index.md +13 -0
- package/docs/server-src-pixelweaver-__main__.md +12 -0
- package/docs/server-src-pixelweaver-autosave.md +12 -0
- package/docs/server-src-pixelweaver-bridge.md +12 -0
- package/docs/server-src-pixelweaver-cli.md +12 -0
- package/docs/server-src-pixelweaver-config.md +12 -0
- package/docs/server-src-pixelweaver-connections.md +12 -0
- package/docs/server-src-pixelweaver-main.md +12 -0
- package/docs/server-src-pixelweaver-mcp_bridge.md +12 -0
- package/docs/server-src-pixelweaver-mcp_drawing_tools.md +12 -0
- package/docs/server-src-pixelweaver-mcp_export_tools.md +12 -0
- package/docs/server-src-pixelweaver-mcp_frame_tools.md +12 -0
- package/docs/server-src-pixelweaver-mcp_history_tools.md +12 -0
- package/docs/server-src-pixelweaver-mcp_layer_tools.md +12 -0
- package/docs/server-src-pixelweaver-mcp_lock.md +12 -0
- package/docs/server-src-pixelweaver-mcp_project_tools.md +12 -0
- package/docs/server-src-pixelweaver-mcp_read_tools.md +12 -0
- package/docs/server-src-pixelweaver-mcp_registry.md +12 -0
- package/docs/server-src-pixelweaver-mcp_resources.md +12 -0
- package/docs/server-src-pixelweaver-mcp_server.md +12 -0
- package/docs/server-src-pixelweaver-protocol.md +12 -0
- package/docs/server-src-pixelweaver-state.md +12 -0
- package/docs/server-src-pixelweaver-storage.md +12 -0
- package/docs/server-src-pixelweaver-websocket.md +12 -0
- package/docs/server-src-pixelweaver.md +12 -0
- package/e2e/app-launch.test.ts +35 -0
- package/e2e/menus.test.ts +26 -0
- package/e2e/tools.test.ts +27 -0
- package/e2e/undo-redo.test.ts +11 -0
- package/eslint.config.js +62 -0
- package/index.html +13 -0
- package/package.json +48 -0
- package/playwright.config.ts +19 -0
- package/plugins/builtin/.gitkeep +0 -0
- package/plugins/builtin/advanced-fill-tool.ts +146 -0
- package/plugins/builtin/circle-tool.ts +186 -0
- package/plugins/builtin/diamond-tool.ts +182 -0
- package/plugins/builtin/dither-tool.ts +186 -0
- package/plugins/builtin/drawing-primitives-plugin.ts +362 -0
- package/plugins/builtin/drawing-utils.test.ts +495 -0
- package/plugins/builtin/drawing-utils.ts +431 -0
- package/plugins/builtin/effects/blur.ts +97 -0
- package/plugins/builtin/effects/color-effects.ts +278 -0
- package/plugins/builtin/effects/flip.ts +83 -0
- package/plugins/builtin/effects/glow.ts +118 -0
- package/plugins/builtin/effects/outline.ts +110 -0
- package/plugins/builtin/effects/rotate.ts +357 -0
- package/plugins/builtin/effects/scale.ts +258 -0
- package/plugins/builtin/effects/shadow.ts +111 -0
- package/plugins/builtin/effects/sharpen.ts +102 -0
- package/plugins/builtin/effects.test.ts +715 -0
- package/plugins/builtin/eraser-tool.ts +23 -0
- package/plugins/builtin/eyedropper-tool.ts +83 -0
- package/plugins/builtin/fill-tool.ts +93 -0
- package/plugins/builtin/gradient-tool.ts +204 -0
- package/plugins/builtin/gradient.test.ts +142 -0
- package/plugins/builtin/importers/aseprite-importer-plugin.ts +174 -0
- package/plugins/builtin/importers/aseprite-parser.ts +497 -0
- package/plugins/builtin/importers/piskel-importer-plugin.ts +222 -0
- package/plugins/builtin/importers/sky-spec-plugin.ts +409 -0
- package/plugins/builtin/line-tool.ts +267 -0
- package/plugins/builtin/make-stroke-tool.ts +271 -0
- package/plugins/builtin/noise-dither.test.ts +151 -0
- package/plugins/builtin/noise-tool.ts +131 -0
- package/plugins/builtin/pattern-stamp-tool.ts +162 -0
- package/plugins/builtin/pencil-tool.ts +25 -0
- package/plugins/builtin/rect-tool.ts +179 -0
- package/plugins/builtin/selection-tool.ts +388 -0
- package/plugins/builtin/selection.test.ts +195 -0
- package/plugins/builtin/tool-plugins.test.ts +529 -0
- package/public/favicon.svg +7 -0
- package/public/sw.js +91 -0
- package/pyproject.toml +49 -0
- package/scripts/eslint-wave-a-list.sh +24 -0
- package/scripts/eslint-wave-a-status.sh +25 -0
- package/scripts/fix-index-signature-access.py +127 -0
- package/scripts/fix-unchecked-index.py +128 -0
- package/scripts/fix-unchecked-vars.py +127 -0
- package/scripts/fix-wave-a-bangs.py +36 -0
- package/scripts/fix-wave-a-templates.py +108 -0
- package/scripts/fix-wave-b-void-expr.py +167 -0
- package/scripts/generate-command-params.py +540 -0
- package/scripts/migrate-single-frame-to-multi.py +171 -0
- package/scripts/smoke-test.sh +77 -0
- package/selfdoc.json +10 -0
- package/server/README.md +0 -0
- package/server/src/pixelweaver/__init__.py +1 -0
- package/server/src/pixelweaver/__main__.py +4 -0
- package/server/src/pixelweaver/autosave.py +114 -0
- package/server/src/pixelweaver/bridge.py +127 -0
- package/server/src/pixelweaver/cli.py +199 -0
- package/server/src/pixelweaver/config.py +24 -0
- package/server/src/pixelweaver/connections.py +54 -0
- package/server/src/pixelweaver/main.py +271 -0
- package/server/src/pixelweaver/mcp_bridge.py +189 -0
- package/server/src/pixelweaver/mcp_drawing_tools.py +178 -0
- package/server/src/pixelweaver/mcp_export_tools.py +291 -0
- package/server/src/pixelweaver/mcp_frame_tools.py +423 -0
- package/server/src/pixelweaver/mcp_history_tools.py +106 -0
- package/server/src/pixelweaver/mcp_layer_tools.py +64 -0
- package/server/src/pixelweaver/mcp_lock.py +37 -0
- package/server/src/pixelweaver/mcp_project_tools.py +302 -0
- package/server/src/pixelweaver/mcp_read_tools.py +163 -0
- package/server/src/pixelweaver/mcp_registry.py +247 -0
- package/server/src/pixelweaver/mcp_resources.py +312 -0
- package/server/src/pixelweaver/mcp_server.py +234 -0
- package/server/src/pixelweaver/protocol.py +219 -0
- package/server/src/pixelweaver/state.py +267 -0
- package/server/src/pixelweaver/storage.py +293 -0
- package/server/src/pixelweaver/websocket.py +282 -0
- package/server/tests/__init__.py +0 -0
- package/server/tests/conftest.py +17 -0
- package/server/tests/test_api.py +96 -0
- package/server/tests/test_bridge.py +161 -0
- package/server/tests/test_health.py +9 -0
- package/server/tests/test_integration.py +86 -0
- package/server/tests/test_mcp_bridge.py +293 -0
- package/server/tests/test_mcp_lock.py +34 -0
- package/server/tests/test_mcp_registry.py +679 -0
- package/server/tests/test_mcp_resources.py +648 -0
- package/server/tests/test_protocol.py +125 -0
- package/server/tests/test_state.py +87 -0
- package/server/tests/test_storage.py +306 -0
- package/server/tests/test_websocket.py +275 -0
- package/src/App.svelte +107 -0
- package/src/app.css +215 -0
- package/src/lib/animation/AnimationPreviewPanel.svelte +667 -0
- package/src/lib/animation/animation-commands.test.ts +228 -0
- package/src/lib/animation/animation-commands.ts +540 -0
- package/src/lib/animation/animation-preview-panel-plugin.ts +25 -0
- package/src/lib/animation/animation-preview.svelte.ts +151 -0
- package/src/lib/animation/clipboard.ts +134 -0
- package/src/lib/animation/frame-model.svelte.ts +437 -0
- package/src/lib/animation/frame-model.test.ts +314 -0
- package/src/lib/animation/frame-selection.svelte.ts +77 -0
- package/src/lib/animation/frame-tags.svelte.ts +238 -0
- package/src/lib/animation/frame-tags.test.ts +136 -0
- package/src/lib/animation/import.test.ts +141 -0
- package/src/lib/animation/import.ts +112 -0
- package/src/lib/animation/spritesheet-export.test.ts +239 -0
- package/src/lib/animation/spritesheet-export.ts +314 -0
- package/src/lib/canvas/CanvasViewport.svelte +216 -0
- package/src/lib/canvas/canvas-init-plugin.ts +178 -0
- package/src/lib/canvas/canvas-renderer.ts +408 -0
- package/src/lib/canvas/canvas-state.svelte.ts +232 -0
- package/src/lib/canvas/canvas-state.test.ts +139 -0
- package/src/lib/canvas/input-handler.ts +221 -0
- package/src/lib/canvas/input-plugin.ts +150 -0
- package/src/lib/canvas/onion-skin.ts +94 -0
- package/src/lib/canvas/pixel-buffer.test.ts +249 -0
- package/src/lib/canvas/pixel-buffer.ts +151 -0
- package/src/lib/canvas/render-state.svelte.ts +18 -0
- package/src/lib/canvas/shape-preview-state.svelte.ts +36 -0
- package/src/lib/canvas/tile-mode.test.ts +53 -0
- package/src/lib/canvas/tile-mode.ts +92 -0
- package/src/lib/canvas/viewport-utils.ts +24 -0
- package/src/lib/canvas/zoom-utils.ts +31 -0
- package/src/lib/color/.gitkeep +0 -0
- package/src/lib/color/color-commands.ts +87 -0
- package/src/lib/color/color-state.svelte.ts +98 -0
- package/src/lib/color/color-state.test.ts +91 -0
- package/src/lib/color/color-utils.test.ts +220 -0
- package/src/lib/color/color-utils.ts +243 -0
- package/src/lib/color/palette-state.svelte.ts +127 -0
- package/src/lib/color/palette-state.test.ts +154 -0
- package/src/lib/color/palette.ts +79 -0
- package/src/lib/core/bootstrap.ts +66 -0
- package/src/lib/core/command-params.generated.ts +1549 -0
- package/src/lib/core/command-params.ts +20 -0
- package/src/lib/core/command-runner.ts +79 -0
- package/src/lib/core/commands.ts +134 -0
- package/src/lib/core/dispatcher.test.ts +548 -0
- package/src/lib/core/dispatcher.ts +361 -0
- package/src/lib/core/index.test.ts +7 -0
- package/src/lib/core/notification-state.svelte.ts +119 -0
- package/src/lib/core/plugin-api.ts +210 -0
- package/src/lib/core/plugin-discovery.test.ts +53 -0
- package/src/lib/core/plugin-discovery.ts +65 -0
- package/src/lib/core/plugin-loader.test.ts +159 -0
- package/src/lib/core/plugin-loader.ts +240 -0
- package/src/lib/core/plugin-types.ts +286 -0
- package/src/lib/core/registries.svelte.ts +74 -0
- package/src/lib/core/tool-options-state.svelte.ts +61 -0
- package/src/lib/dock/DockLayout.svelte +375 -0
- package/src/lib/dock/TabAddMenu.svelte +90 -0
- package/src/lib/dock/dock-persistence.ts +46 -0
- package/src/lib/dock/dock-plugin.ts +49 -0
- package/src/lib/dock/dock-presets.ts +156 -0
- package/src/lib/dock/dock-state.svelte.ts +77 -0
- package/src/lib/dock/dock-types.ts +2 -0
- package/src/lib/dock/dockview-theme.css +226 -0
- package/src/lib/dock/dockview-theme.ts +17 -0
- package/src/lib/dock/header-action-renderer.ts +77 -0
- package/src/lib/edit/clipboard-state.ts +34 -0
- package/src/lib/export/download.ts +201 -0
- package/src/lib/export/png-metadata.ts +181 -0
- package/src/lib/history/ActionLogPanel.svelte +418 -0
- package/src/lib/history/action-log-panel-plugin.ts +24 -0
- package/src/lib/history/action-log.svelte.ts +172 -0
- package/src/lib/history/action-log.test.ts +168 -0
- package/src/lib/history/history-commands.ts +403 -0
- package/src/lib/history/macros.svelte.ts +320 -0
- package/src/lib/history/macros.test.ts +224 -0
- package/src/lib/history/replay-engine.test.ts +241 -0
- package/src/lib/history/replay-engine.ts +149 -0
- package/src/lib/history/spec-format.test.ts +250 -0
- package/src/lib/history/spec-format.ts +210 -0
- package/src/lib/iso/SeamCheckerPanel.svelte +251 -0
- package/src/lib/iso/iso-math.test.ts +77 -0
- package/src/lib/iso/iso-math.ts +77 -0
- package/src/lib/iso/seam-checker-panel-plugin.ts +25 -0
- package/src/lib/iso/seam-checker.test.ts +126 -0
- package/src/lib/iso/seam-checker.ts +93 -0
- package/src/lib/iso/tessellation.ts +67 -0
- package/src/lib/layers/compositor.test.ts +193 -0
- package/src/lib/layers/compositor.ts +175 -0
- package/src/lib/layers/layer-commands.test.ts +263 -0
- package/src/lib/layers/layer-commands.ts +429 -0
- package/src/lib/layers/layer-tree.svelte.ts +516 -0
- package/src/lib/layers/layer-tree.test.ts +383 -0
- package/src/lib/layers/layer-types.ts +56 -0
- package/src/lib/leveleditor/LevelEditorViewport.svelte +1808 -0
- package/src/lib/leveleditor/MapPropertiesPanel.svelte +266 -0
- package/src/lib/leveleditor/TilePickerPanel.svelte +324 -0
- package/src/lib/leveleditor/depth-sort.test.ts +70 -0
- package/src/lib/leveleditor/depth-sort.ts +39 -0
- package/src/lib/leveleditor/level-editor-commands.ts +353 -0
- package/src/lib/leveleditor/level-editor-viewport-plugin.ts +25 -0
- package/src/lib/leveleditor/map-properties-panel-plugin.ts +25 -0
- package/src/lib/leveleditor/map-state.svelte.ts +372 -0
- package/src/lib/leveleditor/map-state.test.ts +243 -0
- package/src/lib/leveleditor/tile-picker-panel-plugin.ts +25 -0
- package/src/lib/leveleditor/tile-source-registry.svelte.ts +91 -0
- package/src/lib/leveleditor/tiled-export.test.ts +144 -0
- package/src/lib/leveleditor/tiled-export.ts +374 -0
- package/src/lib/pywebview.d.ts +15 -0
- package/src/lib/recovery/.gitkeep +0 -0
- package/src/lib/recovery/idb-store.ts +118 -0
- package/src/lib/recovery/recovery-manager.test.ts +321 -0
- package/src/lib/recovery/recovery-manager.ts +115 -0
- package/src/lib/recovery/recovery-plugin.ts +55 -0
- package/src/lib/recovery/recovery-state.svelte.ts +21 -0
- package/src/lib/recovery/sw-plugin.ts +18 -0
- package/src/lib/recovery/sw-register.ts +17 -0
- package/src/lib/save/directory-format.ts +42 -0
- package/src/lib/save/project-snapshot.ts +139 -0
- package/src/lib/save/recent-projects.ts +56 -0
- package/src/lib/save/save-state.svelte.ts +35 -0
- package/src/lib/save/storage.ts +167 -0
- package/src/lib/save/zip-format.ts +45 -0
- package/src/lib/shortcuts/.gitkeep +0 -0
- package/src/lib/shortcuts/ShortcutEditorPanel.svelte +690 -0
- package/src/lib/shortcuts/default-bindings.ts +61 -0
- package/src/lib/shortcuts/shortcut-editor-panel-plugin.ts +25 -0
- package/src/lib/shortcuts/shortcut-init.ts +380 -0
- package/src/lib/shortcuts/shortcut-manager.test.ts +466 -0
- package/src/lib/shortcuts/shortcut-manager.ts +281 -0
- package/src/lib/shortcuts/shortcut-state.svelte.ts +78 -0
- package/src/lib/shortcuts/shortcuts-plugin.ts +17 -0
- package/src/lib/sync/patch-applicator.ts +300 -0
- package/src/lib/sync/patch-types.ts +65 -0
- package/src/lib/sync/project-manager.ts +108 -0
- package/src/lib/sync/sync-init.ts +152 -0
- package/src/lib/sync/sync-plugin.ts +19 -0
- package/src/lib/sync/sync-state.svelte.ts +56 -0
- package/src/lib/sync/ws-client.test.ts +604 -0
- package/src/lib/sync/ws-client.ts +574 -0
- package/src/lib/tools/.gitkeep +0 -0
- package/src/lib/ui/.gitkeep +0 -0
- package/src/lib/ui/AboutDialog.svelte +113 -0
- package/src/lib/ui/ColorPicker.svelte +761 -0
- package/src/lib/ui/ContextMenu.svelte +216 -0
- package/src/lib/ui/ExportDialog.svelte +747 -0
- package/src/lib/ui/FrameStrip.svelte +854 -0
- package/src/lib/ui/LayerPanel.svelte +810 -0
- package/src/lib/ui/MenuBar.svelte +590 -0
- package/src/lib/ui/NewProjectDialog.svelte +803 -0
- package/src/lib/ui/PluginManagerPanel.svelte +475 -0
- package/src/lib/ui/PromptDialog.svelte +252 -0
- package/src/lib/ui/RecoveryDialog.svelte +295 -0
- package/src/lib/ui/ResizeDialog.svelte +416 -0
- package/src/lib/ui/StatusBar.svelte +145 -0
- package/src/lib/ui/ToolbarPanel.svelte +488 -0
- package/src/lib/ui/animation-menu-commands.ts +194 -0
- package/src/lib/ui/command-palette/CommandPalette.svelte +232 -0
- package/src/lib/ui/command-palette/command-palette-plugin.ts +30 -0
- package/src/lib/ui/command-palette/command-palette-state.svelte.ts +190 -0
- package/src/lib/ui/command-palette/command-palette.test.ts +129 -0
- package/src/lib/ui/dialog-state.svelte.ts +70 -0
- package/src/lib/ui/edit-commands.ts +271 -0
- package/src/lib/ui/file-commands.ts +275 -0
- package/src/lib/ui/file-open.ts +99 -0
- package/src/lib/ui/help-commands.ts +93 -0
- package/src/lib/ui/image-commands.ts +181 -0
- package/src/lib/ui/layer-menu-commands.ts +420 -0
- package/src/lib/ui/menu-builder.ts +224 -0
- package/src/lib/ui/notifications/NotificationBanner.svelte +137 -0
- package/src/lib/ui/notifications/notification-plugin.ts +29 -0
- package/src/lib/ui/notifications/notification-state.svelte.ts +9 -0
- package/src/lib/ui/plugin-manager-panel-plugin.ts +26 -0
- package/src/lib/ui/plugin-state.svelte.ts +62 -0
- package/src/lib/ui/select-commands.ts +75 -0
- package/src/lib/ui/theme-plugin.ts +18 -0
- package/src/lib/ui/theme.svelte.ts +45 -0
- package/src/lib/ui/theme.test.ts +51 -0
- package/src/lib/ui/toolbar-config.ts +90 -0
- package/src/lib/ui/toolbar-plugin.ts +39 -0
- package/src/lib/ui/view-commands.ts +629 -0
- package/src/lib/variants/BisectionExportDialog.svelte +500 -0
- package/src/lib/variants/VariantPanel.svelte +822 -0
- package/src/lib/variants/bisection-export.test.ts +113 -0
- package/src/lib/variants/bisection-export.ts +148 -0
- package/src/lib/variants/palette-extraction.test.ts +111 -0
- package/src/lib/variants/palette-extraction.ts +84 -0
- package/src/lib/variants/palette-interpolation.test.ts +113 -0
- package/src/lib/variants/palette-interpolation.ts +87 -0
- package/src/lib/variants/palette-swap.test.ts +101 -0
- package/src/lib/variants/palette-swap.ts +114 -0
- package/src/lib/variants/variant-commands.ts +594 -0
- package/src/lib/variants/variant-panel-plugin.ts +27 -0
- package/src/lib/variants/variant-randomizer.ts +101 -0
- package/src/lib/variants/variant-state.svelte.ts +166 -0
- package/src/lib/variants/variant-state.test.ts +138 -0
- package/src/main.ts +14 -0
- package/src/vite-env.d.ts +3 -0
- package/svelte.config.js +2 -0
- package/todo/.done/audit-design-decisions.md +812 -0
- package/todo/.done/audit-implementation-plan.md +1235 -0
- package/todo/.done/happy-path-polish.md +177 -0
- package/todo/.done/pixelweaver-full-build.md +937 -0
- package/todo/.done/server-multi-frame-design.md +405 -0
- package/todo/.done/typed-dispatcher-design.md +435 -0
- package/todo/.done/unified-toolbar-and-action-system.md +323 -0
- package/todo/.obsolete/comprehensive-audit-obsolete-items.md +33 -0
- package/todo/.obsolete/tauri-desktop-bundle.md +424 -0
- package/todo/comprehensive-audit.md +1085 -0
- package/tsconfig.app.json +26 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +20 -0
- package/uv.lock +1167 -0
- package/vite.config.ts +32 -0
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
"""Tests for MCP resources and subscription tracking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from pydantic import AnyUrl
|
|
10
|
+
|
|
11
|
+
from pixelweaver.mcp_bridge import CollabServerUnreachableError
|
|
12
|
+
from pixelweaver.state import CanvasState, FrameState, ProjectState, ServerState
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Fixtures
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
@pytest.fixture(autouse=True)
|
|
20
|
+
def _reset_subscriptions():
|
|
21
|
+
"""Clear the module-level subscription set before and after each test."""
|
|
22
|
+
from pixelweaver import mcp_resources
|
|
23
|
+
mcp_resources._subscribed_uris.clear()
|
|
24
|
+
yield
|
|
25
|
+
mcp_resources._subscribed_uris.clear()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture()
|
|
29
|
+
def state_with_project() -> ServerState:
|
|
30
|
+
"""ServerState with a single 4x4 project, one canvas, one layer, one frame."""
|
|
31
|
+
s = ServerState()
|
|
32
|
+
s.create_project("test-project", 4, 4)
|
|
33
|
+
return s
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.fixture()
|
|
37
|
+
def state_with_painted_pixel(state_with_project: ServerState) -> ServerState:
|
|
38
|
+
"""State with a red pixel at (0,0) so the palette resource has colors."""
|
|
39
|
+
canvas = next(iter(state_with_project.get_active_project().canvases.values()))
|
|
40
|
+
layer_id = canvas.layers[0]["id"]
|
|
41
|
+
data = bytearray(canvas.current_frame().pixel_data[layer_id])
|
|
42
|
+
data[0:4] = [255, 0, 0, 255] # red pixel at (0,0)
|
|
43
|
+
canvas.current_frame().pixel_data[layer_id] = bytes(data)
|
|
44
|
+
return state_with_project
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _patch_synced_state(state: ServerState):
|
|
48
|
+
"""Return a context manager that patches _synced_state to return the given state."""
|
|
49
|
+
return patch(
|
|
50
|
+
"pixelweaver.mcp_resources._synced_state",
|
|
51
|
+
new_callable=AsyncMock,
|
|
52
|
+
return_value=state,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Helpers to extract resource handlers from a FastMCP mock
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def _register_resources_on_mock() -> tuple[MagicMock, dict[str, Any]]:
|
|
61
|
+
"""Create a FastMCP mock, call init_mcp_resources, return (mock, handlers).
|
|
62
|
+
|
|
63
|
+
handlers is a dict mapping resource URI templates to (handler_fn, kwargs).
|
|
64
|
+
"""
|
|
65
|
+
from pixelweaver.mcp_resources import init_mcp_resources
|
|
66
|
+
|
|
67
|
+
mcp = MagicMock()
|
|
68
|
+
registered: dict[str, tuple] = {}
|
|
69
|
+
|
|
70
|
+
def fake_resource(uri_template: str, **kwargs):
|
|
71
|
+
"""Capture the decorator registration."""
|
|
72
|
+
def decorator(fn):
|
|
73
|
+
registered[uri_template] = (fn, kwargs)
|
|
74
|
+
return fn
|
|
75
|
+
return decorator
|
|
76
|
+
|
|
77
|
+
mcp.resource = fake_resource
|
|
78
|
+
init_mcp_resources(mcp)
|
|
79
|
+
return mcp, registered
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Tests: init_mcp_resources registration
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
class TestInitMcpResources:
|
|
87
|
+
def test_registers_all_four_resources(self):
|
|
88
|
+
"""init_mcp_resources registers all four expected resource URIs."""
|
|
89
|
+
_, registered = _register_resources_on_mock()
|
|
90
|
+
expected = {
|
|
91
|
+
"project://state",
|
|
92
|
+
"project://canvas/{name}",
|
|
93
|
+
"project://canvas/{name}/frame/{index}",
|
|
94
|
+
"project://palette",
|
|
95
|
+
}
|
|
96
|
+
assert set(registered.keys()) == expected
|
|
97
|
+
|
|
98
|
+
def test_resource_names(self):
|
|
99
|
+
"""Each resource has the expected name kwarg."""
|
|
100
|
+
_, registered = _register_resources_on_mock()
|
|
101
|
+
assert registered["project://state"][1]["name"] == "project_state"
|
|
102
|
+
assert registered["project://canvas/{name}"][1]["name"] == "canvas_info"
|
|
103
|
+
assert registered["project://canvas/{name}/frame/{index}"][1]["name"] == "frame_png"
|
|
104
|
+
assert registered["project://palette"][1]["name"] == "palette"
|
|
105
|
+
|
|
106
|
+
def test_resource_mime_types(self):
|
|
107
|
+
"""Each resource specifies the correct mime_type."""
|
|
108
|
+
_, registered = _register_resources_on_mock()
|
|
109
|
+
assert registered["project://state"][1]["mime_type"] == "application/json"
|
|
110
|
+
assert registered["project://canvas/{name}"][1]["mime_type"] == "application/json"
|
|
111
|
+
assert registered["project://canvas/{name}/frame/{index}"][1]["mime_type"] == "image/png"
|
|
112
|
+
assert registered["project://palette"][1]["mime_type"] == "application/json"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Tests: project://state resource handler
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
class TestProjectStateResource:
|
|
120
|
+
async def test_returns_project_overview(self, state_with_project: ServerState):
|
|
121
|
+
"""project://state returns project name, dimensions, and canvas info."""
|
|
122
|
+
_, registered = _register_resources_on_mock()
|
|
123
|
+
handler = registered["project://state"][0]
|
|
124
|
+
|
|
125
|
+
with _patch_synced_state(state_with_project):
|
|
126
|
+
result = await handler()
|
|
127
|
+
|
|
128
|
+
data = json.loads(result)
|
|
129
|
+
assert data["name"] == "test-project"
|
|
130
|
+
assert data["width"] == 4
|
|
131
|
+
assert data["height"] == 4
|
|
132
|
+
assert "canvas-0" in data["canvases"]
|
|
133
|
+
|
|
134
|
+
async def test_canvas_info_structure(self, state_with_project: ServerState):
|
|
135
|
+
"""Canvas entries contain layers, frame_count, and playback settings."""
|
|
136
|
+
_, registered = _register_resources_on_mock()
|
|
137
|
+
handler = registered["project://state"][0]
|
|
138
|
+
|
|
139
|
+
with _patch_synced_state(state_with_project):
|
|
140
|
+
result = await handler()
|
|
141
|
+
|
|
142
|
+
canvas = json.loads(result)["canvases"]["canvas-0"]
|
|
143
|
+
assert canvas["name"] == "canvas-0"
|
|
144
|
+
assert canvas["width"] == 4
|
|
145
|
+
assert canvas["height"] == 4
|
|
146
|
+
assert canvas["layer_count"] == 1
|
|
147
|
+
assert len(canvas["layers"]) == 1
|
|
148
|
+
assert canvas["frame_count"] == 1
|
|
149
|
+
assert canvas["current_frame_index"] == 0
|
|
150
|
+
assert canvas["global_fps"] == 12.0
|
|
151
|
+
|
|
152
|
+
async def test_layer_info_structure(self, state_with_project: ServerState):
|
|
153
|
+
"""Layer entries contain id, name, visible, and opacity."""
|
|
154
|
+
_, registered = _register_resources_on_mock()
|
|
155
|
+
handler = registered["project://state"][0]
|
|
156
|
+
|
|
157
|
+
with _patch_synced_state(state_with_project):
|
|
158
|
+
result = await handler()
|
|
159
|
+
|
|
160
|
+
layer = json.loads(result)["canvases"]["canvas-0"]["layers"][0]
|
|
161
|
+
assert "id" in layer
|
|
162
|
+
assert layer["name"] == "Layer 0"
|
|
163
|
+
assert layer["visible"] is True
|
|
164
|
+
assert layer["opacity"] == 1.0
|
|
165
|
+
|
|
166
|
+
async def test_no_active_project(self):
|
|
167
|
+
"""Returns error JSON when no active project exists."""
|
|
168
|
+
empty_state = ServerState()
|
|
169
|
+
_, registered = _register_resources_on_mock()
|
|
170
|
+
handler = registered["project://state"][0]
|
|
171
|
+
|
|
172
|
+
with _patch_synced_state(empty_state):
|
|
173
|
+
result = await handler()
|
|
174
|
+
|
|
175
|
+
data = json.loads(result)
|
|
176
|
+
assert "error" in data
|
|
177
|
+
assert "No active project" in data["error"]
|
|
178
|
+
|
|
179
|
+
async def test_collab_server_unreachable(self):
|
|
180
|
+
"""Returns error JSON when the collab server is unreachable."""
|
|
181
|
+
_, registered = _register_resources_on_mock()
|
|
182
|
+
handler = registered["project://state"][0]
|
|
183
|
+
|
|
184
|
+
with patch(
|
|
185
|
+
"pixelweaver.mcp_resources._synced_state",
|
|
186
|
+
new_callable=AsyncMock,
|
|
187
|
+
side_effect=CollabServerUnreachableError("connection refused"),
|
|
188
|
+
):
|
|
189
|
+
result = await handler()
|
|
190
|
+
|
|
191
|
+
data = json.loads(result)
|
|
192
|
+
assert "error" in data
|
|
193
|
+
assert "Collab server unreachable" in data["error"]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
# Tests: project://canvas/{name} resource handler
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
class TestCanvasInfoResource:
|
|
201
|
+
async def test_returns_canvas_metadata(self, state_with_project: ServerState):
|
|
202
|
+
"""canvas_info returns dimensions, layers, frames, and playback."""
|
|
203
|
+
_, registered = _register_resources_on_mock()
|
|
204
|
+
handler = registered["project://canvas/{name}"][0]
|
|
205
|
+
|
|
206
|
+
with _patch_synced_state(state_with_project):
|
|
207
|
+
result = await handler("canvas-0")
|
|
208
|
+
|
|
209
|
+
data = json.loads(result)
|
|
210
|
+
assert data["name"] == "canvas-0"
|
|
211
|
+
assert data["width"] == 4
|
|
212
|
+
assert data["height"] == 4
|
|
213
|
+
assert len(data["layers"]) == 1
|
|
214
|
+
assert data["frame_count"] == 1
|
|
215
|
+
assert data["current_frame_index"] == 0
|
|
216
|
+
assert data["global_fps"] == 12.0
|
|
217
|
+
|
|
218
|
+
async def test_layer_includes_extra_fields(self, state_with_project: ServerState):
|
|
219
|
+
"""Layer entries in canvas_info include locked and blend_mode."""
|
|
220
|
+
_, registered = _register_resources_on_mock()
|
|
221
|
+
handler = registered["project://canvas/{name}"][0]
|
|
222
|
+
|
|
223
|
+
with _patch_synced_state(state_with_project):
|
|
224
|
+
result = await handler("canvas-0")
|
|
225
|
+
|
|
226
|
+
layer = json.loads(result)["layers"][0]
|
|
227
|
+
assert "locked" in layer
|
|
228
|
+
assert "blend_mode" in layer
|
|
229
|
+
assert layer["locked"] is False
|
|
230
|
+
assert layer["blend_mode"] == "normal"
|
|
231
|
+
|
|
232
|
+
async def test_canvas_not_found(self, state_with_project: ServerState):
|
|
233
|
+
"""Returns error when canvas name does not exist."""
|
|
234
|
+
_, registered = _register_resources_on_mock()
|
|
235
|
+
handler = registered["project://canvas/{name}"][0]
|
|
236
|
+
|
|
237
|
+
with _patch_synced_state(state_with_project):
|
|
238
|
+
result = await handler("nonexistent")
|
|
239
|
+
|
|
240
|
+
data = json.loads(result)
|
|
241
|
+
assert "error" in data
|
|
242
|
+
assert "not found" in data["error"]
|
|
243
|
+
|
|
244
|
+
async def test_no_active_project(self):
|
|
245
|
+
"""Returns error when no active project exists."""
|
|
246
|
+
empty_state = ServerState()
|
|
247
|
+
_, registered = _register_resources_on_mock()
|
|
248
|
+
handler = registered["project://canvas/{name}"][0]
|
|
249
|
+
|
|
250
|
+
with _patch_synced_state(empty_state):
|
|
251
|
+
result = await handler("canvas-0")
|
|
252
|
+
|
|
253
|
+
data = json.loads(result)
|
|
254
|
+
assert "error" in data
|
|
255
|
+
assert "No active project" in data["error"]
|
|
256
|
+
|
|
257
|
+
async def test_collab_server_unreachable(self):
|
|
258
|
+
"""Returns error JSON when the collab server is unreachable."""
|
|
259
|
+
_, registered = _register_resources_on_mock()
|
|
260
|
+
handler = registered["project://canvas/{name}"][0]
|
|
261
|
+
|
|
262
|
+
with patch(
|
|
263
|
+
"pixelweaver.mcp_resources._synced_state",
|
|
264
|
+
new_callable=AsyncMock,
|
|
265
|
+
side_effect=CollabServerUnreachableError("timeout"),
|
|
266
|
+
):
|
|
267
|
+
result = await handler("canvas-0")
|
|
268
|
+
|
|
269
|
+
data = json.loads(result)
|
|
270
|
+
assert "error" in data
|
|
271
|
+
assert "Collab server unreachable" in data["error"]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ---------------------------------------------------------------------------
|
|
275
|
+
# Tests: project://palette resource handler
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
class TestPaletteResource:
|
|
279
|
+
async def test_returns_colors(self, state_with_painted_pixel: ServerState):
|
|
280
|
+
"""palette returns sorted hex colors from canvas pixel data."""
|
|
281
|
+
_, registered = _register_resources_on_mock()
|
|
282
|
+
handler = registered["project://palette"][0]
|
|
283
|
+
|
|
284
|
+
with _patch_synced_state(state_with_painted_pixel):
|
|
285
|
+
result = await handler()
|
|
286
|
+
|
|
287
|
+
data = json.loads(result)
|
|
288
|
+
assert "colors" in data
|
|
289
|
+
assert "#ff0000ff" in data["colors"]
|
|
290
|
+
|
|
291
|
+
async def test_empty_canvas_returns_no_colors(self, state_with_project: ServerState):
|
|
292
|
+
"""palette returns empty color list for a fully transparent canvas."""
|
|
293
|
+
_, registered = _register_resources_on_mock()
|
|
294
|
+
handler = registered["project://palette"][0]
|
|
295
|
+
|
|
296
|
+
with _patch_synced_state(state_with_project):
|
|
297
|
+
result = await handler()
|
|
298
|
+
|
|
299
|
+
data = json.loads(result)
|
|
300
|
+
assert data["colors"] == []
|
|
301
|
+
|
|
302
|
+
async def test_no_active_project(self):
|
|
303
|
+
"""Returns error when no active project."""
|
|
304
|
+
empty_state = ServerState()
|
|
305
|
+
_, registered = _register_resources_on_mock()
|
|
306
|
+
handler = registered["project://palette"][0]
|
|
307
|
+
|
|
308
|
+
with _patch_synced_state(empty_state):
|
|
309
|
+
result = await handler()
|
|
310
|
+
|
|
311
|
+
data = json.loads(result)
|
|
312
|
+
assert "error" in data
|
|
313
|
+
assert "No active project" in data["error"]
|
|
314
|
+
|
|
315
|
+
async def test_no_canvas_in_project(self):
|
|
316
|
+
"""Returns error when the project has no canvases."""
|
|
317
|
+
state = ServerState()
|
|
318
|
+
state.create_project("empty-proj", 4, 4)
|
|
319
|
+
# Remove all canvases
|
|
320
|
+
state.projects["empty-proj"].canvases.clear()
|
|
321
|
+
|
|
322
|
+
_, registered = _register_resources_on_mock()
|
|
323
|
+
handler = registered["project://palette"][0]
|
|
324
|
+
|
|
325
|
+
with _patch_synced_state(state):
|
|
326
|
+
result = await handler()
|
|
327
|
+
|
|
328
|
+
data = json.loads(result)
|
|
329
|
+
assert "error" in data
|
|
330
|
+
assert "No canvas" in data["error"]
|
|
331
|
+
|
|
332
|
+
async def test_collab_server_unreachable(self):
|
|
333
|
+
"""Returns error JSON when the collab server is unreachable."""
|
|
334
|
+
_, registered = _register_resources_on_mock()
|
|
335
|
+
handler = registered["project://palette"][0]
|
|
336
|
+
|
|
337
|
+
with patch(
|
|
338
|
+
"pixelweaver.mcp_resources._synced_state",
|
|
339
|
+
new_callable=AsyncMock,
|
|
340
|
+
side_effect=CollabServerUnreachableError("down"),
|
|
341
|
+
):
|
|
342
|
+
result = await handler()
|
|
343
|
+
|
|
344
|
+
data = json.loads(result)
|
|
345
|
+
assert "error" in data
|
|
346
|
+
assert "Collab server unreachable" in data["error"]
|
|
347
|
+
|
|
348
|
+
async def test_max_colors_cap(self, state_with_project: ServerState):
|
|
349
|
+
"""palette caps at 256 colors even when more exist."""
|
|
350
|
+
canvas = next(iter(state_with_project.get_active_project().canvases.values()))
|
|
351
|
+
layer_id = canvas.layers[0]["id"]
|
|
352
|
+
|
|
353
|
+
# Create a canvas large enough to hold >256 unique colors.
|
|
354
|
+
# We need at least 257 unique RGBA values with a > 0.
|
|
355
|
+
# Resize canvas to 18x18 = 324 pixels (enough for 257+ unique colors).
|
|
356
|
+
canvas.width = 18
|
|
357
|
+
canvas.height = 18
|
|
358
|
+
big_data = bytearray(18 * 18 * 4)
|
|
359
|
+
for i in range(18 * 18):
|
|
360
|
+
# Generate unique color per pixel (vary R and G, keep A=255)
|
|
361
|
+
r = i % 256
|
|
362
|
+
g = (i // 256) % 256
|
|
363
|
+
big_data[i * 4] = r
|
|
364
|
+
big_data[i * 4 + 1] = g
|
|
365
|
+
big_data[i * 4 + 2] = 0
|
|
366
|
+
big_data[i * 4 + 3] = 255
|
|
367
|
+
canvas.current_frame().pixel_data[layer_id] = bytes(big_data)
|
|
368
|
+
|
|
369
|
+
_, registered = _register_resources_on_mock()
|
|
370
|
+
handler = registered["project://palette"][0]
|
|
371
|
+
|
|
372
|
+
with _patch_synced_state(state_with_project):
|
|
373
|
+
result = await handler()
|
|
374
|
+
|
|
375
|
+
data = json.loads(result)
|
|
376
|
+
assert len(data["colors"]) == 256
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# ---------------------------------------------------------------------------
|
|
380
|
+
# Tests: project://canvas/{name}/frame/{index} resource handler
|
|
381
|
+
# ---------------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
class TestFramePngResource:
|
|
384
|
+
async def test_returns_png_bytes(self, state_with_project: ServerState):
|
|
385
|
+
"""frame_png returns bytes that look like a PNG."""
|
|
386
|
+
_, registered = _register_resources_on_mock()
|
|
387
|
+
handler = registered["project://canvas/{name}/frame/{index}"][0]
|
|
388
|
+
|
|
389
|
+
with _patch_synced_state(state_with_project), patch(
|
|
390
|
+
"pixelweaver.storage.export_frame_png",
|
|
391
|
+
return_value=b"\x89PNG\r\n\x1a\nfake",
|
|
392
|
+
) as mock_export:
|
|
393
|
+
result = await handler("canvas-0", "0")
|
|
394
|
+
|
|
395
|
+
assert isinstance(result, bytes)
|
|
396
|
+
assert result.startswith(b"\x89PNG")
|
|
397
|
+
mock_export.assert_called_once_with(
|
|
398
|
+
state_with_project.get_active_project(), "canvas-0", 0,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
async def test_invalid_frame_index_raises(self, state_with_project: ServerState):
|
|
402
|
+
"""frame_png raises RuntimeError for non-integer index."""
|
|
403
|
+
_, registered = _register_resources_on_mock()
|
|
404
|
+
handler = registered["project://canvas/{name}/frame/{index}"][0]
|
|
405
|
+
|
|
406
|
+
with _patch_synced_state(state_with_project), pytest.raises(
|
|
407
|
+
RuntimeError, match="Invalid frame index",
|
|
408
|
+
):
|
|
409
|
+
await handler("canvas-0", "not-a-number")
|
|
410
|
+
|
|
411
|
+
async def test_no_active_project_raises(self):
|
|
412
|
+
"""frame_png raises RuntimeError when no active project."""
|
|
413
|
+
empty_state = ServerState()
|
|
414
|
+
_, registered = _register_resources_on_mock()
|
|
415
|
+
handler = registered["project://canvas/{name}/frame/{index}"][0]
|
|
416
|
+
|
|
417
|
+
with _patch_synced_state(empty_state), pytest.raises(
|
|
418
|
+
RuntimeError, match="No active project",
|
|
419
|
+
):
|
|
420
|
+
await handler("canvas-0", "0")
|
|
421
|
+
|
|
422
|
+
async def test_collab_server_unreachable_raises(self):
|
|
423
|
+
"""frame_png raises RuntimeError when collab server is unreachable."""
|
|
424
|
+
_, registered = _register_resources_on_mock()
|
|
425
|
+
handler = registered["project://canvas/{name}/frame/{index}"][0]
|
|
426
|
+
|
|
427
|
+
with patch(
|
|
428
|
+
"pixelweaver.mcp_resources._synced_state",
|
|
429
|
+
new_callable=AsyncMock,
|
|
430
|
+
side_effect=CollabServerUnreachableError("refused"),
|
|
431
|
+
), pytest.raises(RuntimeError, match="Collab server unreachable"):
|
|
432
|
+
await handler("canvas-0", "0")
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
# ---------------------------------------------------------------------------
|
|
436
|
+
# Tests: Subscription tracking
|
|
437
|
+
# ---------------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
class TestSubscriptionTracking:
|
|
440
|
+
async def test_subscribe_adds_uri(self):
|
|
441
|
+
"""Subscribing adds the URI string to the tracked set."""
|
|
442
|
+
from pixelweaver.mcp_resources import _subscribed_uris, init_mcp_subscriptions
|
|
443
|
+
|
|
444
|
+
mcp = MagicMock()
|
|
445
|
+
low_level = MagicMock()
|
|
446
|
+
mcp._mcp_server = low_level
|
|
447
|
+
|
|
448
|
+
# Capture the subscribe handler
|
|
449
|
+
subscribe_handler = None
|
|
450
|
+
|
|
451
|
+
def capture_subscribe():
|
|
452
|
+
def decorator(fn):
|
|
453
|
+
nonlocal subscribe_handler
|
|
454
|
+
subscribe_handler = fn
|
|
455
|
+
return fn
|
|
456
|
+
return decorator
|
|
457
|
+
|
|
458
|
+
low_level.subscribe_resource = capture_subscribe
|
|
459
|
+
low_level.unsubscribe_resource = lambda: lambda fn: fn
|
|
460
|
+
low_level.get_capabilities = MagicMock(return_value=MagicMock(resources=MagicMock(subscribe=False)))
|
|
461
|
+
|
|
462
|
+
init_mcp_subscriptions(mcp)
|
|
463
|
+
assert subscribe_handler is not None
|
|
464
|
+
|
|
465
|
+
uri = AnyUrl("project://state")
|
|
466
|
+
await subscribe_handler(uri)
|
|
467
|
+
assert str(uri) in _subscribed_uris
|
|
468
|
+
|
|
469
|
+
async def test_unsubscribe_removes_uri(self):
|
|
470
|
+
"""Unsubscribing removes the URI from the tracked set."""
|
|
471
|
+
from pixelweaver.mcp_resources import _subscribed_uris, init_mcp_subscriptions
|
|
472
|
+
|
|
473
|
+
mcp = MagicMock()
|
|
474
|
+
low_level = MagicMock()
|
|
475
|
+
mcp._mcp_server = low_level
|
|
476
|
+
|
|
477
|
+
# Capture both handlers
|
|
478
|
+
subscribe_handler = None
|
|
479
|
+
unsubscribe_handler = None
|
|
480
|
+
|
|
481
|
+
def capture_subscribe():
|
|
482
|
+
def decorator(fn):
|
|
483
|
+
nonlocal subscribe_handler
|
|
484
|
+
subscribe_handler = fn
|
|
485
|
+
return fn
|
|
486
|
+
return decorator
|
|
487
|
+
|
|
488
|
+
def capture_unsubscribe():
|
|
489
|
+
def decorator(fn):
|
|
490
|
+
nonlocal unsubscribe_handler
|
|
491
|
+
unsubscribe_handler = fn
|
|
492
|
+
return fn
|
|
493
|
+
return decorator
|
|
494
|
+
|
|
495
|
+
low_level.subscribe_resource = capture_subscribe
|
|
496
|
+
low_level.unsubscribe_resource = capture_unsubscribe
|
|
497
|
+
low_level.get_capabilities = MagicMock(return_value=MagicMock(resources=MagicMock(subscribe=False)))
|
|
498
|
+
|
|
499
|
+
init_mcp_subscriptions(mcp)
|
|
500
|
+
|
|
501
|
+
uri = AnyUrl("project://palette")
|
|
502
|
+
await subscribe_handler(uri)
|
|
503
|
+
assert str(uri) in _subscribed_uris
|
|
504
|
+
|
|
505
|
+
await unsubscribe_handler(uri)
|
|
506
|
+
assert str(uri) not in _subscribed_uris
|
|
507
|
+
|
|
508
|
+
async def test_unsubscribe_nonexistent_is_safe(self):
|
|
509
|
+
"""Unsubscribing a URI that was never subscribed does not raise."""
|
|
510
|
+
from pixelweaver.mcp_resources import _subscribed_uris, init_mcp_subscriptions
|
|
511
|
+
|
|
512
|
+
mcp = MagicMock()
|
|
513
|
+
low_level = MagicMock()
|
|
514
|
+
mcp._mcp_server = low_level
|
|
515
|
+
|
|
516
|
+
unsubscribe_handler = None
|
|
517
|
+
|
|
518
|
+
def capture_unsubscribe():
|
|
519
|
+
def decorator(fn):
|
|
520
|
+
nonlocal unsubscribe_handler
|
|
521
|
+
unsubscribe_handler = fn
|
|
522
|
+
return fn
|
|
523
|
+
return decorator
|
|
524
|
+
|
|
525
|
+
low_level.subscribe_resource = lambda: lambda fn: fn
|
|
526
|
+
low_level.unsubscribe_resource = capture_unsubscribe
|
|
527
|
+
low_level.get_capabilities = MagicMock(return_value=MagicMock(resources=MagicMock(subscribe=False)))
|
|
528
|
+
|
|
529
|
+
init_mcp_subscriptions(mcp)
|
|
530
|
+
|
|
531
|
+
# Should not raise
|
|
532
|
+
uri = AnyUrl("project://nonexistent")
|
|
533
|
+
await unsubscribe_handler(uri)
|
|
534
|
+
assert len(_subscribed_uris) == 0
|
|
535
|
+
|
|
536
|
+
def test_get_subscribed_uris_returns_frozen_snapshot(self):
|
|
537
|
+
"""get_subscribed_uris returns a frozenset copy."""
|
|
538
|
+
from pixelweaver.mcp_resources import _subscribed_uris, get_subscribed_uris
|
|
539
|
+
|
|
540
|
+
_subscribed_uris.add("project://state")
|
|
541
|
+
_subscribed_uris.add("project://palette")
|
|
542
|
+
|
|
543
|
+
snapshot = get_subscribed_uris()
|
|
544
|
+
assert isinstance(snapshot, frozenset)
|
|
545
|
+
assert snapshot == {"project://state", "project://palette"}
|
|
546
|
+
|
|
547
|
+
# Mutations to the module set do not affect the snapshot
|
|
548
|
+
_subscribed_uris.add("project://canvas/foo")
|
|
549
|
+
assert "project://canvas/foo" not in snapshot
|
|
550
|
+
|
|
551
|
+
async def test_capabilities_patched_to_advertise_subscribe(self):
|
|
552
|
+
"""init_mcp_subscriptions patches get_capabilities to set subscribe=True."""
|
|
553
|
+
from pixelweaver.mcp_resources import init_mcp_subscriptions
|
|
554
|
+
|
|
555
|
+
mcp = MagicMock()
|
|
556
|
+
low_level = MagicMock()
|
|
557
|
+
mcp._mcp_server = low_level
|
|
558
|
+
|
|
559
|
+
low_level.subscribe_resource = lambda: lambda fn: fn
|
|
560
|
+
low_level.unsubscribe_resource = lambda: lambda fn: fn
|
|
561
|
+
|
|
562
|
+
# Original returns resources with subscribe=False
|
|
563
|
+
resources_cap = MagicMock(subscribe=False)
|
|
564
|
+
original_caps = MagicMock(resources=resources_cap)
|
|
565
|
+
low_level.get_capabilities = MagicMock(return_value=original_caps)
|
|
566
|
+
|
|
567
|
+
init_mcp_subscriptions(mcp)
|
|
568
|
+
|
|
569
|
+
# The patched get_capabilities should set subscribe=True
|
|
570
|
+
caps = low_level.get_capabilities(None, None)
|
|
571
|
+
assert caps.resources.subscribe is True
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
# ---------------------------------------------------------------------------
|
|
575
|
+
# Tests: notify_resource_subscribers
|
|
576
|
+
# ---------------------------------------------------------------------------
|
|
577
|
+
|
|
578
|
+
class TestNotifyResourceSubscribers:
|
|
579
|
+
async def test_sends_updated_for_each_subscribed_uri(self):
|
|
580
|
+
"""notify_resource_subscribers calls send_resource_updated for each URI."""
|
|
581
|
+
from pixelweaver.mcp_resources import _subscribed_uris, notify_resource_subscribers
|
|
582
|
+
|
|
583
|
+
_subscribed_uris.add("project://state")
|
|
584
|
+
_subscribed_uris.add("project://palette")
|
|
585
|
+
|
|
586
|
+
session = AsyncMock()
|
|
587
|
+
context = MagicMock()
|
|
588
|
+
context.session = session
|
|
589
|
+
|
|
590
|
+
mcp = MagicMock()
|
|
591
|
+
mcp._mcp_server.request_context.session = session
|
|
592
|
+
|
|
593
|
+
await notify_resource_subscribers(mcp)
|
|
594
|
+
|
|
595
|
+
assert session.send_resource_updated.call_count == 2
|
|
596
|
+
called_uris = {
|
|
597
|
+
str(call.kwargs["uri"])
|
|
598
|
+
for call in session.send_resource_updated.call_args_list
|
|
599
|
+
}
|
|
600
|
+
assert called_uris == {"project://state", "project://palette"}
|
|
601
|
+
|
|
602
|
+
async def test_no_subscriptions_is_noop(self):
|
|
603
|
+
"""notify_resource_subscribers does nothing when no URIs are subscribed."""
|
|
604
|
+
from pixelweaver.mcp_resources import notify_resource_subscribers
|
|
605
|
+
|
|
606
|
+
mcp = MagicMock()
|
|
607
|
+
await notify_resource_subscribers(mcp)
|
|
608
|
+
|
|
609
|
+
# Should not even attempt to access session
|
|
610
|
+
mcp._mcp_server.request_context.session.send_resource_updated.assert_not_called()
|
|
611
|
+
|
|
612
|
+
async def test_handles_missing_request_context(self):
|
|
613
|
+
"""notify_resource_subscribers handles LookupError from missing context."""
|
|
614
|
+
from pixelweaver.mcp_resources import _subscribed_uris, notify_resource_subscribers
|
|
615
|
+
|
|
616
|
+
_subscribed_uris.add("project://state")
|
|
617
|
+
|
|
618
|
+
mcp = MagicMock()
|
|
619
|
+
# Simulate the ContextVar raising LookupError
|
|
620
|
+
type(mcp._mcp_server.request_context).session = property(
|
|
621
|
+
lambda self: (_ for _ in ()).throw(LookupError("no context"))
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
# Should not raise
|
|
625
|
+
await notify_resource_subscribers(mcp)
|
|
626
|
+
|
|
627
|
+
async def test_handles_send_failure_gracefully(self):
|
|
628
|
+
"""notify_resource_subscribers continues after individual send failures."""
|
|
629
|
+
from pixelweaver.mcp_resources import _subscribed_uris, notify_resource_subscribers
|
|
630
|
+
|
|
631
|
+
_subscribed_uris.add("project://state")
|
|
632
|
+
_subscribed_uris.add("project://palette")
|
|
633
|
+
|
|
634
|
+
session = AsyncMock()
|
|
635
|
+
# First call raises, second succeeds
|
|
636
|
+
session.send_resource_updated.side_effect = [
|
|
637
|
+
Exception("network error"),
|
|
638
|
+
None,
|
|
639
|
+
]
|
|
640
|
+
|
|
641
|
+
mcp = MagicMock()
|
|
642
|
+
mcp._mcp_server.request_context.session = session
|
|
643
|
+
|
|
644
|
+
# Should not raise despite the first send failing
|
|
645
|
+
await notify_resource_subscribers(mcp)
|
|
646
|
+
|
|
647
|
+
# Both sends were attempted
|
|
648
|
+
assert session.send_resource_updated.call_count == 2
|