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,282 @@
|
|
|
1
|
+
"""WebSocket message handler for PixelWeaver.
|
|
2
|
+
|
|
3
|
+
Routes incoming messages to the appropriate handler based on type, validates
|
|
4
|
+
them through the protocol models, and coordinates responses/broadcasts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import uuid
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from wesktop import WebSocket
|
|
15
|
+
|
|
16
|
+
from pixelweaver.connections import ConnectionManager
|
|
17
|
+
from pixelweaver.protocol import (
|
|
18
|
+
CommandMessage,
|
|
19
|
+
RedoMessage,
|
|
20
|
+
UndoMessage,
|
|
21
|
+
parse_client_message,
|
|
22
|
+
)
|
|
23
|
+
from pixelweaver.state import ServerState, build_full_state_patch
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# Callback type for notifying the auto-saver that state changed
|
|
28
|
+
DirtyCallback = Callable[[], None] | None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def handle_message(
|
|
32
|
+
websocket: WebSocket,
|
|
33
|
+
raw: dict[str, Any],
|
|
34
|
+
state: ServerState,
|
|
35
|
+
manager: ConnectionManager,
|
|
36
|
+
on_dirty: DirtyCallback = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Dispatch a parsed JSON message from a client.
|
|
39
|
+
|
|
40
|
+
Never raises -- all errors are sent back to the client as error messages.
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
msg = parse_client_message(raw)
|
|
44
|
+
except (ValueError, Exception) as exc:
|
|
45
|
+
# Malformed message -- send error back
|
|
46
|
+
msg_id = raw.get("id", str(uuid.uuid4()))
|
|
47
|
+
await manager.send(
|
|
48
|
+
websocket,
|
|
49
|
+
{"type": "error", "id": msg_id, "error": f"Invalid message: {exc}"},
|
|
50
|
+
)
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
match msg:
|
|
54
|
+
case CommandMessage():
|
|
55
|
+
await _handle_command(websocket, msg, state, manager, on_dirty)
|
|
56
|
+
case _ if msg.type == "sync_request":
|
|
57
|
+
await _handle_sync_request(websocket, msg, state, manager)
|
|
58
|
+
case UndoMessage():
|
|
59
|
+
await _handle_undo(websocket, msg, state, manager, on_dirty)
|
|
60
|
+
case RedoMessage():
|
|
61
|
+
await _handle_redo(websocket, msg, state, manager, on_dirty)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def _handle_command(
|
|
65
|
+
websocket: WebSocket,
|
|
66
|
+
msg: CommandMessage,
|
|
67
|
+
state: ServerState,
|
|
68
|
+
manager: ConnectionManager,
|
|
69
|
+
on_dirty: DirtyCallback = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Validate command, store in history, ack sender, broadcast to others."""
|
|
72
|
+
project = state.get_active_project()
|
|
73
|
+
if project is None:
|
|
74
|
+
await manager.send(
|
|
75
|
+
websocket,
|
|
76
|
+
{
|
|
77
|
+
"type": "command_reject",
|
|
78
|
+
"id": msg.id,
|
|
79
|
+
"command_id": msg.command.id,
|
|
80
|
+
"error": "No active project",
|
|
81
|
+
},
|
|
82
|
+
)
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
# Store command in history (as a plain dict for JSON serialization).
|
|
86
|
+
# Hold the shared lock so we don't interleave with an in-flight MCP
|
|
87
|
+
# mutation -- both paths must see a consistent history/redo stack.
|
|
88
|
+
cmd_dict = msg.command.model_dump()
|
|
89
|
+
async with state.mutation_lock:
|
|
90
|
+
project.command_history.append(cmd_dict)
|
|
91
|
+
# Any new command clears the redo stack
|
|
92
|
+
project.redo_stack.clear()
|
|
93
|
+
|
|
94
|
+
# Ack the sender
|
|
95
|
+
await manager.send(
|
|
96
|
+
websocket,
|
|
97
|
+
{
|
|
98
|
+
"type": "command_ack",
|
|
99
|
+
"id": msg.id,
|
|
100
|
+
"command_id": msg.command.id,
|
|
101
|
+
"success": True,
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Broadcast to other clients
|
|
106
|
+
await manager.broadcast(
|
|
107
|
+
{"type": "command_broadcast", "command": cmd_dict, "source": "client"},
|
|
108
|
+
exclude=websocket,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if on_dirty:
|
|
112
|
+
on_dirty()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def _handle_sync_request(
|
|
116
|
+
websocket: WebSocket,
|
|
117
|
+
msg: Any,
|
|
118
|
+
state: ServerState,
|
|
119
|
+
manager: ConnectionManager,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Send full state snapshot to the requesting client."""
|
|
122
|
+
sync_state = state.get_sync_state()
|
|
123
|
+
await manager.send(
|
|
124
|
+
websocket,
|
|
125
|
+
{
|
|
126
|
+
"type": "state_sync",
|
|
127
|
+
"id": msg.id,
|
|
128
|
+
"state": sync_state,
|
|
129
|
+
},
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def _handle_undo(
|
|
134
|
+
websocket: WebSocket,
|
|
135
|
+
msg: UndoMessage,
|
|
136
|
+
state: ServerState,
|
|
137
|
+
manager: ConnectionManager,
|
|
138
|
+
on_dirty: DirtyCallback = None,
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Pop last command from history onto redo stack."""
|
|
141
|
+
# Lock around the check + pop so a concurrent MCP/WS mutation can't race
|
|
142
|
+
# the empty-history guard. The lock is released before any I/O.
|
|
143
|
+
undone: dict[str, Any] | None = None
|
|
144
|
+
error: str | None = None
|
|
145
|
+
async with state.mutation_lock:
|
|
146
|
+
project = state.get_active_project()
|
|
147
|
+
if project is None:
|
|
148
|
+
error = "No active project"
|
|
149
|
+
elif not project.command_history:
|
|
150
|
+
error = "Nothing to undo"
|
|
151
|
+
else:
|
|
152
|
+
undone = project.command_history.pop()
|
|
153
|
+
project.redo_stack.append(undone)
|
|
154
|
+
|
|
155
|
+
if undone is None:
|
|
156
|
+
await manager.send(
|
|
157
|
+
websocket,
|
|
158
|
+
{"type": "error", "id": msg.id, "error": error},
|
|
159
|
+
)
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
await manager.send(
|
|
163
|
+
websocket,
|
|
164
|
+
{
|
|
165
|
+
"type": "command_ack",
|
|
166
|
+
"id": msg.id,
|
|
167
|
+
"command_id": undone["id"],
|
|
168
|
+
"success": True,
|
|
169
|
+
},
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Broadcast undo to other clients
|
|
173
|
+
undo_broadcast = {
|
|
174
|
+
"type": "command_broadcast",
|
|
175
|
+
"command": {"type": "undo", "undone": undone},
|
|
176
|
+
"source": "client",
|
|
177
|
+
}
|
|
178
|
+
await manager.broadcast(undo_broadcast, exclude=websocket)
|
|
179
|
+
|
|
180
|
+
if on_dirty:
|
|
181
|
+
on_dirty()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def _handle_redo(
|
|
185
|
+
websocket: WebSocket,
|
|
186
|
+
msg: RedoMessage,
|
|
187
|
+
state: ServerState,
|
|
188
|
+
manager: ConnectionManager,
|
|
189
|
+
on_dirty: DirtyCallback = None,
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Pop last undone command from redo stack back into history."""
|
|
192
|
+
redone: dict[str, Any] | None = None
|
|
193
|
+
error: str | None = None
|
|
194
|
+
async with state.mutation_lock:
|
|
195
|
+
project = state.get_active_project()
|
|
196
|
+
if project is None:
|
|
197
|
+
error = "No active project"
|
|
198
|
+
elif not project.redo_stack:
|
|
199
|
+
error = "Nothing to redo"
|
|
200
|
+
else:
|
|
201
|
+
redone = project.redo_stack.pop()
|
|
202
|
+
project.command_history.append(redone)
|
|
203
|
+
|
|
204
|
+
if redone is None:
|
|
205
|
+
await manager.send(
|
|
206
|
+
websocket,
|
|
207
|
+
{"type": "error", "id": msg.id, "error": error},
|
|
208
|
+
)
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
await manager.send(
|
|
212
|
+
websocket,
|
|
213
|
+
{
|
|
214
|
+
"type": "command_ack",
|
|
215
|
+
"id": msg.id,
|
|
216
|
+
"command_id": redone["id"],
|
|
217
|
+
"success": True,
|
|
218
|
+
},
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Broadcast redo to other clients
|
|
222
|
+
redo_broadcast = {
|
|
223
|
+
"type": "command_broadcast",
|
|
224
|
+
"command": {"type": "redo", "redone": redone},
|
|
225
|
+
"source": "client",
|
|
226
|
+
}
|
|
227
|
+
await manager.broadcast(redo_broadcast, exclude=websocket)
|
|
228
|
+
|
|
229
|
+
if on_dirty:
|
|
230
|
+
on_dirty()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
# Patch broadcasting -- used by MCP when it mutates server state
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
async def broadcast_patches(
|
|
239
|
+
connections: ConnectionManager,
|
|
240
|
+
project_name: str,
|
|
241
|
+
patches: list[dict[str, Any]],
|
|
242
|
+
command_id: str | None = None,
|
|
243
|
+
exclude_ws: WebSocket | None = None,
|
|
244
|
+
) -> None:
|
|
245
|
+
"""Broadcast data patches to all connected WebSocket clients.
|
|
246
|
+
|
|
247
|
+
After MCP modifies server state, call this with the resulting patches
|
|
248
|
+
so every frontend can apply the changes. *exclude_ws* allows skipping
|
|
249
|
+
the originating client (e.g. the one that sent a frontend command --
|
|
250
|
+
it already applied the mutation locally).
|
|
251
|
+
"""
|
|
252
|
+
message = {
|
|
253
|
+
"type": "state_patch",
|
|
254
|
+
"project_name": project_name,
|
|
255
|
+
"patches": patches,
|
|
256
|
+
"command_id": command_id,
|
|
257
|
+
}
|
|
258
|
+
await connections.broadcast(message, exclude=exclude_ws)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
async def broadcast_full_state(
|
|
262
|
+
state: ServerState,
|
|
263
|
+
connections: ConnectionManager,
|
|
264
|
+
project_name: str | None = None,
|
|
265
|
+
command_id: str | None = None,
|
|
266
|
+
exclude_ws: WebSocket | None = None,
|
|
267
|
+
) -> None:
|
|
268
|
+
"""Build a full-state patch from the active project and broadcast it.
|
|
269
|
+
|
|
270
|
+
Convenience wrapper combining build_full_state_patch + broadcast_patches.
|
|
271
|
+
Used by MCP tools after they mutate pixel data / layers / canvas.
|
|
272
|
+
"""
|
|
273
|
+
name = project_name or state.active_project
|
|
274
|
+
if name is None:
|
|
275
|
+
return
|
|
276
|
+
project = state.get_project(name)
|
|
277
|
+
if project is None:
|
|
278
|
+
return
|
|
279
|
+
patch = build_full_state_patch(project)
|
|
280
|
+
await broadcast_patches(
|
|
281
|
+
connections, name, [patch], command_id=command_id, exclude_ws=exclude_ws,
|
|
282
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from wesktop.testing import TestClient
|
|
3
|
+
from pixelweaver.main import app, state
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.fixture
|
|
7
|
+
def client():
|
|
8
|
+
return TestClient(app)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture(autouse=True)
|
|
12
|
+
def _reset_state():
|
|
13
|
+
state.projects.clear()
|
|
14
|
+
state.active_project = None
|
|
15
|
+
yield
|
|
16
|
+
state.projects.clear()
|
|
17
|
+
state.active_project = None
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Tests for the REST API endpoints."""
|
|
2
|
+
|
|
3
|
+
from pixelweaver.main import state
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestCreateProject:
|
|
7
|
+
def test_create_returns_201(self, client, tmp_path):
|
|
8
|
+
"""Creating a project should return 201 with project metadata."""
|
|
9
|
+
from pixelweaver.config import set_data_dir
|
|
10
|
+
set_data_dir(str(tmp_path))
|
|
11
|
+
|
|
12
|
+
response = client.post("/api/projects", json={
|
|
13
|
+
"name": "my-sprite",
|
|
14
|
+
"width": 32,
|
|
15
|
+
"height": 32,
|
|
16
|
+
})
|
|
17
|
+
assert response.status_code == 201
|
|
18
|
+
data = response.json()
|
|
19
|
+
assert data["name"] == "my-sprite"
|
|
20
|
+
assert data["width"] == 32
|
|
21
|
+
|
|
22
|
+
def test_create_duplicate_returns_409(self, client, tmp_path):
|
|
23
|
+
from pixelweaver.config import set_data_dir
|
|
24
|
+
set_data_dir(str(tmp_path))
|
|
25
|
+
|
|
26
|
+
body = {"name": "dup", "width": 16, "height": 16}
|
|
27
|
+
client.post("/api/projects", json=body)
|
|
28
|
+
response = client.post("/api/projects", json=body)
|
|
29
|
+
assert response.status_code == 409
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestListProjects:
|
|
33
|
+
def test_list_empty(self, client):
|
|
34
|
+
response = client.get("/api/projects")
|
|
35
|
+
assert response.status_code == 200
|
|
36
|
+
assert response.json() == {"projects": []}
|
|
37
|
+
|
|
38
|
+
def test_list_includes_created(self, client, tmp_path):
|
|
39
|
+
from pixelweaver.config import set_data_dir
|
|
40
|
+
set_data_dir(str(tmp_path))
|
|
41
|
+
|
|
42
|
+
client.post("/api/projects", json={"name": "proj-a", "width": 8, "height": 8})
|
|
43
|
+
response = client.get("/api/projects")
|
|
44
|
+
assert "proj-a" in response.json()["projects"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TestGetProject:
|
|
48
|
+
def test_get_existing(self, client, tmp_path):
|
|
49
|
+
from pixelweaver.config import set_data_dir
|
|
50
|
+
set_data_dir(str(tmp_path))
|
|
51
|
+
|
|
52
|
+
client.post("/api/projects", json={"name": "proj-b", "width": 16, "height": 16})
|
|
53
|
+
response = client.get("/api/projects/proj-b")
|
|
54
|
+
assert response.status_code == 200
|
|
55
|
+
data = response.json()
|
|
56
|
+
assert data["name"] == "proj-b"
|
|
57
|
+
assert "canvases" in data
|
|
58
|
+
|
|
59
|
+
def test_get_nonexistent_returns_404(self, client):
|
|
60
|
+
response = client.get("/api/projects/no-such-project")
|
|
61
|
+
assert response.status_code == 404
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TestDeleteProject:
|
|
65
|
+
def test_delete_existing_returns_204(self, client, tmp_path):
|
|
66
|
+
from pixelweaver.config import set_data_dir
|
|
67
|
+
set_data_dir(str(tmp_path))
|
|
68
|
+
|
|
69
|
+
client.post("/api/projects", json={"name": "to-delete", "width": 8, "height": 8})
|
|
70
|
+
response = client.delete("/api/projects/to-delete")
|
|
71
|
+
assert response.status_code == 204
|
|
72
|
+
|
|
73
|
+
# Verify it's gone
|
|
74
|
+
response = client.get("/api/projects/to-delete")
|
|
75
|
+
assert response.status_code == 404
|
|
76
|
+
|
|
77
|
+
def test_delete_nonexistent_returns_404(self, client):
|
|
78
|
+
response = client.delete("/api/projects/no-such-project")
|
|
79
|
+
assert response.status_code == 404
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TestExportPng:
|
|
83
|
+
def test_export_returns_png(self, client, tmp_path):
|
|
84
|
+
from pixelweaver.config import set_data_dir
|
|
85
|
+
set_data_dir(str(tmp_path))
|
|
86
|
+
|
|
87
|
+
client.post("/api/projects", json={"name": "export-test", "width": 8, "height": 8})
|
|
88
|
+
response = client.get("/api/projects/export-test/export/png/canvas-0/0")
|
|
89
|
+
assert response.status_code == 200
|
|
90
|
+
assert response.headers["content-type"] == "image/png"
|
|
91
|
+
# PNG magic bytes
|
|
92
|
+
assert response.content[:8] == b"\x89PNG\r\n\x1a\n"
|
|
93
|
+
|
|
94
|
+
def test_export_nonexistent_project_returns_404(self, client):
|
|
95
|
+
response = client.get("/api/projects/nope/export/png/canvas-0/0")
|
|
96
|
+
assert response.status_code == 404
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Tests for DesktopBridge (pywebview file I/O bridge)."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from unittest.mock import MagicMock, patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from pixelweaver.bridge import DesktopBridge
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture()
|
|
14
|
+
def mock_webview():
|
|
15
|
+
"""Inject a mock webview module into sys.modules for late imports."""
|
|
16
|
+
mock = MagicMock()
|
|
17
|
+
mock.SAVE_DIALOG = 20
|
|
18
|
+
mock.OPEN_DIALOG = 10
|
|
19
|
+
mock.FOLDER_DIALOG = 30
|
|
20
|
+
with patch.dict(sys.modules, {"webview": mock}):
|
|
21
|
+
yield mock
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_save_file_writes_data(mock_webview, tmp_path):
|
|
25
|
+
"""save_file decodes base64 data and writes it to the chosen path."""
|
|
26
|
+
mock_window = MagicMock()
|
|
27
|
+
mock_webview.windows = [mock_window]
|
|
28
|
+
|
|
29
|
+
out_path = str(tmp_path / "test.pwv")
|
|
30
|
+
mock_window.create_file_dialog.return_value = (out_path,)
|
|
31
|
+
|
|
32
|
+
bridge = DesktopBridge()
|
|
33
|
+
data = b"hello world"
|
|
34
|
+
result = bridge.save_file(
|
|
35
|
+
base64.b64encode(data).decode(), "test.pwv", "PixelWeaver", ["pwv"]
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
assert result == out_path
|
|
39
|
+
assert Path(out_path).read_bytes() == data
|
|
40
|
+
mock_window.create_file_dialog.assert_called_once_with(
|
|
41
|
+
20,
|
|
42
|
+
save_filename="test.pwv",
|
|
43
|
+
file_types=("PixelWeaver (*.pwv)",),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_save_file_cancelled(mock_webview, tmp_path):
|
|
48
|
+
"""save_file returns None when the user cancels the dialog."""
|
|
49
|
+
mock_window = MagicMock()
|
|
50
|
+
mock_webview.windows = [mock_window]
|
|
51
|
+
mock_window.create_file_dialog.return_value = None
|
|
52
|
+
|
|
53
|
+
bridge = DesktopBridge()
|
|
54
|
+
result = bridge.save_file(
|
|
55
|
+
base64.b64encode(b"data").decode(), "test.pwv", "PixelWeaver", ["pwv"]
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
assert result is None
|
|
59
|
+
# Nothing should have been written
|
|
60
|
+
assert not list(tmp_path.iterdir())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_open_file_reads_data(mock_webview, tmp_path):
|
|
64
|
+
"""open_file reads the selected file and returns path + base64 data."""
|
|
65
|
+
mock_window = MagicMock()
|
|
66
|
+
mock_webview.windows = [mock_window]
|
|
67
|
+
|
|
68
|
+
test_file = tmp_path / "image.png"
|
|
69
|
+
content = b"\x89PNG\r\n\x1a\nfake png data"
|
|
70
|
+
test_file.write_bytes(content)
|
|
71
|
+
|
|
72
|
+
mock_window.create_file_dialog.return_value = (str(test_file),)
|
|
73
|
+
|
|
74
|
+
bridge = DesktopBridge()
|
|
75
|
+
result = bridge.open_file("Images", ["png", "jpg"])
|
|
76
|
+
|
|
77
|
+
assert result is not None
|
|
78
|
+
assert result["path"] == str(test_file)
|
|
79
|
+
assert base64.b64decode(result["data"]) == content
|
|
80
|
+
mock_window.create_file_dialog.assert_called_once_with(
|
|
81
|
+
10,
|
|
82
|
+
file_types=("Images (*.png *.jpg)",),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_open_file_cancelled(mock_webview):
|
|
87
|
+
"""open_file returns None when the user cancels the dialog."""
|
|
88
|
+
mock_window = MagicMock()
|
|
89
|
+
mock_webview.windows = [mock_window]
|
|
90
|
+
mock_window.create_file_dialog.return_value = None
|
|
91
|
+
|
|
92
|
+
bridge = DesktopBridge()
|
|
93
|
+
result = bridge.open_file("Images", ["png"])
|
|
94
|
+
|
|
95
|
+
assert result is None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_write_file_success(tmp_path):
|
|
99
|
+
"""write_file decodes and writes data to the given path."""
|
|
100
|
+
out_path = str(tmp_path / "output.bin")
|
|
101
|
+
data = b"\x00\x01\x02\x03"
|
|
102
|
+
|
|
103
|
+
bridge = DesktopBridge()
|
|
104
|
+
result = bridge.write_file(out_path, base64.b64encode(data).decode())
|
|
105
|
+
|
|
106
|
+
assert result is True
|
|
107
|
+
assert Path(out_path).read_bytes() == data
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_write_file_creates_parent_dirs(tmp_path):
|
|
111
|
+
"""write_file creates intermediate directories as needed."""
|
|
112
|
+
out_path = str(tmp_path / "deep" / "nested" / "dir" / "file.dat")
|
|
113
|
+
data = b"nested file content"
|
|
114
|
+
|
|
115
|
+
bridge = DesktopBridge()
|
|
116
|
+
result = bridge.write_file(out_path, base64.b64encode(data).decode())
|
|
117
|
+
|
|
118
|
+
assert result is True
|
|
119
|
+
assert Path(out_path).read_bytes() == data
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_pick_directory_returns_path(mock_webview):
|
|
123
|
+
"""pick_directory returns the selected directory path."""
|
|
124
|
+
mock_window = MagicMock()
|
|
125
|
+
mock_webview.windows = [mock_window]
|
|
126
|
+
mock_window.create_file_dialog.return_value = ("/home/user/exports",)
|
|
127
|
+
|
|
128
|
+
bridge = DesktopBridge()
|
|
129
|
+
result = bridge.pick_directory()
|
|
130
|
+
|
|
131
|
+
assert result == "/home/user/exports"
|
|
132
|
+
mock_window.create_file_dialog.assert_called_once_with(30)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_pick_directory_cancelled(mock_webview):
|
|
136
|
+
"""pick_directory returns None when the user cancels."""
|
|
137
|
+
mock_window = MagicMock()
|
|
138
|
+
mock_webview.windows = [mock_window]
|
|
139
|
+
mock_window.create_file_dialog.return_value = None
|
|
140
|
+
|
|
141
|
+
bridge = DesktopBridge()
|
|
142
|
+
result = bridge.pick_directory()
|
|
143
|
+
|
|
144
|
+
assert result is None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_write_files_to_directory(tmp_path):
|
|
148
|
+
"""write_files_to_directory writes all files to the target directory."""
|
|
149
|
+
files = [
|
|
150
|
+
{"name": "frame_0.png", "data": base64.b64encode(b"png0").decode()},
|
|
151
|
+
{"name": "frame_1.png", "data": base64.b64encode(b"png1").decode()},
|
|
152
|
+
{"name": "sub/frame_2.png", "data": base64.b64encode(b"png2").decode()},
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
bridge = DesktopBridge()
|
|
156
|
+
result = bridge.write_files_to_directory(str(tmp_path / "output"), files)
|
|
157
|
+
|
|
158
|
+
assert result is True
|
|
159
|
+
assert (tmp_path / "output" / "frame_0.png").read_bytes() == b"png0"
|
|
160
|
+
assert (tmp_path / "output" / "frame_1.png").read_bytes() == b"png1"
|
|
161
|
+
assert (tmp_path / "output" / "sub" / "frame_2.png").read_bytes() == b"png2"
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Integration test: full project lifecycle through the API."""
|
|
2
|
+
|
|
3
|
+
from pixelweaver.main import app
|
|
4
|
+
from wesktop.testing import TestClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_project_lifecycle(client, tmp_path):
|
|
8
|
+
"""Create, read, export, and delete a project."""
|
|
9
|
+
from pixelweaver.config import set_data_dir
|
|
10
|
+
set_data_dir(str(tmp_path))
|
|
11
|
+
|
|
12
|
+
# Create
|
|
13
|
+
resp = client.post("/api/projects", json={
|
|
14
|
+
"name": "integration-test",
|
|
15
|
+
"width": 16,
|
|
16
|
+
"height": 16,
|
|
17
|
+
})
|
|
18
|
+
assert resp.status_code == 201
|
|
19
|
+
|
|
20
|
+
# List
|
|
21
|
+
resp = client.get("/api/projects")
|
|
22
|
+
assert resp.status_code == 200
|
|
23
|
+
assert "integration-test" in resp.json()["projects"]
|
|
24
|
+
|
|
25
|
+
# Get
|
|
26
|
+
resp = client.get("/api/projects/integration-test")
|
|
27
|
+
assert resp.status_code == 200
|
|
28
|
+
data = resp.json()
|
|
29
|
+
assert "canvases" in data
|
|
30
|
+
assert len(data["canvases"]) > 0
|
|
31
|
+
|
|
32
|
+
# Export PNG
|
|
33
|
+
canvas_name = list(data["canvases"].keys())[0]
|
|
34
|
+
resp = client.get(f"/api/projects/integration-test/export/png/{canvas_name}/0")
|
|
35
|
+
assert resp.status_code == 200
|
|
36
|
+
assert resp.headers.get("content-type") == "image/png"
|
|
37
|
+
assert len(resp.content) > 0
|
|
38
|
+
|
|
39
|
+
# State endpoints (MCP bridge)
|
|
40
|
+
resp = client.get("/api/state/full")
|
|
41
|
+
assert resp.status_code == 200
|
|
42
|
+
state = resp.json()
|
|
43
|
+
assert "integration-test" in state.get("projects", {})
|
|
44
|
+
|
|
45
|
+
# Delete
|
|
46
|
+
resp = client.delete("/api/projects/integration-test")
|
|
47
|
+
assert resp.status_code == 204
|
|
48
|
+
|
|
49
|
+
# Verify deleted
|
|
50
|
+
resp = client.get("/api/projects/integration-test")
|
|
51
|
+
assert resp.status_code == 404
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_validation_errors(client):
|
|
55
|
+
"""Test that invalid requests return proper error codes."""
|
|
56
|
+
# Invalid project name (special chars)
|
|
57
|
+
resp = client.post("/api/projects", json={
|
|
58
|
+
"name": "../evil",
|
|
59
|
+
"width": 16,
|
|
60
|
+
"height": 16,
|
|
61
|
+
})
|
|
62
|
+
assert resp.status_code == 422
|
|
63
|
+
|
|
64
|
+
# Width too large
|
|
65
|
+
resp = client.post("/api/projects", json={
|
|
66
|
+
"name": "too-big",
|
|
67
|
+
"width": 9999,
|
|
68
|
+
"height": 16,
|
|
69
|
+
})
|
|
70
|
+
assert resp.status_code == 422
|
|
71
|
+
|
|
72
|
+
# Missing fields
|
|
73
|
+
resp = client.post("/api/projects", json={"name": "incomplete"})
|
|
74
|
+
assert resp.status_code == 422
|
|
75
|
+
|
|
76
|
+
# Get nonexistent
|
|
77
|
+
resp = client.get("/api/projects/does-not-exist")
|
|
78
|
+
assert resp.status_code == 404
|
|
79
|
+
|
|
80
|
+
# Delete nonexistent
|
|
81
|
+
resp = client.delete("/api/projects/does-not-exist")
|
|
82
|
+
assert resp.status_code == 404
|
|
83
|
+
|
|
84
|
+
# Export from nonexistent
|
|
85
|
+
resp = client.get("/api/projects/nope/export/png/canvas-0/0")
|
|
86
|
+
assert resp.status_code == 404
|