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,715 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the effects plugins.
|
|
3
|
+
*
|
|
4
|
+
* Covers rotate, flip, invert, grayscale, blur, outline, shadow, glow,
|
|
5
|
+
* scale, sharpen, colorize, brightness/contrast, hue shift, and undo.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
9
|
+
import { PixelBuffer } from '../../src/lib/canvas/pixel-buffer.js';
|
|
10
|
+
import { dispatch, undoLast, _resetForTesting as resetDispatcher, setContext } from '../../src/lib/core/dispatcher.js';
|
|
11
|
+
import type { Command } from '../../src/lib/core/commands.js';
|
|
12
|
+
import type { CommandType, ParamsOf } from '../../src/lib/core/command-params.js';
|
|
13
|
+
import { createPluginAPI } from '../../src/lib/core/plugin-api.js';
|
|
14
|
+
|
|
15
|
+
import { rotateEffectPlugin, computeRotation } from './effects/rotate.js';
|
|
16
|
+
import { flipEffectPlugin, computeFlip } from './effects/flip.js';
|
|
17
|
+
import { scaleEffectPlugin, computeScale } from './effects/scale.js';
|
|
18
|
+
import { colorEffectsPlugin, invertPixels, grayscalePixels } from './effects/color-effects.js';
|
|
19
|
+
import { blurEffectPlugin, computeBlur } from './effects/blur.js';
|
|
20
|
+
import { sharpenEffectPlugin } from './effects/sharpen.js';
|
|
21
|
+
import { outlineEffectPlugin, computeOutline } from './effects/outline.js';
|
|
22
|
+
import { shadowEffectPlugin, computeShadow } from './effects/shadow.js';
|
|
23
|
+
import { glowEffectPlugin, computeGlow } from './effects/glow.js';
|
|
24
|
+
|
|
25
|
+
// --- Helpers ---
|
|
26
|
+
|
|
27
|
+
// Typed overload: validates params when type is a known command literal
|
|
28
|
+
function makeCommand<T extends CommandType>(type: T, params: ParamsOf<T>): Command;
|
|
29
|
+
// String fallback: for dynamic/unknown command types in tests
|
|
30
|
+
function makeCommand(type: string, params?: Record<string, unknown>): Command;
|
|
31
|
+
function makeCommand(type: string, params: Record<string, unknown> = {}): Command {
|
|
32
|
+
return { type, plugin: 'test', version: '1.0.0', params, timestamp: Date.now(), id: crypto.randomUUID() };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Test context helper. Holds a mutable buffer reference so effects that
|
|
37
|
+
* call setActiveBuffer (rotate on non-square, scale) actually swap the
|
|
38
|
+
* buffer the test sees. Returns a handle with a live `current` getter.
|
|
39
|
+
*/
|
|
40
|
+
interface BufferHandle {
|
|
41
|
+
readonly current: PixelBuffer;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function setupBuffer(width = 8, height = 8): BufferHandle {
|
|
45
|
+
let buffer = new PixelBuffer(width, height);
|
|
46
|
+
setContext({
|
|
47
|
+
getActiveBuffer: () => buffer,
|
|
48
|
+
setActiveBuffer: (next: PixelBuffer) => {
|
|
49
|
+
buffer = next;
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
return {
|
|
53
|
+
get current() {
|
|
54
|
+
return buffer;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Multi-layer test context helper. Simulates a project with several frames
|
|
61
|
+
* and layers so we can verify rotate/scale undo restores into the ORIGINAL
|
|
62
|
+
* (frame, layer) pair even when the active pair has changed.
|
|
63
|
+
*
|
|
64
|
+
* Layout is a Map<frameIndex, Map<layerId, PixelBuffer>>. activeFrameIndex
|
|
65
|
+
* and activeLayerId are mutable so tests can "switch layers" between
|
|
66
|
+
* execute and undo.
|
|
67
|
+
*/
|
|
68
|
+
interface MultiLayerHandle {
|
|
69
|
+
readonly frames: Map<number, Map<string, PixelBuffer>>;
|
|
70
|
+
activeFrameIndex: number;
|
|
71
|
+
activeLayerId: string;
|
|
72
|
+
/** Get the buffer at an explicit (frameIndex, layerId) pair. */
|
|
73
|
+
get(frameIndex: number, layerId: string): PixelBuffer | undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function setupMultiLayer(
|
|
77
|
+
initial: Array<{ frameIndex: number; layerId: string; width: number; height: number }>,
|
|
78
|
+
): MultiLayerHandle {
|
|
79
|
+
const frames = new Map<number, Map<string, PixelBuffer>>();
|
|
80
|
+
for (const { frameIndex, layerId, width, height } of initial) {
|
|
81
|
+
if (!frames.has(frameIndex)) frames.set(frameIndex, new Map());
|
|
82
|
+
frames.get(frameIndex)?.set(layerId, new PixelBuffer(width, height));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const handle: MultiLayerHandle = {
|
|
86
|
+
frames,
|
|
87
|
+
activeFrameIndex: initial[0]?.frameIndex ?? 0,
|
|
88
|
+
activeLayerId: initial[0]?.layerId ?? '',
|
|
89
|
+
get(frameIndex: number, layerId: string) {
|
|
90
|
+
return frames.get(frameIndex)?.get(layerId);
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
setContext({
|
|
95
|
+
getActiveBuffer: () => {
|
|
96
|
+
return frames.get(handle.activeFrameIndex)?.get(handle.activeLayerId) ?? null;
|
|
97
|
+
},
|
|
98
|
+
setActiveBuffer: (next: PixelBuffer) => {
|
|
99
|
+
if (!frames.has(handle.activeFrameIndex)) {
|
|
100
|
+
frames.set(handle.activeFrameIndex, new Map());
|
|
101
|
+
}
|
|
102
|
+
frames.get(handle.activeFrameIndex)?.set(handle.activeLayerId, next);
|
|
103
|
+
},
|
|
104
|
+
getActiveFrameIndex: () => handle.activeFrameIndex,
|
|
105
|
+
getActiveLayerId: () => handle.activeLayerId,
|
|
106
|
+
setBufferForLayer: (frameIndex: number, layerId: string, buffer: PixelBuffer) => {
|
|
107
|
+
if (!frames.has(frameIndex)) frames.set(frameIndex, new Map());
|
|
108
|
+
frames.get(frameIndex)?.set(layerId, buffer);
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return handle;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function registerAll(): void {
|
|
116
|
+
const plugins = [
|
|
117
|
+
rotateEffectPlugin, flipEffectPlugin, scaleEffectPlugin,
|
|
118
|
+
colorEffectsPlugin, blurEffectPlugin, sharpenEffectPlugin,
|
|
119
|
+
outlineEffectPlugin, shadowEffectPlugin, glowEffectPlugin,
|
|
120
|
+
];
|
|
121
|
+
for (const p of plugins) {
|
|
122
|
+
const api = createPluginAPI(p.name);
|
|
123
|
+
p.register(api);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- Tests ---
|
|
128
|
+
|
|
129
|
+
describe('Effects Plugins', () => {
|
|
130
|
+
beforeEach(() => {
|
|
131
|
+
resetDispatcher();
|
|
132
|
+
registerAll();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// -- Rotate --
|
|
136
|
+
|
|
137
|
+
it('rotate 90: pixel at (1,0) moves to expected position', () => {
|
|
138
|
+
const buffer = new PixelBuffer(4, 4);
|
|
139
|
+
buffer.setPixel(1, 0, 255, 0, 0, 255);
|
|
140
|
+
|
|
141
|
+
const out = computeRotation(buffer, 90);
|
|
142
|
+
// Square buffer: output dims unchanged.
|
|
143
|
+
expect(out.width).toBe(4);
|
|
144
|
+
expect(out.height).toBe(4);
|
|
145
|
+
// For 90 CW: source (sx, sy) -> dest (h-1-sy, sx).
|
|
146
|
+
// So source (1, 0) -> dest (3, 1).
|
|
147
|
+
expect(out.getPixel(3, 1)).toEqual([255, 0, 0, 255]);
|
|
148
|
+
// Original location should now be empty.
|
|
149
|
+
expect(out.getPixel(1, 0)).toEqual([0, 0, 0, 0]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('rotate 180: pixel position is mirrored', () => {
|
|
153
|
+
const buffer = new PixelBuffer(4, 4);
|
|
154
|
+
buffer.setPixel(0, 0, 255, 0, 0, 255);
|
|
155
|
+
|
|
156
|
+
const out = computeRotation(buffer, 180);
|
|
157
|
+
// Square, 180: output dims unchanged.
|
|
158
|
+
expect(out.width).toBe(4);
|
|
159
|
+
expect(out.height).toBe(4);
|
|
160
|
+
expect(out.getPixel(3, 3)).toEqual([255, 0, 0, 255]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('rotate 90 on non-square (4x2): output dims are 2x4 and pixels are placed correctly', () => {
|
|
164
|
+
// Source 4x2:
|
|
165
|
+
// (0,0)=R (1,0)=G (2,0)=B (3,0)=W
|
|
166
|
+
// (0,1)=_ (1,1)=_ (2,1)=_ (3,1)=Y
|
|
167
|
+
const buffer = new PixelBuffer(4, 2);
|
|
168
|
+
buffer.setPixel(0, 0, 255, 0, 0, 255); // R
|
|
169
|
+
buffer.setPixel(1, 0, 0, 255, 0, 255); // G
|
|
170
|
+
buffer.setPixel(2, 0, 0, 0, 255, 255); // B
|
|
171
|
+
buffer.setPixel(3, 0, 255, 255, 255, 255); // W
|
|
172
|
+
buffer.setPixel(3, 1, 255, 255, 0, 255); // Y
|
|
173
|
+
|
|
174
|
+
const out = computeRotation(buffer, 90);
|
|
175
|
+
// 90 CW on a 4x2 source -> 2x4 output.
|
|
176
|
+
expect(out.width).toBe(2);
|
|
177
|
+
expect(out.height).toBe(4);
|
|
178
|
+
|
|
179
|
+
// Mapping: source (sx, sy) -> dest (h-1-sy, sx) where h = 2.
|
|
180
|
+
// (0,0)=R -> (1, 0)
|
|
181
|
+
// (1,0)=G -> (1, 1)
|
|
182
|
+
// (2,0)=B -> (1, 2)
|
|
183
|
+
// (3,0)=W -> (1, 3)
|
|
184
|
+
// (3,1)=Y -> (0, 3)
|
|
185
|
+
expect(out.getPixel(1, 0)).toEqual([255, 0, 0, 255]);
|
|
186
|
+
expect(out.getPixel(1, 1)).toEqual([0, 255, 0, 255]);
|
|
187
|
+
expect(out.getPixel(1, 2)).toEqual([0, 0, 255, 255]);
|
|
188
|
+
expect(out.getPixel(1, 3)).toEqual([255, 255, 255, 255]);
|
|
189
|
+
expect(out.getPixel(0, 3)).toEqual([255, 255, 0, 255]);
|
|
190
|
+
// Other pixels on the top row of source were transparent, so their
|
|
191
|
+
// rotated positions should remain transparent.
|
|
192
|
+
expect(out.getPixel(0, 0)).toEqual([0, 0, 0, 0]);
|
|
193
|
+
expect(out.getPixel(0, 1)).toEqual([0, 0, 0, 0]);
|
|
194
|
+
expect(out.getPixel(0, 2)).toEqual([0, 0, 0, 0]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('rotate 270 on non-square (4x2): output dims are 2x4 and pixels are placed correctly', () => {
|
|
198
|
+
const buffer = new PixelBuffer(4, 2);
|
|
199
|
+
buffer.setPixel(0, 0, 255, 0, 0, 255); // R
|
|
200
|
+
buffer.setPixel(1, 0, 0, 255, 0, 255); // G
|
|
201
|
+
buffer.setPixel(2, 0, 0, 0, 255, 255); // B
|
|
202
|
+
buffer.setPixel(3, 0, 255, 255, 255, 255); // W
|
|
203
|
+
buffer.setPixel(3, 1, 255, 255, 0, 255); // Y
|
|
204
|
+
|
|
205
|
+
const out = computeRotation(buffer, 270);
|
|
206
|
+
// 270 CW on a 4x2 source -> 2x4 output.
|
|
207
|
+
expect(out.width).toBe(2);
|
|
208
|
+
expect(out.height).toBe(4);
|
|
209
|
+
|
|
210
|
+
// Mapping: source (sx, sy) -> dest (sy, w-1-sx) where w = 4.
|
|
211
|
+
// (0,0)=R -> (0, 3)
|
|
212
|
+
// (1,0)=G -> (0, 2)
|
|
213
|
+
// (2,0)=B -> (0, 1)
|
|
214
|
+
// (3,0)=W -> (0, 0)
|
|
215
|
+
// (3,1)=Y -> (1, 0)
|
|
216
|
+
expect(out.getPixel(0, 3)).toEqual([255, 0, 0, 255]);
|
|
217
|
+
expect(out.getPixel(0, 2)).toEqual([0, 255, 0, 255]);
|
|
218
|
+
expect(out.getPixel(0, 1)).toEqual([0, 0, 255, 255]);
|
|
219
|
+
expect(out.getPixel(0, 0)).toEqual([255, 255, 255, 255]);
|
|
220
|
+
expect(out.getPixel(1, 0)).toEqual([255, 255, 0, 255]);
|
|
221
|
+
// The rest came from transparent source pixels.
|
|
222
|
+
expect(out.getPixel(1, 1)).toEqual([0, 0, 0, 0]);
|
|
223
|
+
expect(out.getPixel(1, 2)).toEqual([0, 0, 0, 0]);
|
|
224
|
+
expect(out.getPixel(1, 3)).toEqual([0, 0, 0, 0]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('rotate 180 on non-square (4x2): output dims remain 4x2', () => {
|
|
228
|
+
const buffer = new PixelBuffer(4, 2);
|
|
229
|
+
buffer.setPixel(0, 0, 255, 0, 0, 255);
|
|
230
|
+
|
|
231
|
+
const out = computeRotation(buffer, 180);
|
|
232
|
+
expect(out.width).toBe(4);
|
|
233
|
+
expect(out.height).toBe(2);
|
|
234
|
+
// (0,0) -> (w-1, h-1) = (3, 1)
|
|
235
|
+
expect(out.getPixel(3, 1)).toEqual([255, 0, 0, 255]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// -- Flip --
|
|
239
|
+
|
|
240
|
+
it('flip horizontal: pixel at (0,0) moves to (w-1,0)', () => {
|
|
241
|
+
const buffer = new PixelBuffer(4, 4);
|
|
242
|
+
buffer.setPixel(0, 0, 255, 0, 0, 255);
|
|
243
|
+
|
|
244
|
+
const pixels = computeFlip(buffer, 'h');
|
|
245
|
+
const moved = pixels.find((p) => p.r === 255 && p.a === 255);
|
|
246
|
+
expect(moved).toBeDefined();
|
|
247
|
+
expect(moved?.x).toBe(3);
|
|
248
|
+
expect(moved?.y).toBe(0);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('flip vertical: pixel at (0,0) moves to (0,h-1)', () => {
|
|
252
|
+
const buffer = new PixelBuffer(4, 4);
|
|
253
|
+
buffer.setPixel(0, 0, 255, 0, 0, 255);
|
|
254
|
+
|
|
255
|
+
const pixels = computeFlip(buffer, 'v');
|
|
256
|
+
const moved = pixels.find((p) => p.r === 255 && p.a === 255);
|
|
257
|
+
expect(moved).toBeDefined();
|
|
258
|
+
expect(moved?.x).toBe(0);
|
|
259
|
+
expect(moved?.y).toBe(3);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// -- Invert --
|
|
263
|
+
|
|
264
|
+
it('invert: RGB values are inverted, alpha preserved', () => {
|
|
265
|
+
const buffer = new PixelBuffer(2, 2);
|
|
266
|
+
buffer.setPixel(0, 0, 100, 150, 200, 255);
|
|
267
|
+
|
|
268
|
+
const pixels = invertPixels(buffer);
|
|
269
|
+
const p = pixels.find((px) => px.x === 0 && px.y === 0);
|
|
270
|
+
if (!p) throw new Error('expected inverted pixel at (0,0)');
|
|
271
|
+
expect(p.r).toBe(155);
|
|
272
|
+
expect(p.g).toBe(105);
|
|
273
|
+
expect(p.b).toBe(55);
|
|
274
|
+
expect(p.a).toBe(255);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// -- Grayscale --
|
|
278
|
+
|
|
279
|
+
it('grayscale: correct luminance', () => {
|
|
280
|
+
const buffer = new PixelBuffer(1, 1);
|
|
281
|
+
buffer.setPixel(0, 0, 255, 0, 0, 255); // Pure red
|
|
282
|
+
|
|
283
|
+
const pixels = grayscalePixels(buffer);
|
|
284
|
+
const p = pixels[0];
|
|
285
|
+
if (!p) throw new Error('expected first pixel');
|
|
286
|
+
// BT.601: 0.299 * 255 = 76.245 -> 76
|
|
287
|
+
expect(p.r).toBe(76);
|
|
288
|
+
expect(p.g).toBe(76);
|
|
289
|
+
expect(p.b).toBe(76);
|
|
290
|
+
expect(p.a).toBe(255);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// -- Blur --
|
|
294
|
+
|
|
295
|
+
it('blur: center pixel is averaged with neighbors', () => {
|
|
296
|
+
const buffer = new PixelBuffer(3, 3);
|
|
297
|
+
// Set center pixel to white, rest are transparent (0,0,0,0)
|
|
298
|
+
buffer.setPixel(1, 1, 255, 255, 255, 255);
|
|
299
|
+
|
|
300
|
+
const pixels = computeBlur(buffer);
|
|
301
|
+
const center = pixels.find((p) => p.x === 1 && p.y === 1);
|
|
302
|
+
if (!center) throw new Error('expected center pixel');
|
|
303
|
+
// Average of 9 pixels: 1 white + 8 transparent
|
|
304
|
+
// R: 255/9 ~ 28, same for G, B, A
|
|
305
|
+
expect(center.r).toBe(Math.round(255 / 9));
|
|
306
|
+
expect(center.g).toBe(Math.round(255 / 9));
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// -- Outline --
|
|
310
|
+
|
|
311
|
+
it('outline: non-transparent pixels get border', () => {
|
|
312
|
+
const buffer = new PixelBuffer(8, 8);
|
|
313
|
+
buffer.setPixel(4, 4, 255, 0, 0, 255);
|
|
314
|
+
|
|
315
|
+
const outlinePixels = computeOutline(buffer, 1, '#00FF00');
|
|
316
|
+
// Should have pixels around (4,4) -- the 8 neighbors
|
|
317
|
+
expect(outlinePixels.length).toBeGreaterThan(0);
|
|
318
|
+
expect(outlinePixels.length).toBeLessThanOrEqual(8);
|
|
319
|
+
|
|
320
|
+
// All outline pixels should be green
|
|
321
|
+
for (const p of outlinePixels) {
|
|
322
|
+
expect(p.r).toBe(0);
|
|
323
|
+
expect(p.g).toBe(255);
|
|
324
|
+
expect(p.b).toBe(0);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// The original pixel should NOT be in the outline (it's not transparent)
|
|
328
|
+
const original = outlinePixels.find((p) => p.x === 4 && p.y === 4);
|
|
329
|
+
expect(original).toBeUndefined();
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// -- Shadow --
|
|
333
|
+
|
|
334
|
+
it('shadow: creates offset copy', () => {
|
|
335
|
+
const buffer = new PixelBuffer(8, 8);
|
|
336
|
+
buffer.setPixel(2, 2, 255, 0, 0, 255);
|
|
337
|
+
|
|
338
|
+
const shadowPixels = computeShadow(buffer, 2, 2, '#000000', 0.5);
|
|
339
|
+
// Shadow should be at (4, 4) since offset is (2, 2)
|
|
340
|
+
const sp = shadowPixels.find((p) => p.x === 4 && p.y === 4);
|
|
341
|
+
expect(sp).toBeDefined();
|
|
342
|
+
expect(sp?.r).toBe(0);
|
|
343
|
+
expect(sp?.g).toBe(0);
|
|
344
|
+
expect(sp?.b).toBe(0);
|
|
345
|
+
expect(sp?.a).toBe(128); // 0.5 * 255 = 127.5 -> 128
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// -- Glow --
|
|
349
|
+
|
|
350
|
+
it('glow: transparent pixels near opaque ones get glow color', () => {
|
|
351
|
+
const buffer = new PixelBuffer(8, 8);
|
|
352
|
+
buffer.setPixel(4, 4, 255, 0, 0, 255);
|
|
353
|
+
|
|
354
|
+
const glowPixels = computeGlow(buffer, 2, '#FFFF00', 0.8);
|
|
355
|
+
expect(glowPixels.length).toBeGreaterThan(0);
|
|
356
|
+
|
|
357
|
+
// All glow pixels should have the glow color
|
|
358
|
+
for (const p of glowPixels) {
|
|
359
|
+
expect(p.r).toBe(255);
|
|
360
|
+
expect(p.g).toBe(255);
|
|
361
|
+
expect(p.b).toBe(0);
|
|
362
|
+
expect(p.a).toBeGreaterThan(0);
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// -- Scale --
|
|
367
|
+
|
|
368
|
+
it('scale: nearest-neighbor produces expected result', () => {
|
|
369
|
+
const buffer = new PixelBuffer(4, 4);
|
|
370
|
+
// Create a 2x2 checkerboard in top-left
|
|
371
|
+
buffer.setPixel(0, 0, 255, 0, 0, 255);
|
|
372
|
+
buffer.setPixel(1, 1, 255, 0, 0, 255);
|
|
373
|
+
|
|
374
|
+
const scaled = computeScale(buffer, 2, 2);
|
|
375
|
+
// computeScale returns a PixelBuffer sized to (newWidth, newHeight).
|
|
376
|
+
expect(scaled.width).toBe(2);
|
|
377
|
+
expect(scaled.height).toBe(2);
|
|
378
|
+
// Scaling 4x4 down to 2x2 should sample every other pixel.
|
|
379
|
+
// Source (0, 0) is red -> dest (0, 0) is red.
|
|
380
|
+
expect(scaled.getPixel(0, 0)).toEqual([255, 0, 0, 255]);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// -- Undo tests --
|
|
384
|
+
|
|
385
|
+
it('invert effect undoes correctly', () => {
|
|
386
|
+
const handle = setupBuffer(2, 2);
|
|
387
|
+
handle.current.setPixel(0, 0, 100, 150, 200, 255);
|
|
388
|
+
|
|
389
|
+
dispatch(makeCommand('invert', { layerId: '' }));
|
|
390
|
+
expect(handle.current.getPixel(0, 0)).toEqual([155, 105, 55, 255]);
|
|
391
|
+
|
|
392
|
+
undoLast();
|
|
393
|
+
expect(handle.current.getPixel(0, 0)).toEqual([100, 150, 200, 255]);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('rotate effect undoes correctly', () => {
|
|
397
|
+
const handle = setupBuffer(4, 4);
|
|
398
|
+
handle.current.setPixel(0, 0, 255, 0, 0, 255);
|
|
399
|
+
|
|
400
|
+
dispatch(makeCommand('rotate', { angle: 90, layerId: '' }));
|
|
401
|
+
// Square 4x4: dims unchanged, but (0,0) should no longer be red.
|
|
402
|
+
expect(handle.current.width).toBe(4);
|
|
403
|
+
expect(handle.current.height).toBe(4);
|
|
404
|
+
expect(handle.current.getPixel(0, 0)[0]).not.toBe(255);
|
|
405
|
+
|
|
406
|
+
undoLast();
|
|
407
|
+
expect(handle.current.width).toBe(4);
|
|
408
|
+
expect(handle.current.height).toBe(4);
|
|
409
|
+
expect(handle.current.getPixel(0, 0)).toEqual([255, 0, 0, 255]);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('rotate 90 on non-square canvas swaps dims through execute()', () => {
|
|
413
|
+
// 4x2 source with a single red pixel at (1, 0).
|
|
414
|
+
const handle = setupBuffer(4, 2);
|
|
415
|
+
handle.current.setPixel(1, 0, 255, 0, 0, 255);
|
|
416
|
+
|
|
417
|
+
dispatch(makeCommand('rotate', { angle: 90, layerId: '' }));
|
|
418
|
+
// After 90 CW on a 4x2: active buffer should be 2x4.
|
|
419
|
+
expect(handle.current.width).toBe(2);
|
|
420
|
+
expect(handle.current.height).toBe(4);
|
|
421
|
+
// Source (1, 0) -> dest (h-1-0, 1) = (1, 1) in the 2x4 result.
|
|
422
|
+
expect(handle.current.getPixel(1, 1)).toEqual([255, 0, 0, 255]);
|
|
423
|
+
|
|
424
|
+
undoLast();
|
|
425
|
+
// Undo restores original 4x2 dims and original pixel position.
|
|
426
|
+
expect(handle.current.width).toBe(4);
|
|
427
|
+
expect(handle.current.height).toBe(2);
|
|
428
|
+
expect(handle.current.getPixel(1, 0)).toEqual([255, 0, 0, 255]);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('rotate 270 on non-square canvas swaps dims through execute()', () => {
|
|
432
|
+
const handle = setupBuffer(4, 2);
|
|
433
|
+
handle.current.setPixel(0, 0, 255, 0, 0, 255);
|
|
434
|
+
|
|
435
|
+
dispatch(makeCommand('rotate', { angle: 270, layerId: '' }));
|
|
436
|
+
expect(handle.current.width).toBe(2);
|
|
437
|
+
expect(handle.current.height).toBe(4);
|
|
438
|
+
// Source (0, 0) -> dest (0, w-1-0) = (0, 3) in the 2x4 result.
|
|
439
|
+
expect(handle.current.getPixel(0, 3)).toEqual([255, 0, 0, 255]);
|
|
440
|
+
|
|
441
|
+
undoLast();
|
|
442
|
+
expect(handle.current.width).toBe(4);
|
|
443
|
+
expect(handle.current.height).toBe(2);
|
|
444
|
+
expect(handle.current.getPixel(0, 0)).toEqual([255, 0, 0, 255]);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('scale effect resizes active buffer through execute()', () => {
|
|
448
|
+
// 4x4 source; upscale to 8x8.
|
|
449
|
+
const handle = setupBuffer(4, 4);
|
|
450
|
+
handle.current.setPixel(0, 0, 255, 0, 0, 255);
|
|
451
|
+
|
|
452
|
+
dispatch(makeCommand('scale', { newWidth: 8, newHeight: 8, layerId: '' }));
|
|
453
|
+
// Active buffer should now report 8x8 dims.
|
|
454
|
+
expect(handle.current.width).toBe(8);
|
|
455
|
+
expect(handle.current.height).toBe(8);
|
|
456
|
+
// Nearest-neighbor: dest (0, 0) maps to source (0, 0) = red.
|
|
457
|
+
// dest (1, 1) also maps to source (0, 0) via floor(1*4/8)=0.
|
|
458
|
+
expect(handle.current.getPixel(0, 0)).toEqual([255, 0, 0, 255]);
|
|
459
|
+
expect(handle.current.getPixel(1, 1)).toEqual([255, 0, 0, 255]);
|
|
460
|
+
|
|
461
|
+
undoLast();
|
|
462
|
+
// Undo restores original 4x4 dims and pixel data.
|
|
463
|
+
expect(handle.current.width).toBe(4);
|
|
464
|
+
expect(handle.current.height).toBe(4);
|
|
465
|
+
expect(handle.current.getPixel(0, 0)).toEqual([255, 0, 0, 255]);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('scale downscale through execute() produces correct dims', () => {
|
|
469
|
+
const handle = setupBuffer(8, 8);
|
|
470
|
+
handle.current.setPixel(0, 0, 255, 0, 0, 255);
|
|
471
|
+
|
|
472
|
+
dispatch(makeCommand('scale', { newWidth: 4, newHeight: 4, layerId: '' }));
|
|
473
|
+
expect(handle.current.width).toBe(4);
|
|
474
|
+
expect(handle.current.height).toBe(4);
|
|
475
|
+
expect(handle.current.getPixel(0, 0)).toEqual([255, 0, 0, 255]);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('flip effect undoes correctly', () => {
|
|
479
|
+
const handle = setupBuffer(4, 4);
|
|
480
|
+
handle.current.setPixel(0, 0, 255, 0, 0, 255);
|
|
481
|
+
|
|
482
|
+
dispatch(makeCommand('flip', { direction: 'h', layerId: '' }));
|
|
483
|
+
expect(handle.current.getPixel(3, 0)).toEqual([255, 0, 0, 255]);
|
|
484
|
+
expect(handle.current.getPixel(0, 0)[3]).toBe(0);
|
|
485
|
+
|
|
486
|
+
undoLast();
|
|
487
|
+
expect(handle.current.getPixel(0, 0)).toEqual([255, 0, 0, 255]);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
// -- Snapshot-targets-original-layer regression tests --
|
|
491
|
+
//
|
|
492
|
+
// These cover the bug where rotate/scale undo would write into whatever
|
|
493
|
+
// layer was active at undo time instead of the layer the effect actually
|
|
494
|
+
// operated on. Run rotate/scale on layer A, switch active layer to B,
|
|
495
|
+
// undo -- layer A must be restored to its original pixels AND layer B
|
|
496
|
+
// must be left untouched.
|
|
497
|
+
|
|
498
|
+
it('rotate undo targets the original layer, not the current active layer', () => {
|
|
499
|
+
// Two layers in a single frame. Layer A has a red pixel at (0,0);
|
|
500
|
+
// layer B has a blue pixel at (1,1). Both 4x4.
|
|
501
|
+
const handle = setupMultiLayer([
|
|
502
|
+
{ frameIndex: 0, layerId: 'A', width: 4, height: 4 },
|
|
503
|
+
{ frameIndex: 0, layerId: 'B', width: 4, height: 4 },
|
|
504
|
+
]);
|
|
505
|
+
handle.get(0, 'A')?.setPixel(0, 0, 255, 0, 0, 255);
|
|
506
|
+
handle.get(0, 'B')?.setPixel(1, 1, 0, 0, 255, 255);
|
|
507
|
+
|
|
508
|
+
// Execute rotate on layer A.
|
|
509
|
+
handle.activeLayerId = 'A';
|
|
510
|
+
dispatch(makeCommand('rotate', { angle: 90, layerId: '' }));
|
|
511
|
+
// Layer A's red pixel has moved off (0,0).
|
|
512
|
+
expect(handle.get(0, 'A')?.getPixel(0, 0)[0]).not.toBe(255);
|
|
513
|
+
|
|
514
|
+
// Switch active layer to B BEFORE undo.
|
|
515
|
+
handle.activeLayerId = 'B';
|
|
516
|
+
|
|
517
|
+
// Undo. The correct behaviour is that layer A is restored and layer B
|
|
518
|
+
// is untouched -- the old buggy code wrote snap.data back into layer B.
|
|
519
|
+
undoLast();
|
|
520
|
+
|
|
521
|
+
// Layer A restored.
|
|
522
|
+
expect(handle.get(0, 'A')?.getPixel(0, 0)).toEqual([255, 0, 0, 255]);
|
|
523
|
+
// Layer B untouched: the original blue pixel is still there and there's
|
|
524
|
+
// no stray red pixel from A's snapshot leaking in.
|
|
525
|
+
expect(handle.get(0, 'B')?.getPixel(1, 1)).toEqual([0, 0, 255, 255]);
|
|
526
|
+
expect(handle.get(0, 'B')?.getPixel(0, 0)).toEqual([0, 0, 0, 0]);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// -- Whole-canvas effects --
|
|
530
|
+
//
|
|
531
|
+
// rotate_canvas and scale_canvas iterate all (frame, pixel-layer) pairs.
|
|
532
|
+
// Tests use a richer multi-frame helper so we can verify every frame is
|
|
533
|
+
// actually touched and undo restores every frame.
|
|
534
|
+
|
|
535
|
+
interface WholeCanvasPair {
|
|
536
|
+
frameIndex: number;
|
|
537
|
+
layerId: string;
|
|
538
|
+
buffer: PixelBuffer;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
interface WholeCanvasHandle {
|
|
542
|
+
readonly pairs: WholeCanvasPair[];
|
|
543
|
+
canvasWidth: number;
|
|
544
|
+
canvasHeight: number;
|
|
545
|
+
get(frameIndex: number, layerId: string): PixelBuffer | undefined;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Set up a fake multi-frame multi-layer project for whole-canvas tests.
|
|
550
|
+
* Every pair starts at the given initialWidth x initialHeight so the
|
|
551
|
+
* project looks like a real canvas (all buffers share dimensions).
|
|
552
|
+
*/
|
|
553
|
+
function setupWholeCanvas(
|
|
554
|
+
descriptor: Array<{ frameIndex: number; layerId: string }>,
|
|
555
|
+
initialWidth: number,
|
|
556
|
+
initialHeight: number,
|
|
557
|
+
): WholeCanvasHandle {
|
|
558
|
+
const pairs: WholeCanvasPair[] = descriptor.map((d) => ({
|
|
559
|
+
frameIndex: d.frameIndex,
|
|
560
|
+
layerId: d.layerId,
|
|
561
|
+
buffer: new PixelBuffer(initialWidth, initialHeight),
|
|
562
|
+
}));
|
|
563
|
+
|
|
564
|
+
const handle: WholeCanvasHandle = {
|
|
565
|
+
pairs,
|
|
566
|
+
canvasWidth: initialWidth,
|
|
567
|
+
canvasHeight: initialHeight,
|
|
568
|
+
get(frameIndex: number, layerId: string) {
|
|
569
|
+
return pairs.find((p) => p.frameIndex === frameIndex && p.layerId === layerId)?.buffer;
|
|
570
|
+
},
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
setContext({
|
|
574
|
+
getActiveBuffer: () => pairs[0]?.buffer ?? null,
|
|
575
|
+
setActiveBuffer: (next: PixelBuffer) => {
|
|
576
|
+
if (pairs[0]) pairs[0].buffer = next;
|
|
577
|
+
},
|
|
578
|
+
getActiveFrameIndex: () => pairs[0]?.frameIndex ?? 0,
|
|
579
|
+
getActiveLayerId: () => pairs[0]?.layerId ?? null,
|
|
580
|
+
setBufferForLayer: (frameIndex: number, layerId: string, buffer: PixelBuffer) => {
|
|
581
|
+
const pair = pairs.find((p) => p.frameIndex === frameIndex && p.layerId === layerId);
|
|
582
|
+
if (pair) pair.buffer = buffer;
|
|
583
|
+
},
|
|
584
|
+
getAllFrameLayerBuffers: () => pairs.map((p) => ({
|
|
585
|
+
frameIndex: p.frameIndex,
|
|
586
|
+
layerId: p.layerId,
|
|
587
|
+
buffer: p.buffer,
|
|
588
|
+
})),
|
|
589
|
+
setCanvasSize: (width: number, height: number) => {
|
|
590
|
+
handle.canvasWidth = width;
|
|
591
|
+
handle.canvasHeight = height;
|
|
592
|
+
},
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
return handle;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
it('rotate_canvas rotates every (frame, layer) pair and updates canvas size once', () => {
|
|
599
|
+
// Two frames, two layers each. 4x2 canvas -- rotate 90 makes it 2x4.
|
|
600
|
+
const handle = setupWholeCanvas(
|
|
601
|
+
[
|
|
602
|
+
{ frameIndex: 0, layerId: 'A' },
|
|
603
|
+
{ frameIndex: 0, layerId: 'B' },
|
|
604
|
+
{ frameIndex: 1, layerId: 'A' },
|
|
605
|
+
{ frameIndex: 1, layerId: 'B' },
|
|
606
|
+
],
|
|
607
|
+
4,
|
|
608
|
+
2,
|
|
609
|
+
);
|
|
610
|
+
// Put a distinct pixel in each pair so we can track them after rotation.
|
|
611
|
+
handle.get(0, 'A')?.setPixel(0, 0, 255, 0, 0, 255); // frame0/A red
|
|
612
|
+
handle.get(0, 'B')?.setPixel(1, 0, 0, 255, 0, 255); // frame0/B green
|
|
613
|
+
handle.get(1, 'A')?.setPixel(2, 0, 0, 0, 255, 255); // frame1/A blue
|
|
614
|
+
handle.get(1, 'B')?.setPixel(3, 1, 255, 255, 0, 255); // frame1/B yellow
|
|
615
|
+
|
|
616
|
+
dispatch(makeCommand('rotate_canvas', { angle: 90, layerId: '' }));
|
|
617
|
+
|
|
618
|
+
// Canvas is now 2x4 (all pairs resized consistently).
|
|
619
|
+
expect(handle.canvasWidth).toBe(2);
|
|
620
|
+
expect(handle.canvasHeight).toBe(4);
|
|
621
|
+
for (const pair of handle.pairs) {
|
|
622
|
+
expect(pair.buffer.width).toBe(2);
|
|
623
|
+
expect(pair.buffer.height).toBe(4);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Every pair was actually rotated (not just frame 0, layer A). Mapping:
|
|
627
|
+
// For 90 CW on 4x2 source -> 2x4 dest: source (sx, sy) -> dest (1-sy, sx).
|
|
628
|
+
// (0, 0) -> (1, 0), (1, 0) -> (1, 1), (2, 0) -> (1, 2), (3, 1) -> (0, 3).
|
|
629
|
+
expect(handle.get(0, 'A')?.getPixel(1, 0)).toEqual([255, 0, 0, 255]);
|
|
630
|
+
expect(handle.get(0, 'B')?.getPixel(1, 1)).toEqual([0, 255, 0, 255]);
|
|
631
|
+
expect(handle.get(1, 'A')?.getPixel(1, 2)).toEqual([0, 0, 255, 255]);
|
|
632
|
+
expect(handle.get(1, 'B')?.getPixel(0, 3)).toEqual([255, 255, 0, 255]);
|
|
633
|
+
|
|
634
|
+
// Undo restores every pair AND the canvas size.
|
|
635
|
+
undoLast();
|
|
636
|
+
expect(handle.canvasWidth).toBe(4);
|
|
637
|
+
expect(handle.canvasHeight).toBe(2);
|
|
638
|
+
for (const pair of handle.pairs) {
|
|
639
|
+
expect(pair.buffer.width).toBe(4);
|
|
640
|
+
expect(pair.buffer.height).toBe(2);
|
|
641
|
+
}
|
|
642
|
+
expect(handle.get(0, 'A')?.getPixel(0, 0)).toEqual([255, 0, 0, 255]);
|
|
643
|
+
expect(handle.get(0, 'B')?.getPixel(1, 0)).toEqual([0, 255, 0, 255]);
|
|
644
|
+
expect(handle.get(1, 'A')?.getPixel(2, 0)).toEqual([0, 0, 255, 255]);
|
|
645
|
+
expect(handle.get(1, 'B')?.getPixel(3, 1)).toEqual([255, 255, 0, 255]);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it('scale_canvas scales every (frame, layer) pair and updates canvas size once', () => {
|
|
649
|
+
const handle = setupWholeCanvas(
|
|
650
|
+
[
|
|
651
|
+
{ frameIndex: 0, layerId: 'A' },
|
|
652
|
+
{ frameIndex: 1, layerId: 'A' },
|
|
653
|
+
],
|
|
654
|
+
4,
|
|
655
|
+
4,
|
|
656
|
+
);
|
|
657
|
+
handle.get(0, 'A')?.setPixel(0, 0, 255, 0, 0, 255);
|
|
658
|
+
handle.get(1, 'A')?.setPixel(2, 2, 0, 0, 255, 255);
|
|
659
|
+
|
|
660
|
+
dispatch(makeCommand('scale_canvas', { newWidth: 8, newHeight: 8, layerId: '' }));
|
|
661
|
+
|
|
662
|
+
expect(handle.canvasWidth).toBe(8);
|
|
663
|
+
expect(handle.canvasHeight).toBe(8);
|
|
664
|
+
for (const pair of handle.pairs) {
|
|
665
|
+
expect(pair.buffer.width).toBe(8);
|
|
666
|
+
expect(pair.buffer.height).toBe(8);
|
|
667
|
+
}
|
|
668
|
+
// Nearest-neighbor: source (0, 0) stays at dest (0, 0); source (2, 2)
|
|
669
|
+
// upscaled 4->8 lands at dest (4, 4).
|
|
670
|
+
expect(handle.get(0, 'A')?.getPixel(0, 0)).toEqual([255, 0, 0, 255]);
|
|
671
|
+
expect(handle.get(1, 'A')?.getPixel(4, 4)).toEqual([0, 0, 255, 255]);
|
|
672
|
+
|
|
673
|
+
undoLast();
|
|
674
|
+
expect(handle.canvasWidth).toBe(4);
|
|
675
|
+
expect(handle.canvasHeight).toBe(4);
|
|
676
|
+
for (const pair of handle.pairs) {
|
|
677
|
+
expect(pair.buffer.width).toBe(4);
|
|
678
|
+
expect(pair.buffer.height).toBe(4);
|
|
679
|
+
}
|
|
680
|
+
expect(handle.get(0, 'A')?.getPixel(0, 0)).toEqual([255, 0, 0, 255]);
|
|
681
|
+
expect(handle.get(1, 'A')?.getPixel(2, 2)).toEqual([0, 0, 255, 255]);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it('scale undo targets the original layer, not the current active layer', () => {
|
|
685
|
+
const handle = setupMultiLayer([
|
|
686
|
+
{ frameIndex: 0, layerId: 'A', width: 4, height: 4 },
|
|
687
|
+
{ frameIndex: 0, layerId: 'B', width: 4, height: 4 },
|
|
688
|
+
]);
|
|
689
|
+
handle.get(0, 'A')?.setPixel(0, 0, 255, 0, 0, 255);
|
|
690
|
+
handle.get(0, 'B')?.setPixel(2, 2, 0, 0, 255, 255);
|
|
691
|
+
|
|
692
|
+
handle.activeLayerId = 'A';
|
|
693
|
+
dispatch(makeCommand('scale', { newWidth: 8, newHeight: 8, layerId: '' }));
|
|
694
|
+
// Layer A is now 8x8.
|
|
695
|
+
expect(handle.get(0, 'A')?.width).toBe(8);
|
|
696
|
+
expect(handle.get(0, 'A')?.height).toBe(8);
|
|
697
|
+
// Layer B is still the original 4x4 with its blue pixel.
|
|
698
|
+
expect(handle.get(0, 'B')?.width).toBe(4);
|
|
699
|
+
expect(handle.get(0, 'B')?.getPixel(2, 2)).toEqual([0, 0, 255, 255]);
|
|
700
|
+
|
|
701
|
+
// Switch active layer to B BEFORE undo.
|
|
702
|
+
handle.activeLayerId = 'B';
|
|
703
|
+
|
|
704
|
+
undoLast();
|
|
705
|
+
|
|
706
|
+
// Layer A restored to 4x4 with its original red pixel.
|
|
707
|
+
expect(handle.get(0, 'A')?.width).toBe(4);
|
|
708
|
+
expect(handle.get(0, 'A')?.height).toBe(4);
|
|
709
|
+
expect(handle.get(0, 'A')?.getPixel(0, 0)).toEqual([255, 0, 0, 255]);
|
|
710
|
+
// Layer B untouched: still 4x4 and still has its blue pixel at (2, 2).
|
|
711
|
+
expect(handle.get(0, 'B')?.width).toBe(4);
|
|
712
|
+
expect(handle.get(0, 'B')?.height).toBe(4);
|
|
713
|
+
expect(handle.get(0, 'B')?.getPixel(2, 2)).toEqual([0, 0, 255, 255]);
|
|
714
|
+
});
|
|
715
|
+
});
|