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,679 @@
|
|
|
1
|
+
"""Tests for the MCP command registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
from io import BytesIO
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from PIL import Image
|
|
10
|
+
|
|
11
|
+
from pixelweaver.connections import ConnectionManager
|
|
12
|
+
from pixelweaver.mcp_registry import MCPCommandRegistry
|
|
13
|
+
from pixelweaver.state import ServerState
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture()
|
|
17
|
+
def state() -> ServerState:
|
|
18
|
+
"""ServerState with one active project."""
|
|
19
|
+
s = ServerState()
|
|
20
|
+
s.create_project("test-project", 32, 32)
|
|
21
|
+
return s
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture()
|
|
25
|
+
def connections() -> ConnectionManager:
|
|
26
|
+
return ConnectionManager()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture()
|
|
30
|
+
def registry(state: ServerState, connections: ConnectionManager) -> MCPCommandRegistry:
|
|
31
|
+
return MCPCommandRegistry(state, connections)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TestToolDefinitions:
|
|
35
|
+
def test_has_all_expected_tools(self, registry: MCPCommandRegistry):
|
|
36
|
+
"""Tool definitions include all expected drawing, layer, and project tools."""
|
|
37
|
+
defs = registry.get_tool_definitions()
|
|
38
|
+
names = {d["name"] for d in defs}
|
|
39
|
+
|
|
40
|
+
# Drawing tools
|
|
41
|
+
for tool in [
|
|
42
|
+
"draw_pixels", "erase_pixels", "flood_fill", "draw_line",
|
|
43
|
+
"draw_rect", "draw_ellipse", "draw_diamond", "draw_gradient",
|
|
44
|
+
"draw_noise", "draw_dither",
|
|
45
|
+
]:
|
|
46
|
+
assert tool in names, f"Missing drawing tool: {tool}"
|
|
47
|
+
|
|
48
|
+
# Layer tools
|
|
49
|
+
for tool in [
|
|
50
|
+
"add_layer", "remove_layer", "rename_layer", "move_layer",
|
|
51
|
+
"set_layer_visibility", "set_layer_opacity",
|
|
52
|
+
]:
|
|
53
|
+
assert tool in names, f"Missing layer tool: {tool}"
|
|
54
|
+
|
|
55
|
+
# Frame tools (mutation)
|
|
56
|
+
for tool in [
|
|
57
|
+
"add_frame", "remove_frame", "duplicate_frame", "reorder_frame",
|
|
58
|
+
"set_frame_duration", "set_global_fps", "set_current_frame",
|
|
59
|
+
]:
|
|
60
|
+
assert tool in names, f"Missing frame tool: {tool}"
|
|
61
|
+
|
|
62
|
+
# Frame tools (read-only)
|
|
63
|
+
for tool in ["get_frame_count", "get_current_frame"]:
|
|
64
|
+
assert tool in names, f"Missing frame read tool: {tool}"
|
|
65
|
+
|
|
66
|
+
# Project tools
|
|
67
|
+
for tool in [
|
|
68
|
+
"create_project", "list_projects", "open_project",
|
|
69
|
+
"save_project", "get_project_info",
|
|
70
|
+
]:
|
|
71
|
+
assert tool in names, f"Missing project tool: {tool}"
|
|
72
|
+
|
|
73
|
+
# Canvas tools
|
|
74
|
+
for tool in ["get_canvas_size", "resize_canvas", "get_pixel", "get_region"]:
|
|
75
|
+
assert tool in names, f"Missing canvas tool: {tool}"
|
|
76
|
+
|
|
77
|
+
# History tools
|
|
78
|
+
for tool in ["undo", "redo", "get_action_log"]:
|
|
79
|
+
assert tool in names, f"Missing history tool: {tool}"
|
|
80
|
+
|
|
81
|
+
# Export tools
|
|
82
|
+
for tool in ["export_png", "export_spritesheet"]:
|
|
83
|
+
assert tool in names, f"Missing export tool: {tool}"
|
|
84
|
+
|
|
85
|
+
# Curated tools
|
|
86
|
+
for tool in ["draw_shape", "fill_area", "apply_effect"]:
|
|
87
|
+
assert tool in names, f"Missing curated tool: {tool}"
|
|
88
|
+
|
|
89
|
+
# Read tools
|
|
90
|
+
for tool in [
|
|
91
|
+
"get_canvas_info", "get_layer_tree", "get_palette", "get_canvas_thumbnail",
|
|
92
|
+
]:
|
|
93
|
+
assert tool in names, f"Missing read tool: {tool}"
|
|
94
|
+
|
|
95
|
+
def test_tool_definitions_have_required_fields(self, registry: MCPCommandRegistry):
|
|
96
|
+
"""Every tool def has name, description, and inputSchema."""
|
|
97
|
+
for td in registry.get_tool_definitions():
|
|
98
|
+
assert "name" in td
|
|
99
|
+
assert "description" in td
|
|
100
|
+
assert "inputSchema" in td
|
|
101
|
+
assert td["inputSchema"]["type"] == "object"
|
|
102
|
+
|
|
103
|
+
def test_get_tool_def_by_name(self, registry: MCPCommandRegistry):
|
|
104
|
+
"""get_tool_def returns the correct ToolDef for a known tool."""
|
|
105
|
+
td = registry.get_tool_def("draw_pixels")
|
|
106
|
+
assert td is not None
|
|
107
|
+
assert td.name == "draw_pixels"
|
|
108
|
+
assert td.mutates is True
|
|
109
|
+
|
|
110
|
+
td_read = registry.get_tool_def("get_canvas_info")
|
|
111
|
+
assert td_read is not None
|
|
112
|
+
assert td_read.mutates is False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class TestDrawingToolExecution:
|
|
116
|
+
@pytest.mark.asyncio
|
|
117
|
+
async def test_draw_pixels_creates_command(
|
|
118
|
+
self, registry: MCPCommandRegistry, state: ServerState,
|
|
119
|
+
):
|
|
120
|
+
"""Executing draw_pixels stores a command in project history."""
|
|
121
|
+
result = await registry.execute_tool("draw_pixels", {
|
|
122
|
+
"pixels": [{"x": 0, "y": 0}],
|
|
123
|
+
"color": "#ff0000ff",
|
|
124
|
+
})
|
|
125
|
+
assert result["success"] is True
|
|
126
|
+
assert "command_id" in result
|
|
127
|
+
|
|
128
|
+
project = state.get_active_project()
|
|
129
|
+
assert project is not None
|
|
130
|
+
assert len(project.command_history) == 1
|
|
131
|
+
assert project.command_history[0]["type"] == "draw_pixels"
|
|
132
|
+
|
|
133
|
+
@pytest.mark.asyncio
|
|
134
|
+
async def test_draw_pixels_no_project(self, connections: ConnectionManager):
|
|
135
|
+
"""Drawing without an active project returns an error."""
|
|
136
|
+
empty_state = ServerState()
|
|
137
|
+
reg = MCPCommandRegistry(empty_state, connections)
|
|
138
|
+
result = await reg.execute_tool("draw_pixels", {
|
|
139
|
+
"pixels": [{"x": 0, "y": 0}],
|
|
140
|
+
"color": "#ff0000ff",
|
|
141
|
+
})
|
|
142
|
+
assert result["success"] is False
|
|
143
|
+
assert "No active project" in result["error"]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class TestReadToolExecution:
|
|
147
|
+
@pytest.mark.asyncio
|
|
148
|
+
async def test_get_canvas_info_returns_data(self, registry: MCPCommandRegistry):
|
|
149
|
+
"""get_canvas_info returns canvas metadata."""
|
|
150
|
+
result = await registry.execute_tool("get_canvas_info", {})
|
|
151
|
+
assert result["success"] is True
|
|
152
|
+
assert "canvases" in result
|
|
153
|
+
assert len(result["canvases"]) == 1
|
|
154
|
+
canvas = result["canvases"][0]
|
|
155
|
+
assert canvas["width"] == 32
|
|
156
|
+
assert canvas["height"] == 32
|
|
157
|
+
|
|
158
|
+
@pytest.mark.asyncio
|
|
159
|
+
async def test_get_layer_tree_returns_layers(self, registry: MCPCommandRegistry):
|
|
160
|
+
"""get_layer_tree returns the layer structure."""
|
|
161
|
+
result = await registry.execute_tool("get_layer_tree", {})
|
|
162
|
+
assert result["success"] is True
|
|
163
|
+
assert len(result["layers"]) == 1
|
|
164
|
+
assert result["layers"][0]["name"] == "Layer 0"
|
|
165
|
+
|
|
166
|
+
@pytest.mark.asyncio
|
|
167
|
+
async def test_get_pixel_returns_color(self, registry: MCPCommandRegistry):
|
|
168
|
+
"""get_pixel reads from pixel data (default is transparent)."""
|
|
169
|
+
result = await registry.execute_tool("get_pixel", {"x": 0, "y": 0})
|
|
170
|
+
assert result["success"] is True
|
|
171
|
+
# Default canvas is all transparent
|
|
172
|
+
assert result["rgba"] == [0, 0, 0, 0]
|
|
173
|
+
|
|
174
|
+
@pytest.mark.asyncio
|
|
175
|
+
async def test_get_pixel_out_of_bounds(self, registry: MCPCommandRegistry):
|
|
176
|
+
"""get_pixel with invalid coords returns error."""
|
|
177
|
+
result = await registry.execute_tool("get_pixel", {"x": 999, "y": 999})
|
|
178
|
+
assert result["success"] is False
|
|
179
|
+
assert "out of bounds" in result["error"]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class TestUnknownTool:
|
|
183
|
+
@pytest.mark.asyncio
|
|
184
|
+
async def test_unknown_tool_returns_error(self, registry: MCPCommandRegistry):
|
|
185
|
+
"""Calling an unregistered tool returns an error."""
|
|
186
|
+
result = await registry.execute_tool("nonexistent_tool", {})
|
|
187
|
+
assert result["success"] is False
|
|
188
|
+
assert "Unknown tool" in result["error"]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class TestCuratedTools:
|
|
192
|
+
@pytest.mark.asyncio
|
|
193
|
+
async def test_draw_shape_rect(self, registry: MCPCommandRegistry, state: ServerState):
|
|
194
|
+
"""draw_shape with shape=rect dispatches draw_rect command."""
|
|
195
|
+
result = await registry.execute_tool("draw_shape", {
|
|
196
|
+
"shape": "rect",
|
|
197
|
+
"x": 0,
|
|
198
|
+
"y": 0,
|
|
199
|
+
"width": 10,
|
|
200
|
+
"height": 10,
|
|
201
|
+
"color": "#00ff00ff",
|
|
202
|
+
})
|
|
203
|
+
assert result["success"] is True
|
|
204
|
+
|
|
205
|
+
project = state.get_active_project()
|
|
206
|
+
assert project is not None
|
|
207
|
+
assert project.command_history[-1]["type"] == "draw_rect"
|
|
208
|
+
|
|
209
|
+
@pytest.mark.asyncio
|
|
210
|
+
async def test_draw_shape_ellipse(self, registry: MCPCommandRegistry, state: ServerState):
|
|
211
|
+
"""draw_shape with shape=ellipse dispatches draw_ellipse command."""
|
|
212
|
+
result = await registry.execute_tool("draw_shape", {
|
|
213
|
+
"shape": "ellipse",
|
|
214
|
+
"x": 10,
|
|
215
|
+
"y": 10,
|
|
216
|
+
"width": 5,
|
|
217
|
+
"height": 5,
|
|
218
|
+
"color": "#0000ffff",
|
|
219
|
+
})
|
|
220
|
+
assert result["success"] is True
|
|
221
|
+
|
|
222
|
+
project = state.get_active_project()
|
|
223
|
+
assert project is not None
|
|
224
|
+
cmd = project.command_history[-1]
|
|
225
|
+
assert cmd["type"] == "draw_ellipse"
|
|
226
|
+
# x/y should be remapped to cx/cy
|
|
227
|
+
assert cmd["params"]["cx"] == 10
|
|
228
|
+
assert cmd["params"]["cy"] == 10
|
|
229
|
+
|
|
230
|
+
@pytest.mark.asyncio
|
|
231
|
+
async def test_draw_shape_unknown(self, registry: MCPCommandRegistry):
|
|
232
|
+
"""draw_shape with an invalid shape name returns error."""
|
|
233
|
+
result = await registry.execute_tool("draw_shape", {
|
|
234
|
+
"shape": "hexagon",
|
|
235
|
+
"x": 0, "y": 0, "width": 5, "height": 5,
|
|
236
|
+
"color": "#ffffffff",
|
|
237
|
+
})
|
|
238
|
+
assert result["success"] is False
|
|
239
|
+
assert "Unknown shape" in result["error"]
|
|
240
|
+
|
|
241
|
+
@pytest.mark.asyncio
|
|
242
|
+
async def test_fill_area_dispatches_flood_fill(
|
|
243
|
+
self, registry: MCPCommandRegistry, state: ServerState,
|
|
244
|
+
):
|
|
245
|
+
"""fill_area dispatches a flood_fill command."""
|
|
246
|
+
result = await registry.execute_tool("fill_area", {
|
|
247
|
+
"x": 5, "y": 5, "color": "#ff0000ff",
|
|
248
|
+
})
|
|
249
|
+
assert result["success"] is True
|
|
250
|
+
|
|
251
|
+
project = state.get_active_project()
|
|
252
|
+
assert project is not None
|
|
253
|
+
assert project.command_history[-1]["type"] == "flood_fill"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class TestProjectTools:
|
|
257
|
+
@pytest.mark.asyncio
|
|
258
|
+
async def test_list_projects(self, registry: MCPCommandRegistry):
|
|
259
|
+
"""list_projects returns the loaded projects."""
|
|
260
|
+
result = await registry.execute_tool("list_projects", {})
|
|
261
|
+
assert result["success"] is True
|
|
262
|
+
assert "test-project" in result["projects"]
|
|
263
|
+
|
|
264
|
+
@pytest.mark.asyncio
|
|
265
|
+
async def test_create_project(self, registry: MCPCommandRegistry, state: ServerState):
|
|
266
|
+
"""create_project adds a new project and sets it active."""
|
|
267
|
+
result = await registry.execute_tool("create_project", {
|
|
268
|
+
"name": "new-art",
|
|
269
|
+
"width": 16,
|
|
270
|
+
"height": 16,
|
|
271
|
+
})
|
|
272
|
+
assert result["success"] is True
|
|
273
|
+
assert "new-art" in state.projects
|
|
274
|
+
assert state.active_project == "new-art"
|
|
275
|
+
|
|
276
|
+
@pytest.mark.asyncio
|
|
277
|
+
async def test_create_duplicate_project(self, registry: MCPCommandRegistry):
|
|
278
|
+
"""Creating a project with a duplicate name returns error."""
|
|
279
|
+
result = await registry.execute_tool("create_project", {
|
|
280
|
+
"name": "test-project",
|
|
281
|
+
"width": 8,
|
|
282
|
+
"height": 8,
|
|
283
|
+
})
|
|
284
|
+
assert result["success"] is False
|
|
285
|
+
assert "already exists" in result["error"]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class TestHistoryTools:
|
|
289
|
+
@pytest.mark.asyncio
|
|
290
|
+
async def test_undo_redo_cycle(self, registry: MCPCommandRegistry, state: ServerState):
|
|
291
|
+
"""undo/redo cycle works correctly via MCP tools."""
|
|
292
|
+
# Add a command
|
|
293
|
+
await registry.execute_tool("draw_pixels", {
|
|
294
|
+
"pixels": [{"x": 0, "y": 0}],
|
|
295
|
+
"color": "#ff0000ff",
|
|
296
|
+
})
|
|
297
|
+
project = state.get_active_project()
|
|
298
|
+
assert project is not None
|
|
299
|
+
assert len(project.command_history) == 1
|
|
300
|
+
|
|
301
|
+
# Undo
|
|
302
|
+
result = await registry.execute_tool("undo", {})
|
|
303
|
+
assert result["success"] is True
|
|
304
|
+
assert len(project.command_history) == 0
|
|
305
|
+
assert len(project.redo_stack) == 1
|
|
306
|
+
|
|
307
|
+
# Redo
|
|
308
|
+
result = await registry.execute_tool("redo", {})
|
|
309
|
+
assert result["success"] is True
|
|
310
|
+
assert len(project.command_history) == 1
|
|
311
|
+
assert len(project.redo_stack) == 0
|
|
312
|
+
|
|
313
|
+
@pytest.mark.asyncio
|
|
314
|
+
async def test_get_action_log(self, registry: MCPCommandRegistry):
|
|
315
|
+
"""get_action_log returns history entries."""
|
|
316
|
+
await registry.execute_tool("draw_pixels", {
|
|
317
|
+
"pixels": [{"x": 0, "y": 0}],
|
|
318
|
+
"color": "#ff0000ff",
|
|
319
|
+
})
|
|
320
|
+
result = await registry.execute_tool("get_action_log", {"limit": 10})
|
|
321
|
+
assert result["success"] is True
|
|
322
|
+
assert len(result["actions"]) == 1
|
|
323
|
+
assert result["actions"][0]["type"] == "draw_pixels"
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class TestFrameTools:
|
|
327
|
+
"""Tests for frame mutation and read tools."""
|
|
328
|
+
|
|
329
|
+
@pytest.mark.asyncio
|
|
330
|
+
async def test_add_frame_increases_count(
|
|
331
|
+
self, registry: MCPCommandRegistry, state: ServerState,
|
|
332
|
+
):
|
|
333
|
+
"""add_frame inserts a new frame and increases frame_count."""
|
|
334
|
+
canvas = next(iter(state.get_active_project().canvases.values()))
|
|
335
|
+
initial_count = canvas.frame_count
|
|
336
|
+
result = await registry.execute_tool("add_frame", {})
|
|
337
|
+
assert result["success"] is True
|
|
338
|
+
assert result["frame_count"] == initial_count + 1
|
|
339
|
+
assert canvas.frame_count == initial_count + 1
|
|
340
|
+
|
|
341
|
+
@pytest.mark.asyncio
|
|
342
|
+
async def test_add_frame_after_index(
|
|
343
|
+
self, registry: MCPCommandRegistry, state: ServerState,
|
|
344
|
+
):
|
|
345
|
+
"""add_frame inserts after the specified index."""
|
|
346
|
+
canvas = next(iter(state.get_active_project().canvases.values()))
|
|
347
|
+
first_frame_id = canvas.frames[0].id
|
|
348
|
+
result = await registry.execute_tool("add_frame", {"after_index": -1})
|
|
349
|
+
assert result["success"] is True
|
|
350
|
+
# New frame should be at index 0, original moves to index 1
|
|
351
|
+
assert canvas.frames[1].id == first_frame_id
|
|
352
|
+
assert canvas.frames[0].id == result["frame_id"]
|
|
353
|
+
|
|
354
|
+
@pytest.mark.asyncio
|
|
355
|
+
async def test_add_frame_no_project(self, connections: ConnectionManager):
|
|
356
|
+
"""add_frame without active project returns error."""
|
|
357
|
+
empty = ServerState()
|
|
358
|
+
reg = MCPCommandRegistry(empty, connections)
|
|
359
|
+
result = await reg.execute_tool("add_frame", {})
|
|
360
|
+
assert result["success"] is False
|
|
361
|
+
assert "No active project" in result["error"]
|
|
362
|
+
|
|
363
|
+
@pytest.mark.asyncio
|
|
364
|
+
async def test_remove_frame_decreases_count(
|
|
365
|
+
self, registry: MCPCommandRegistry, state: ServerState,
|
|
366
|
+
):
|
|
367
|
+
"""remove_frame removes a frame and decreases frame_count."""
|
|
368
|
+
# First add a second frame
|
|
369
|
+
await registry.execute_tool("add_frame", {})
|
|
370
|
+
canvas = next(iter(state.get_active_project().canvases.values()))
|
|
371
|
+
assert canvas.frame_count == 2
|
|
372
|
+
|
|
373
|
+
result = await registry.execute_tool("remove_frame", {"frame_index": 1})
|
|
374
|
+
assert result["success"] is True
|
|
375
|
+
assert result["frame_count"] == 1
|
|
376
|
+
assert canvas.frame_count == 1
|
|
377
|
+
|
|
378
|
+
@pytest.mark.asyncio
|
|
379
|
+
async def test_remove_last_frame_fails(
|
|
380
|
+
self, registry: MCPCommandRegistry,
|
|
381
|
+
):
|
|
382
|
+
"""Cannot remove the only remaining frame."""
|
|
383
|
+
result = await registry.execute_tool("remove_frame", {"frame_index": 0})
|
|
384
|
+
assert result["success"] is False
|
|
385
|
+
assert "Cannot remove the last frame" in result["error"]
|
|
386
|
+
|
|
387
|
+
@pytest.mark.asyncio
|
|
388
|
+
async def test_remove_frame_adjusts_current_index(
|
|
389
|
+
self, registry: MCPCommandRegistry, state: ServerState,
|
|
390
|
+
):
|
|
391
|
+
"""Removing a frame adjusts current_frame_index to stay valid."""
|
|
392
|
+
canvas = next(iter(state.get_active_project().canvases.values()))
|
|
393
|
+
# Add two more frames (total 3)
|
|
394
|
+
await registry.execute_tool("add_frame", {})
|
|
395
|
+
await registry.execute_tool("add_frame", {})
|
|
396
|
+
assert canvas.frame_count == 3
|
|
397
|
+
|
|
398
|
+
# Point to the last frame
|
|
399
|
+
canvas.current_frame_index = 2
|
|
400
|
+
# Remove it
|
|
401
|
+
result = await registry.execute_tool("remove_frame", {"frame_index": 2})
|
|
402
|
+
assert result["success"] is True
|
|
403
|
+
assert canvas.current_frame_index == 1 # clamped to new last index
|
|
404
|
+
|
|
405
|
+
@pytest.mark.asyncio
|
|
406
|
+
async def test_duplicate_frame_creates_copy(
|
|
407
|
+
self, registry: MCPCommandRegistry, state: ServerState,
|
|
408
|
+
):
|
|
409
|
+
"""duplicate_frame creates a new frame with a different ID but same data."""
|
|
410
|
+
canvas = next(iter(state.get_active_project().canvases.values()))
|
|
411
|
+
original_id = canvas.frames[0].id
|
|
412
|
+
|
|
413
|
+
result = await registry.execute_tool("duplicate_frame", {"frame_index": 0})
|
|
414
|
+
assert result["success"] is True
|
|
415
|
+
assert result["source_frame_id"] == original_id
|
|
416
|
+
assert result["frame_id"] != original_id
|
|
417
|
+
assert result["frame_count"] == 2
|
|
418
|
+
assert canvas.frame_count == 2
|
|
419
|
+
|
|
420
|
+
# Pixel data should be copied (same content, different objects)
|
|
421
|
+
source = canvas.frames[0]
|
|
422
|
+
dup = canvas.frames[1]
|
|
423
|
+
for layer_id, data in source.pixel_data.items():
|
|
424
|
+
assert layer_id in dup.pixel_data
|
|
425
|
+
assert dup.pixel_data[layer_id] == data
|
|
426
|
+
assert dup.pixel_data[layer_id] is not data # different object
|
|
427
|
+
|
|
428
|
+
@pytest.mark.asyncio
|
|
429
|
+
async def test_duplicate_frame_out_of_range(
|
|
430
|
+
self, registry: MCPCommandRegistry,
|
|
431
|
+
):
|
|
432
|
+
"""duplicate_frame with invalid index returns error."""
|
|
433
|
+
result = await registry.execute_tool("duplicate_frame", {"frame_index": 99})
|
|
434
|
+
assert result["success"] is False
|
|
435
|
+
assert "out of range" in result["error"]
|
|
436
|
+
|
|
437
|
+
@pytest.mark.asyncio
|
|
438
|
+
async def test_reorder_frame_moves_correctly(
|
|
439
|
+
self, registry: MCPCommandRegistry, state: ServerState,
|
|
440
|
+
):
|
|
441
|
+
"""reorder_frame moves a frame from one position to another."""
|
|
442
|
+
canvas = next(iter(state.get_active_project().canvases.values()))
|
|
443
|
+
# Add two frames (total 3: indices 0, 1, 2)
|
|
444
|
+
await registry.execute_tool("add_frame", {})
|
|
445
|
+
await registry.execute_tool("add_frame", {})
|
|
446
|
+
assert canvas.frame_count == 3
|
|
447
|
+
|
|
448
|
+
id_0 = canvas.frames[0].id
|
|
449
|
+
id_1 = canvas.frames[1].id
|
|
450
|
+
id_2 = canvas.frames[2].id
|
|
451
|
+
|
|
452
|
+
# Move frame 0 to position 2
|
|
453
|
+
result = await registry.execute_tool(
|
|
454
|
+
"reorder_frame", {"from_index": 0, "to_index": 2},
|
|
455
|
+
)
|
|
456
|
+
assert result["success"] is True
|
|
457
|
+
assert canvas.frames[0].id == id_1
|
|
458
|
+
assert canvas.frames[1].id == id_2
|
|
459
|
+
assert canvas.frames[2].id == id_0
|
|
460
|
+
|
|
461
|
+
@pytest.mark.asyncio
|
|
462
|
+
async def test_reorder_frame_tracks_current_index(
|
|
463
|
+
self, registry: MCPCommandRegistry, state: ServerState,
|
|
464
|
+
):
|
|
465
|
+
"""reorder_frame adjusts current_frame_index to track the same frame."""
|
|
466
|
+
canvas = next(iter(state.get_active_project().canvases.values()))
|
|
467
|
+
await registry.execute_tool("add_frame", {})
|
|
468
|
+
await registry.execute_tool("add_frame", {})
|
|
469
|
+
|
|
470
|
+
# Current index points to frame 0
|
|
471
|
+
canvas.current_frame_index = 0
|
|
472
|
+
current_frame_id = canvas.frames[0].id
|
|
473
|
+
|
|
474
|
+
# Move frame 0 to position 2
|
|
475
|
+
await registry.execute_tool(
|
|
476
|
+
"reorder_frame", {"from_index": 0, "to_index": 2},
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
# current_frame_index should follow the frame that was at index 0
|
|
480
|
+
assert canvas.frames[canvas.current_frame_index].id == current_frame_id
|
|
481
|
+
|
|
482
|
+
@pytest.mark.asyncio
|
|
483
|
+
async def test_set_frame_duration_updates(
|
|
484
|
+
self, registry: MCPCommandRegistry, state: ServerState,
|
|
485
|
+
):
|
|
486
|
+
"""set_frame_duration updates the frame's duration_ms."""
|
|
487
|
+
canvas = next(iter(state.get_active_project().canvases.values()))
|
|
488
|
+
assert canvas.frames[0].duration_ms is None # default
|
|
489
|
+
|
|
490
|
+
result = await registry.execute_tool(
|
|
491
|
+
"set_frame_duration", {"frame_index": 0, "duration_ms": 200},
|
|
492
|
+
)
|
|
493
|
+
assert result["success"] is True
|
|
494
|
+
assert canvas.frames[0].duration_ms == 200
|
|
495
|
+
|
|
496
|
+
@pytest.mark.asyncio
|
|
497
|
+
async def test_set_frame_duration_null(
|
|
498
|
+
self, registry: MCPCommandRegistry, state: ServerState,
|
|
499
|
+
):
|
|
500
|
+
"""set_frame_duration with null resets to global FPS."""
|
|
501
|
+
canvas = next(iter(state.get_active_project().canvases.values()))
|
|
502
|
+
canvas.frames[0].duration_ms = 200
|
|
503
|
+
|
|
504
|
+
result = await registry.execute_tool(
|
|
505
|
+
"set_frame_duration", {"frame_index": 0, "duration_ms": None},
|
|
506
|
+
)
|
|
507
|
+
assert result["success"] is True
|
|
508
|
+
assert canvas.frames[0].duration_ms is None
|
|
509
|
+
|
|
510
|
+
@pytest.mark.asyncio
|
|
511
|
+
async def test_set_global_fps_updates(
|
|
512
|
+
self, registry: MCPCommandRegistry, state: ServerState,
|
|
513
|
+
):
|
|
514
|
+
"""set_global_fps updates canvas.global_fps with clamping."""
|
|
515
|
+
canvas = next(iter(state.get_active_project().canvases.values()))
|
|
516
|
+
assert canvas.global_fps == 12.0 # default
|
|
517
|
+
|
|
518
|
+
result = await registry.execute_tool("set_global_fps", {"fps": 24})
|
|
519
|
+
assert result["success"] is True
|
|
520
|
+
assert canvas.global_fps == 24.0
|
|
521
|
+
|
|
522
|
+
@pytest.mark.asyncio
|
|
523
|
+
async def test_set_global_fps_clamps(
|
|
524
|
+
self, registry: MCPCommandRegistry, state: ServerState,
|
|
525
|
+
):
|
|
526
|
+
"""set_global_fps clamps to [1, 120]."""
|
|
527
|
+
canvas = next(iter(state.get_active_project().canvases.values()))
|
|
528
|
+
|
|
529
|
+
await registry.execute_tool("set_global_fps", {"fps": 0.1})
|
|
530
|
+
assert canvas.global_fps == 1.0 # clamped to min
|
|
531
|
+
|
|
532
|
+
await registry.execute_tool("set_global_fps", {"fps": 999})
|
|
533
|
+
assert canvas.global_fps == 120.0 # clamped to max
|
|
534
|
+
|
|
535
|
+
@pytest.mark.asyncio
|
|
536
|
+
async def test_get_frame_count(self, registry: MCPCommandRegistry):
|
|
537
|
+
"""get_frame_count returns count, current index, and fps."""
|
|
538
|
+
result = await registry.execute_tool("get_frame_count", {})
|
|
539
|
+
assert result["success"] is True
|
|
540
|
+
assert result["count"] == 1
|
|
541
|
+
assert result["current_index"] == 0
|
|
542
|
+
assert result["global_fps"] == 12.0
|
|
543
|
+
|
|
544
|
+
@pytest.mark.asyncio
|
|
545
|
+
async def test_get_current_frame(self, registry: MCPCommandRegistry):
|
|
546
|
+
"""get_current_frame returns info about the current frame."""
|
|
547
|
+
result = await registry.execute_tool("get_current_frame", {})
|
|
548
|
+
assert result["success"] is True
|
|
549
|
+
assert result["index"] == 0
|
|
550
|
+
assert "id" in result
|
|
551
|
+
assert result["duration_ms"] is None
|
|
552
|
+
|
|
553
|
+
@pytest.mark.asyncio
|
|
554
|
+
async def test_set_current_frame(
|
|
555
|
+
self, registry: MCPCommandRegistry, state: ServerState,
|
|
556
|
+
):
|
|
557
|
+
"""set_current_frame changes the active frame index."""
|
|
558
|
+
canvas = next(iter(state.get_active_project().canvases.values()))
|
|
559
|
+
# Add a frame so we have two
|
|
560
|
+
await registry.execute_tool("add_frame", {})
|
|
561
|
+
assert canvas.frame_count == 2
|
|
562
|
+
|
|
563
|
+
result = await registry.execute_tool("set_current_frame", {"frame_index": 1})
|
|
564
|
+
assert result["success"] is True
|
|
565
|
+
assert canvas.current_frame_index == 1
|
|
566
|
+
assert result["current_frame_index"] == 1
|
|
567
|
+
|
|
568
|
+
@pytest.mark.asyncio
|
|
569
|
+
async def test_set_current_frame_out_of_range(
|
|
570
|
+
self, registry: MCPCommandRegistry,
|
|
571
|
+
):
|
|
572
|
+
"""set_current_frame with invalid index returns error."""
|
|
573
|
+
result = await registry.execute_tool("set_current_frame", {"frame_index": 99})
|
|
574
|
+
assert result["success"] is False
|
|
575
|
+
assert "out of range" in result["error"]
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
class TestExportSpritesheet:
|
|
579
|
+
"""Tests for export_spritesheet iterating frames within a canvas."""
|
|
580
|
+
|
|
581
|
+
@pytest.mark.asyncio
|
|
582
|
+
async def test_spritesheet_multi_frame_horizontal(
|
|
583
|
+
self, registry: MCPCommandRegistry, state: ServerState,
|
|
584
|
+
):
|
|
585
|
+
"""Spritesheet from 3 frames produces correct horizontal strip dimensions."""
|
|
586
|
+
project = state.get_active_project()
|
|
587
|
+
assert project is not None
|
|
588
|
+
canvas = next(iter(project.canvases.values()))
|
|
589
|
+
|
|
590
|
+
# Draw distinct pixels into frame 0 so it's non-empty
|
|
591
|
+
await registry.execute_tool("draw_pixels", {
|
|
592
|
+
"pixels": [{"x": 0, "y": 0}],
|
|
593
|
+
"color": "#ff0000ff",
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
# Add frame 1 with a different color
|
|
597
|
+
await registry.execute_tool("add_frame", {})
|
|
598
|
+
await registry.execute_tool("set_current_frame", {"frame_index": 1})
|
|
599
|
+
await registry.execute_tool("draw_pixels", {
|
|
600
|
+
"pixels": [{"x": 1, "y": 1}],
|
|
601
|
+
"color": "#00ff00ff",
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
# Add frame 2 with yet another color
|
|
605
|
+
await registry.execute_tool("add_frame", {})
|
|
606
|
+
await registry.execute_tool("set_current_frame", {"frame_index": 2})
|
|
607
|
+
await registry.execute_tool("draw_pixels", {
|
|
608
|
+
"pixels": [{"x": 2, "y": 2}],
|
|
609
|
+
"color": "#0000ffff",
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
assert canvas.frame_count == 3
|
|
613
|
+
|
|
614
|
+
result = await registry.execute_tool("export_spritesheet", {})
|
|
615
|
+
assert result["success"] is True
|
|
616
|
+
assert result["frame_count"] == canvas.frame_count
|
|
617
|
+
assert result["direction"] == "horizontal"
|
|
618
|
+
assert result["frame_width"] == canvas.width
|
|
619
|
+
assert result["frame_height"] == canvas.height
|
|
620
|
+
|
|
621
|
+
# Decode and verify the full image dimensions
|
|
622
|
+
img = Image.open(BytesIO(base64.b64decode(result["image_base64"])))
|
|
623
|
+
assert img.width == canvas.width * 3
|
|
624
|
+
assert img.height == canvas.height
|
|
625
|
+
|
|
626
|
+
@pytest.mark.asyncio
|
|
627
|
+
async def test_spritesheet_vertical(
|
|
628
|
+
self, registry: MCPCommandRegistry, state: ServerState,
|
|
629
|
+
):
|
|
630
|
+
"""Vertical spritesheet stacks frames top-to-bottom."""
|
|
631
|
+
project = state.get_active_project()
|
|
632
|
+
assert project is not None
|
|
633
|
+
canvas = next(iter(project.canvases.values()))
|
|
634
|
+
|
|
635
|
+
# Add a second frame
|
|
636
|
+
await registry.execute_tool("add_frame", {})
|
|
637
|
+
assert canvas.frame_count == 2
|
|
638
|
+
|
|
639
|
+
result = await registry.execute_tool(
|
|
640
|
+
"export_spritesheet", {"direction": "vertical"},
|
|
641
|
+
)
|
|
642
|
+
assert result["success"] is True
|
|
643
|
+
assert result["frame_count"] == 2
|
|
644
|
+
assert result["direction"] == "vertical"
|
|
645
|
+
|
|
646
|
+
img = Image.open(BytesIO(base64.b64decode(result["image_base64"])))
|
|
647
|
+
assert img.width == canvas.width
|
|
648
|
+
assert img.height == canvas.height * 2
|
|
649
|
+
|
|
650
|
+
@pytest.mark.asyncio
|
|
651
|
+
async def test_spritesheet_single_frame(
|
|
652
|
+
self, registry: MCPCommandRegistry,
|
|
653
|
+
):
|
|
654
|
+
"""Spritesheet with one frame returns a single-frame image."""
|
|
655
|
+
result = await registry.execute_tool("export_spritesheet", {})
|
|
656
|
+
assert result["success"] is True
|
|
657
|
+
assert result["frame_count"] == 1
|
|
658
|
+
|
|
659
|
+
img = Image.open(BytesIO(base64.b64decode(result["image_base64"])))
|
|
660
|
+
assert img.width == 32
|
|
661
|
+
assert img.height == 32
|
|
662
|
+
|
|
663
|
+
@pytest.mark.asyncio
|
|
664
|
+
async def test_spritesheet_no_project(self, connections: ConnectionManager):
|
|
665
|
+
"""export_spritesheet without active project returns error."""
|
|
666
|
+
empty = ServerState()
|
|
667
|
+
reg = MCPCommandRegistry(empty, connections)
|
|
668
|
+
result = await reg.execute_tool("export_spritesheet", {})
|
|
669
|
+
assert result["success"] is False
|
|
670
|
+
assert "No active project" in result["error"]
|
|
671
|
+
|
|
672
|
+
@pytest.mark.asyncio
|
|
673
|
+
async def test_spritesheet_unknown_canvas(self, registry: MCPCommandRegistry):
|
|
674
|
+
"""export_spritesheet with a non-existent canvas_name returns error."""
|
|
675
|
+
result = await registry.execute_tool(
|
|
676
|
+
"export_spritesheet", {"canvas_name": "no-such-canvas"},
|
|
677
|
+
)
|
|
678
|
+
assert result["success"] is False
|
|
679
|
+
assert "not found" in result["error"]
|