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,125 @@
|
|
|
1
|
+
"""Tests for the WebSocket JSON protocol message models."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from pydantic import ValidationError
|
|
5
|
+
|
|
6
|
+
from pixelweaver.protocol import (
|
|
7
|
+
CommandAckMessage,
|
|
8
|
+
CommandBroadcastMessage,
|
|
9
|
+
CommandMessage,
|
|
10
|
+
CommandRejectMessage,
|
|
11
|
+
ErrorMessage,
|
|
12
|
+
RedoMessage,
|
|
13
|
+
StateSyncMessage,
|
|
14
|
+
SyncRequestMessage,
|
|
15
|
+
UndoMessage,
|
|
16
|
+
parse_client_message,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Client -> Server message parsing
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestParseClientMessage:
|
|
25
|
+
def test_parse_command(self):
|
|
26
|
+
raw = {
|
|
27
|
+
"type": "command",
|
|
28
|
+
"id": "msg-1",
|
|
29
|
+
"command": {
|
|
30
|
+
"type": "draw_pixels",
|
|
31
|
+
"plugin": "builtin/pencil",
|
|
32
|
+
"version": "1.0.0",
|
|
33
|
+
"params": {"x": 10, "y": 20},
|
|
34
|
+
"id": "cmd-1",
|
|
35
|
+
"timestamp": 1234567890,
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
msg = parse_client_message(raw)
|
|
39
|
+
assert isinstance(msg, CommandMessage)
|
|
40
|
+
assert msg.id == "msg-1"
|
|
41
|
+
assert msg.command.type == "draw_pixels"
|
|
42
|
+
assert msg.command.plugin == "builtin/pencil"
|
|
43
|
+
assert msg.command.params == {"x": 10, "y": 20}
|
|
44
|
+
|
|
45
|
+
def test_parse_sync_request(self):
|
|
46
|
+
raw = {"type": "sync_request", "id": "msg-2"}
|
|
47
|
+
msg = parse_client_message(raw)
|
|
48
|
+
assert isinstance(msg, SyncRequestMessage)
|
|
49
|
+
assert msg.id == "msg-2"
|
|
50
|
+
|
|
51
|
+
def test_parse_undo(self):
|
|
52
|
+
raw = {"type": "undo", "id": "msg-3"}
|
|
53
|
+
msg = parse_client_message(raw)
|
|
54
|
+
assert isinstance(msg, UndoMessage)
|
|
55
|
+
|
|
56
|
+
def test_parse_redo(self):
|
|
57
|
+
raw = {"type": "redo", "id": "msg-4"}
|
|
58
|
+
msg = parse_client_message(raw)
|
|
59
|
+
assert isinstance(msg, RedoMessage)
|
|
60
|
+
|
|
61
|
+
def test_unknown_type_raises(self):
|
|
62
|
+
with pytest.raises(ValueError, match="Unknown message type"):
|
|
63
|
+
parse_client_message({"type": "bogus", "id": "x"})
|
|
64
|
+
|
|
65
|
+
def test_missing_type_raises(self):
|
|
66
|
+
with pytest.raises(ValueError, match="Unknown message type"):
|
|
67
|
+
parse_client_message({"id": "x"})
|
|
68
|
+
|
|
69
|
+
def test_malformed_command_raises(self):
|
|
70
|
+
"""Command message missing required 'command' field."""
|
|
71
|
+
with pytest.raises(Exception):
|
|
72
|
+
parse_client_message({"type": "command", "id": "x"})
|
|
73
|
+
|
|
74
|
+
def test_command_missing_inner_fields_raises(self):
|
|
75
|
+
"""Command payload missing required fields."""
|
|
76
|
+
with pytest.raises(Exception):
|
|
77
|
+
parse_client_message({
|
|
78
|
+
"type": "command",
|
|
79
|
+
"id": "msg-1",
|
|
80
|
+
"command": {"type": "draw"},
|
|
81
|
+
# missing plugin, version, id, timestamp
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# Server -> Client models (construction)
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class TestServerMessages:
|
|
91
|
+
def test_command_ack(self):
|
|
92
|
+
msg = CommandAckMessage(id="msg-1", command_id="cmd-1")
|
|
93
|
+
d = msg.model_dump()
|
|
94
|
+
assert d["type"] == "command_ack"
|
|
95
|
+
assert d["success"] is True
|
|
96
|
+
assert d["id"] == "msg-1"
|
|
97
|
+
|
|
98
|
+
def test_command_reject(self):
|
|
99
|
+
msg = CommandRejectMessage(id="msg-1", command_id="cmd-1", error="Bad pixel")
|
|
100
|
+
d = msg.model_dump()
|
|
101
|
+
assert d["type"] == "command_reject"
|
|
102
|
+
assert d["error"] == "Bad pixel"
|
|
103
|
+
|
|
104
|
+
def test_state_sync(self):
|
|
105
|
+
msg = StateSyncMessage(id="msg-1", state={"project": None})
|
|
106
|
+
d = msg.model_dump()
|
|
107
|
+
assert d["type"] == "state_sync"
|
|
108
|
+
assert d["state"]["project"] is None
|
|
109
|
+
|
|
110
|
+
def test_command_broadcast(self):
|
|
111
|
+
msg = CommandBroadcastMessage(command={"type": "draw"}, source="mcp")
|
|
112
|
+
d = msg.model_dump()
|
|
113
|
+
assert d["type"] == "command_broadcast"
|
|
114
|
+
assert d["source"] == "mcp"
|
|
115
|
+
|
|
116
|
+
def test_error(self):
|
|
117
|
+
msg = ErrorMessage(id="msg-1", error="Something went wrong")
|
|
118
|
+
d = msg.model_dump()
|
|
119
|
+
assert d["type"] == "error"
|
|
120
|
+
assert d["error"] == "Something went wrong"
|
|
121
|
+
|
|
122
|
+
def test_ack_type_literal_enforced(self):
|
|
123
|
+
"""The type field must be the literal value."""
|
|
124
|
+
with pytest.raises(ValidationError):
|
|
125
|
+
CommandAckMessage(type="wrong", id="x", command_id="y")
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Tests for FrameState and multi-frame CanvasState accessors."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from pixelweaver.state import CanvasState, FrameState, ServerState
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestFrameState:
|
|
9
|
+
def test_creation_defaults(self):
|
|
10
|
+
f = FrameState(id="abc")
|
|
11
|
+
assert f.id == "abc"
|
|
12
|
+
assert f.duration_ms is None
|
|
13
|
+
assert f.pixel_data == {}
|
|
14
|
+
|
|
15
|
+
def test_creation_with_data(self):
|
|
16
|
+
data = {"layer-1": b"\x00" * 16}
|
|
17
|
+
f = FrameState(id="xyz", duration_ms=100, pixel_data=data)
|
|
18
|
+
assert f.duration_ms == 100
|
|
19
|
+
assert f.pixel_data["layer-1"] == b"\x00" * 16
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestCanvasFrameAccessors:
|
|
23
|
+
def _make_canvas(self, n_frames: int = 3) -> CanvasState:
|
|
24
|
+
frames = [FrameState(id=f"frame-{i}") for i in range(n_frames)]
|
|
25
|
+
canvas = CanvasState(
|
|
26
|
+
name="test",
|
|
27
|
+
width=8,
|
|
28
|
+
height=8,
|
|
29
|
+
layers=[],
|
|
30
|
+
frames=frames,
|
|
31
|
+
)
|
|
32
|
+
return canvas
|
|
33
|
+
|
|
34
|
+
def test_current_frame(self):
|
|
35
|
+
canvas = self._make_canvas()
|
|
36
|
+
canvas.current_frame_index = 1
|
|
37
|
+
assert canvas.current_frame().id == "frame-1"
|
|
38
|
+
|
|
39
|
+
def test_current_frame_clamps(self):
|
|
40
|
+
canvas = self._make_canvas()
|
|
41
|
+
canvas.current_frame_index = 99
|
|
42
|
+
assert canvas.current_frame().id == "frame-2" # last frame
|
|
43
|
+
|
|
44
|
+
def test_current_frame_empty_raises(self):
|
|
45
|
+
canvas = self._make_canvas(0)
|
|
46
|
+
with pytest.raises(ValueError, match="no frames"):
|
|
47
|
+
canvas.current_frame()
|
|
48
|
+
|
|
49
|
+
def test_frame_at(self):
|
|
50
|
+
canvas = self._make_canvas()
|
|
51
|
+
assert canvas.frame_at(2).id == "frame-2"
|
|
52
|
+
|
|
53
|
+
def test_frame_at_out_of_range(self):
|
|
54
|
+
canvas = self._make_canvas()
|
|
55
|
+
with pytest.raises(IndexError):
|
|
56
|
+
canvas.frame_at(5)
|
|
57
|
+
|
|
58
|
+
def test_frame_count(self):
|
|
59
|
+
canvas = self._make_canvas()
|
|
60
|
+
assert canvas.frame_count == 3
|
|
61
|
+
|
|
62
|
+
def test_frame_count_empty(self):
|
|
63
|
+
canvas = self._make_canvas(0)
|
|
64
|
+
assert canvas.frame_count == 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TestCreateProjectSeedsFrames:
|
|
68
|
+
def test_new_project_has_one_frame(self):
|
|
69
|
+
state = ServerState()
|
|
70
|
+
state.create_project("test-proj", 16, 16)
|
|
71
|
+
project = state.projects["test-proj"]
|
|
72
|
+
canvas = list(project.canvases.values())[0]
|
|
73
|
+
assert canvas.frame_count == 1
|
|
74
|
+
frame = canvas.frames[0]
|
|
75
|
+
assert frame.id # non-empty UUID
|
|
76
|
+
assert frame.duration_ms is None
|
|
77
|
+
|
|
78
|
+
def test_frame_pixel_data_is_sole_owner(self):
|
|
79
|
+
"""Pixel data lives only in FrameState, not on CanvasState."""
|
|
80
|
+
state = ServerState()
|
|
81
|
+
state.create_project("test-proj", 4, 4)
|
|
82
|
+
canvas = list(state.projects["test-proj"].canvases.values())[0]
|
|
83
|
+
assert not hasattr(canvas, "pixel_data") or "pixel_data" not in canvas.__dataclass_fields__
|
|
84
|
+
# Pixel data is accessible through the frame
|
|
85
|
+
layer_id = canvas.layers[0]["id"]
|
|
86
|
+
assert layer_id in canvas.frames[0].pixel_data
|
|
87
|
+
assert len(canvas.frames[0].pixel_data[layer_id]) == 4 * 4 * 4
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Tests for project file I/O (save, load, list, export)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import uuid
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from pixelweaver.state import CanvasState, FrameState, ProjectState, ServerState
|
|
10
|
+
from pixelweaver.storage import export_frame_png, list_projects, load_project, save_project
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture()
|
|
14
|
+
def tmp_data_dir(tmp_path: Path) -> str:
|
|
15
|
+
"""Provide a temporary data directory path as a string."""
|
|
16
|
+
return str(tmp_path)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture()
|
|
20
|
+
def sample_project() -> ProjectState:
|
|
21
|
+
"""Create a sample project with one canvas and one layer."""
|
|
22
|
+
state = ServerState()
|
|
23
|
+
project = state.create_project("test-project", 16, 16)
|
|
24
|
+
return project
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _make_multi_frame_project() -> ProjectState:
|
|
28
|
+
"""Create a project with one canvas containing two frames and two layers.
|
|
29
|
+
|
|
30
|
+
Frame 0: layer-a has red pixels, layer-b has green pixels.
|
|
31
|
+
Frame 1: layer-a has blue pixels, layer-b has white pixels.
|
|
32
|
+
Each frame has duration_ms set (None for frame 0, 200 for frame 1).
|
|
33
|
+
"""
|
|
34
|
+
w, h = 4, 4
|
|
35
|
+
layer_a_id = "layer-a"
|
|
36
|
+
layer_b_id = "layer-b"
|
|
37
|
+
|
|
38
|
+
# 4x4 RGBA = 64 bytes per layer
|
|
39
|
+
red_px = (b"\xff\x00\x00\xff") * (w * h)
|
|
40
|
+
green_px = (b"\x00\xff\x00\xff") * (w * h)
|
|
41
|
+
blue_px = (b"\x00\x00\xff\xff") * (w * h)
|
|
42
|
+
white_px = (b"\xff\xff\xff\xff") * (w * h)
|
|
43
|
+
|
|
44
|
+
frame0 = FrameState(
|
|
45
|
+
id=str(uuid.uuid4()),
|
|
46
|
+
duration_ms=None,
|
|
47
|
+
pixel_data={layer_a_id: red_px, layer_b_id: green_px},
|
|
48
|
+
)
|
|
49
|
+
frame1 = FrameState(
|
|
50
|
+
id=str(uuid.uuid4()),
|
|
51
|
+
duration_ms=200,
|
|
52
|
+
pixel_data={layer_a_id: blue_px, layer_b_id: white_px},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
canvas = CanvasState(
|
|
56
|
+
name="canvas-0",
|
|
57
|
+
width=w,
|
|
58
|
+
height=h,
|
|
59
|
+
layers=[
|
|
60
|
+
{"id": layer_a_id, "name": "Layer A", "visible": True, "opacity": 1.0},
|
|
61
|
+
{"id": layer_b_id, "name": "Layer B", "visible": True, "opacity": 1.0},
|
|
62
|
+
],
|
|
63
|
+
frames=[frame0, frame1],
|
|
64
|
+
current_frame_index=1,
|
|
65
|
+
global_fps=24.0,
|
|
66
|
+
origin_x=5,
|
|
67
|
+
origin_y=10,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
project = ProjectState(name="multi-frame-project", width=w, height=h)
|
|
71
|
+
project.canvases["canvas-0"] = canvas
|
|
72
|
+
return project
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TestSaveAndLoad:
|
|
76
|
+
def test_save_creates_expected_files(self, tmp_data_dir: str, sample_project: ProjectState):
|
|
77
|
+
save_project(sample_project, tmp_data_dir)
|
|
78
|
+
|
|
79
|
+
project_dir = Path(tmp_data_dir) / "test-project"
|
|
80
|
+
assert project_dir.is_dir()
|
|
81
|
+
assert (project_dir / "project.json").is_file()
|
|
82
|
+
assert (project_dir / "history.json").is_file()
|
|
83
|
+
assert (project_dir / "canvases" / "canvas-0" / "canvas.json").is_file()
|
|
84
|
+
assert (project_dir / "canvases" / "canvas-0" / "frames.json").is_file()
|
|
85
|
+
|
|
86
|
+
# Verify project.json content
|
|
87
|
+
meta = json.loads((project_dir / "project.json").read_text())
|
|
88
|
+
assert meta["name"] == "test-project"
|
|
89
|
+
assert meta["width"] == 16
|
|
90
|
+
assert meta["height"] == 16
|
|
91
|
+
assert "canvas-0" in meta["canvases"]
|
|
92
|
+
|
|
93
|
+
def test_save_creates_frames_json(self, tmp_data_dir: str, sample_project: ProjectState):
|
|
94
|
+
save_project(sample_project, tmp_data_dir)
|
|
95
|
+
|
|
96
|
+
frames_json_path = (
|
|
97
|
+
Path(tmp_data_dir) / "test-project" / "canvases" / "canvas-0" / "frames.json"
|
|
98
|
+
)
|
|
99
|
+
manifest = json.loads(frames_json_path.read_text())
|
|
100
|
+
|
|
101
|
+
assert "frames" in manifest
|
|
102
|
+
assert len(manifest["frames"]) == 1
|
|
103
|
+
assert "id" in manifest["frames"][0]
|
|
104
|
+
assert manifest["current_frame_index"] == 0
|
|
105
|
+
assert manifest["global_fps"] == 12.0
|
|
106
|
+
assert manifest["origin_x"] == 0
|
|
107
|
+
assert manifest["origin_y"] == 0
|
|
108
|
+
|
|
109
|
+
def test_save_creates_uuid_frame_dirs(self, tmp_data_dir: str, sample_project: ProjectState):
|
|
110
|
+
save_project(sample_project, tmp_data_dir)
|
|
111
|
+
|
|
112
|
+
canvas_dir = Path(tmp_data_dir) / "test-project" / "canvases" / "canvas-0"
|
|
113
|
+
manifest = json.loads((canvas_dir / "frames.json").read_text())
|
|
114
|
+
frame_id = manifest["frames"][0]["id"]
|
|
115
|
+
|
|
116
|
+
# Frame directory should be named by UUID, not "0"
|
|
117
|
+
frame_dir = canvas_dir / "frames" / frame_id
|
|
118
|
+
assert frame_dir.is_dir()
|
|
119
|
+
assert not (canvas_dir / "frames" / "0").exists()
|
|
120
|
+
|
|
121
|
+
# There should be at least one layer PNG
|
|
122
|
+
pngs = list(frame_dir.glob("layer-*.png"))
|
|
123
|
+
assert len(pngs) == 1
|
|
124
|
+
|
|
125
|
+
def test_save_cleans_up_old_frame_dirs(self, tmp_data_dir: str, sample_project: ProjectState):
|
|
126
|
+
"""Old frames/0/ directory should be removed after save."""
|
|
127
|
+
# Create a fake old-format directory
|
|
128
|
+
canvas_dir = Path(tmp_data_dir) / "test-project" / "canvases" / "canvas-0"
|
|
129
|
+
old_frame_dir = canvas_dir / "frames" / "0"
|
|
130
|
+
old_frame_dir.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
(old_frame_dir / "layer-fake.png").write_bytes(b"fake")
|
|
132
|
+
|
|
133
|
+
save_project(sample_project, tmp_data_dir)
|
|
134
|
+
|
|
135
|
+
# Old "0" directory should be gone
|
|
136
|
+
assert not old_frame_dir.exists()
|
|
137
|
+
|
|
138
|
+
def test_round_trip(self, tmp_data_dir: str, sample_project: ProjectState):
|
|
139
|
+
"""Save then load should produce an equivalent project."""
|
|
140
|
+
# Add a command to history before saving
|
|
141
|
+
sample_project.command_history.append({"type": "draw_pixels", "id": "cmd-1"})
|
|
142
|
+
|
|
143
|
+
save_project(sample_project, tmp_data_dir)
|
|
144
|
+
loaded = load_project(f"{tmp_data_dir}/test-project")
|
|
145
|
+
|
|
146
|
+
assert loaded.name == sample_project.name
|
|
147
|
+
assert loaded.width == sample_project.width
|
|
148
|
+
assert loaded.height == sample_project.height
|
|
149
|
+
assert len(loaded.canvases) == len(sample_project.canvases)
|
|
150
|
+
assert "canvas-0" in loaded.canvases
|
|
151
|
+
assert len(loaded.command_history) == 1
|
|
152
|
+
assert loaded.command_history[0]["id"] == "cmd-1"
|
|
153
|
+
|
|
154
|
+
# Pixel data should round-trip through PNG
|
|
155
|
+
orig_canvas = sample_project.canvases["canvas-0"]
|
|
156
|
+
loaded_canvas = loaded.canvases["canvas-0"]
|
|
157
|
+
assert len(loaded_canvas.layers) == len(orig_canvas.layers)
|
|
158
|
+
orig_pd = orig_canvas.current_frame().pixel_data
|
|
159
|
+
loaded_pd = loaded_canvas.current_frame().pixel_data
|
|
160
|
+
assert len(loaded_pd) == len(orig_pd)
|
|
161
|
+
|
|
162
|
+
for layer_id in orig_pd:
|
|
163
|
+
assert layer_id in loaded_pd
|
|
164
|
+
assert loaded_pd[layer_id] == orig_pd[layer_id]
|
|
165
|
+
|
|
166
|
+
def test_round_trip_preserves_frame_ids(self, tmp_data_dir: str, sample_project: ProjectState):
|
|
167
|
+
"""Frame UUIDs should survive a save/load round-trip."""
|
|
168
|
+
orig_frame_id = sample_project.canvases["canvas-0"].frames[0].id
|
|
169
|
+
|
|
170
|
+
save_project(sample_project, tmp_data_dir)
|
|
171
|
+
loaded = load_project(f"{tmp_data_dir}/test-project")
|
|
172
|
+
|
|
173
|
+
loaded_frame_id = loaded.canvases["canvas-0"].frames[0].id
|
|
174
|
+
assert loaded_frame_id == orig_frame_id
|
|
175
|
+
|
|
176
|
+
def test_round_trip_preserves_frame_metadata(self, tmp_data_dir: str):
|
|
177
|
+
"""current_frame_index, global_fps, origin should round-trip."""
|
|
178
|
+
project = _make_multi_frame_project()
|
|
179
|
+
|
|
180
|
+
save_project(project, tmp_data_dir)
|
|
181
|
+
loaded = load_project(f"{tmp_data_dir}/multi-frame-project")
|
|
182
|
+
|
|
183
|
+
loaded_canvas = loaded.canvases["canvas-0"]
|
|
184
|
+
assert loaded_canvas.current_frame_index == 1
|
|
185
|
+
assert loaded_canvas.global_fps == 24.0
|
|
186
|
+
assert loaded_canvas.origin_x == 5
|
|
187
|
+
assert loaded_canvas.origin_y == 10
|
|
188
|
+
|
|
189
|
+
def test_multi_frame_round_trip(self, tmp_data_dir: str):
|
|
190
|
+
"""Two frames with different pixel data should round-trip correctly."""
|
|
191
|
+
project = _make_multi_frame_project()
|
|
192
|
+
orig_canvas = project.canvases["canvas-0"]
|
|
193
|
+
|
|
194
|
+
save_project(project, tmp_data_dir)
|
|
195
|
+
loaded = load_project(f"{tmp_data_dir}/multi-frame-project")
|
|
196
|
+
loaded_canvas = loaded.canvases["canvas-0"]
|
|
197
|
+
|
|
198
|
+
# Same number of frames
|
|
199
|
+
assert len(loaded_canvas.frames) == 2
|
|
200
|
+
|
|
201
|
+
# Frame IDs preserved
|
|
202
|
+
assert loaded_canvas.frames[0].id == orig_canvas.frames[0].id
|
|
203
|
+
assert loaded_canvas.frames[1].id == orig_canvas.frames[1].id
|
|
204
|
+
|
|
205
|
+
# duration_ms preserved
|
|
206
|
+
assert loaded_canvas.frames[0].duration_ms is None
|
|
207
|
+
assert loaded_canvas.frames[1].duration_ms == 200
|
|
208
|
+
|
|
209
|
+
# Pixel data preserved for all layers in both frames
|
|
210
|
+
for i in range(2):
|
|
211
|
+
orig_pd = orig_canvas.frames[i].pixel_data
|
|
212
|
+
loaded_pd = loaded_canvas.frames[i].pixel_data
|
|
213
|
+
assert set(loaded_pd.keys()) == set(orig_pd.keys())
|
|
214
|
+
for layer_id in orig_pd:
|
|
215
|
+
assert loaded_pd[layer_id] == orig_pd[layer_id]
|
|
216
|
+
|
|
217
|
+
def test_multi_frame_creates_separate_dirs(self, tmp_data_dir: str):
|
|
218
|
+
"""Each frame should get its own UUID-named directory on disk."""
|
|
219
|
+
project = _make_multi_frame_project()
|
|
220
|
+
orig_canvas = project.canvases["canvas-0"]
|
|
221
|
+
|
|
222
|
+
save_project(project, tmp_data_dir)
|
|
223
|
+
|
|
224
|
+
canvas_dir = Path(tmp_data_dir) / "multi-frame-project" / "canvases" / "canvas-0"
|
|
225
|
+
for frame in orig_canvas.frames:
|
|
226
|
+
frame_dir = canvas_dir / "frames" / frame.id
|
|
227
|
+
assert frame_dir.is_dir()
|
|
228
|
+
# Each frame should have PNGs for both layers
|
|
229
|
+
pngs = list(frame_dir.glob("layer-*.png"))
|
|
230
|
+
assert len(pngs) == 2
|
|
231
|
+
|
|
232
|
+
def test_load_missing_frames_json_raises(self, tmp_data_dir: str):
|
|
233
|
+
"""Loading a canvas without frames.json should raise FileNotFoundError."""
|
|
234
|
+
# Create minimal project structure without frames.json
|
|
235
|
+
project_dir = Path(tmp_data_dir) / "old-project"
|
|
236
|
+
canvas_dir = project_dir / "canvases" / "canvas-0"
|
|
237
|
+
canvas_dir.mkdir(parents=True)
|
|
238
|
+
|
|
239
|
+
(project_dir / "project.json").write_text(json.dumps({
|
|
240
|
+
"name": "old-project", "width": 16, "height": 16, "canvases": ["canvas-0"],
|
|
241
|
+
}))
|
|
242
|
+
(canvas_dir / "canvas.json").write_text(json.dumps({
|
|
243
|
+
"name": "canvas-0", "width": 16, "height": 16, "layers": [],
|
|
244
|
+
}))
|
|
245
|
+
|
|
246
|
+
with pytest.raises(FileNotFoundError, match="frames.json"):
|
|
247
|
+
load_project(str(project_dir))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class TestListProjects:
|
|
251
|
+
def test_empty_dir(self, tmp_data_dir: str):
|
|
252
|
+
assert list_projects(tmp_data_dir) == []
|
|
253
|
+
|
|
254
|
+
def test_nonexistent_dir(self):
|
|
255
|
+
assert list_projects("/nonexistent/path") == []
|
|
256
|
+
|
|
257
|
+
def test_lists_saved_projects(self, tmp_data_dir: str, sample_project: ProjectState):
|
|
258
|
+
save_project(sample_project, tmp_data_dir)
|
|
259
|
+
names = list_projects(tmp_data_dir)
|
|
260
|
+
assert names == ["test-project"]
|
|
261
|
+
|
|
262
|
+
def test_ignores_non_project_dirs(self, tmp_data_dir: str):
|
|
263
|
+
"""Directories without project.json should not be listed."""
|
|
264
|
+
(Path(tmp_data_dir) / "not-a-project").mkdir()
|
|
265
|
+
assert list_projects(tmp_data_dir) == []
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class TestExportFramePng:
|
|
269
|
+
def test_export_produces_png_bytes(self, sample_project: ProjectState):
|
|
270
|
+
png_bytes = export_frame_png(sample_project, "canvas-0", 0)
|
|
271
|
+
# PNG files start with the PNG magic bytes
|
|
272
|
+
assert png_bytes[:8] == b"\x89PNG\r\n\x1a\n"
|
|
273
|
+
|
|
274
|
+
def test_export_nonexistent_canvas_raises(self, sample_project: ProjectState):
|
|
275
|
+
with pytest.raises(ValueError, match="not found"):
|
|
276
|
+
export_frame_png(sample_project, "no-such-canvas", 0)
|
|
277
|
+
|
|
278
|
+
def test_export_nonzero_frame(self):
|
|
279
|
+
"""Exporting frame 1 should produce valid PNG with that frame's pixel data."""
|
|
280
|
+
project = _make_multi_frame_project()
|
|
281
|
+
png_bytes = export_frame_png(project, "canvas-0", 1)
|
|
282
|
+
assert png_bytes[:8] == b"\x89PNG\r\n\x1a\n"
|
|
283
|
+
|
|
284
|
+
# Verify the composited image contains frame 1's data (blue + white)
|
|
285
|
+
from io import BytesIO
|
|
286
|
+
from PIL import Image
|
|
287
|
+
|
|
288
|
+
img = Image.open(BytesIO(png_bytes)).convert("RGBA")
|
|
289
|
+
assert img.size == (4, 4)
|
|
290
|
+
|
|
291
|
+
# Frame 1: layer-a is blue, layer-b is white (composited on top)
|
|
292
|
+
# Alpha composite of blue over transparent, then white over that = white
|
|
293
|
+
pixel = img.getpixel((0, 0))
|
|
294
|
+
assert pixel == (255, 255, 255, 255)
|
|
295
|
+
|
|
296
|
+
def test_export_each_frame_differs(self):
|
|
297
|
+
"""Each frame should export different pixel content."""
|
|
298
|
+
project = _make_multi_frame_project()
|
|
299
|
+
png0 = export_frame_png(project, "canvas-0", 0)
|
|
300
|
+
png1 = export_frame_png(project, "canvas-0", 1)
|
|
301
|
+
assert png0 != png1
|
|
302
|
+
|
|
303
|
+
def test_export_out_of_range_frame_raises(self, sample_project: ProjectState):
|
|
304
|
+
"""Out-of-range frame index should raise IndexError."""
|
|
305
|
+
with pytest.raises(IndexError, match="out of range"):
|
|
306
|
+
export_frame_png(sample_project, "canvas-0", 5)
|