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.
Files changed (392) hide show
  1. package/.env.development +1 -0
  2. package/.github/workflows/ci.yml +22 -0
  3. package/.github/workflows/publish.yml +18 -0
  4. package/.prettierignore +5 -0
  5. package/.prettierrc +16 -0
  6. package/.python-version +1 -0
  7. package/.rlsbl/bases/.github/workflows/ci.yml +21 -0
  8. package/.rlsbl/bases/.github/workflows/publish.yml +18 -0
  9. package/.rlsbl/bases/.rlsbl/changes/unreleased.jsonl +0 -0
  10. package/.rlsbl/bases/.rlsbl/hooks/post-release.sh +8 -0
  11. package/.rlsbl/bases/.rlsbl/hooks/pre-checks.sh +5 -0
  12. package/.rlsbl/bases/.rlsbl/hooks/pre-release.sh +8 -0
  13. package/.rlsbl/bases/.rlsbl/lint/go.toml +17 -0
  14. package/.rlsbl/bases/.rlsbl/lint/npm.toml +19 -0
  15. package/.rlsbl/bases/.rlsbl/lint/python.toml +25 -0
  16. package/.rlsbl/bases/CHANGELOG.md +5 -0
  17. package/.rlsbl/bases/LICENSE +21 -0
  18. package/.rlsbl/changes/.validated +1 -0
  19. package/.rlsbl/changes/0.1.0.jsonl +85 -0
  20. package/.rlsbl/changes/0.1.0.md +13 -0
  21. package/.rlsbl/changes/unreleased.jsonl +0 -0
  22. package/.rlsbl/config.json +6 -0
  23. package/.rlsbl/hashes.json +14 -0
  24. package/.rlsbl/hooks/post-release.sh +8 -0
  25. package/.rlsbl/hooks/pre-checks.sh +5 -0
  26. package/.rlsbl/hooks/pre-release.sh +8 -0
  27. package/.rlsbl/lint/go.toml +17 -0
  28. package/.rlsbl/lint/npm.toml +19 -0
  29. package/.rlsbl/lint/python.toml +25 -0
  30. package/.rlsbl/releases/unreleased.toml +0 -0
  31. package/.rlsbl/releases/v0.1.0.toml +3 -0
  32. package/.rlsbl/version +1 -0
  33. package/.selfdoc/hashes/hashes.json +146 -0
  34. package/.strictcli/schema.json +227 -0
  35. package/CHANGELOG.md +17 -0
  36. package/CLAUDE.md +100 -0
  37. package/LICENSE +21 -0
  38. package/README.md +116 -0
  39. package/assets/icon.png +0 -0
  40. package/docs/_README.md +117 -0
  41. package/docs/cli-config.md +35 -0
  42. package/docs/cli-dev.md +21 -0
  43. package/docs/cli-diagnose.md +12 -0
  44. package/docs/cli-index.md +30 -0
  45. package/docs/cli-list.md +18 -0
  46. package/docs/cli-mcp.md +18 -0
  47. package/docs/cli-new.md +26 -0
  48. package/docs/cli-open.md +21 -0
  49. package/docs/cli-serve.md +21 -0
  50. package/docs/cli-stop.md +12 -0
  51. package/docs/gen-index.md +36 -0
  52. package/docs/index.md +13 -0
  53. package/docs/server-src-pixelweaver-__main__.md +12 -0
  54. package/docs/server-src-pixelweaver-autosave.md +12 -0
  55. package/docs/server-src-pixelweaver-bridge.md +12 -0
  56. package/docs/server-src-pixelweaver-cli.md +12 -0
  57. package/docs/server-src-pixelweaver-config.md +12 -0
  58. package/docs/server-src-pixelweaver-connections.md +12 -0
  59. package/docs/server-src-pixelweaver-main.md +12 -0
  60. package/docs/server-src-pixelweaver-mcp_bridge.md +12 -0
  61. package/docs/server-src-pixelweaver-mcp_drawing_tools.md +12 -0
  62. package/docs/server-src-pixelweaver-mcp_export_tools.md +12 -0
  63. package/docs/server-src-pixelweaver-mcp_frame_tools.md +12 -0
  64. package/docs/server-src-pixelweaver-mcp_history_tools.md +12 -0
  65. package/docs/server-src-pixelweaver-mcp_layer_tools.md +12 -0
  66. package/docs/server-src-pixelweaver-mcp_lock.md +12 -0
  67. package/docs/server-src-pixelweaver-mcp_project_tools.md +12 -0
  68. package/docs/server-src-pixelweaver-mcp_read_tools.md +12 -0
  69. package/docs/server-src-pixelweaver-mcp_registry.md +12 -0
  70. package/docs/server-src-pixelweaver-mcp_resources.md +12 -0
  71. package/docs/server-src-pixelweaver-mcp_server.md +12 -0
  72. package/docs/server-src-pixelweaver-protocol.md +12 -0
  73. package/docs/server-src-pixelweaver-state.md +12 -0
  74. package/docs/server-src-pixelweaver-storage.md +12 -0
  75. package/docs/server-src-pixelweaver-websocket.md +12 -0
  76. package/docs/server-src-pixelweaver.md +12 -0
  77. package/e2e/app-launch.test.ts +35 -0
  78. package/e2e/menus.test.ts +26 -0
  79. package/e2e/tools.test.ts +27 -0
  80. package/e2e/undo-redo.test.ts +11 -0
  81. package/eslint.config.js +62 -0
  82. package/index.html +13 -0
  83. package/package.json +48 -0
  84. package/playwright.config.ts +19 -0
  85. package/plugins/builtin/.gitkeep +0 -0
  86. package/plugins/builtin/advanced-fill-tool.ts +146 -0
  87. package/plugins/builtin/circle-tool.ts +186 -0
  88. package/plugins/builtin/diamond-tool.ts +182 -0
  89. package/plugins/builtin/dither-tool.ts +186 -0
  90. package/plugins/builtin/drawing-primitives-plugin.ts +362 -0
  91. package/plugins/builtin/drawing-utils.test.ts +495 -0
  92. package/plugins/builtin/drawing-utils.ts +431 -0
  93. package/plugins/builtin/effects/blur.ts +97 -0
  94. package/plugins/builtin/effects/color-effects.ts +278 -0
  95. package/plugins/builtin/effects/flip.ts +83 -0
  96. package/plugins/builtin/effects/glow.ts +118 -0
  97. package/plugins/builtin/effects/outline.ts +110 -0
  98. package/plugins/builtin/effects/rotate.ts +357 -0
  99. package/plugins/builtin/effects/scale.ts +258 -0
  100. package/plugins/builtin/effects/shadow.ts +111 -0
  101. package/plugins/builtin/effects/sharpen.ts +102 -0
  102. package/plugins/builtin/effects.test.ts +715 -0
  103. package/plugins/builtin/eraser-tool.ts +23 -0
  104. package/plugins/builtin/eyedropper-tool.ts +83 -0
  105. package/plugins/builtin/fill-tool.ts +93 -0
  106. package/plugins/builtin/gradient-tool.ts +204 -0
  107. package/plugins/builtin/gradient.test.ts +142 -0
  108. package/plugins/builtin/importers/aseprite-importer-plugin.ts +174 -0
  109. package/plugins/builtin/importers/aseprite-parser.ts +497 -0
  110. package/plugins/builtin/importers/piskel-importer-plugin.ts +222 -0
  111. package/plugins/builtin/importers/sky-spec-plugin.ts +409 -0
  112. package/plugins/builtin/line-tool.ts +267 -0
  113. package/plugins/builtin/make-stroke-tool.ts +271 -0
  114. package/plugins/builtin/noise-dither.test.ts +151 -0
  115. package/plugins/builtin/noise-tool.ts +131 -0
  116. package/plugins/builtin/pattern-stamp-tool.ts +162 -0
  117. package/plugins/builtin/pencil-tool.ts +25 -0
  118. package/plugins/builtin/rect-tool.ts +179 -0
  119. package/plugins/builtin/selection-tool.ts +388 -0
  120. package/plugins/builtin/selection.test.ts +195 -0
  121. package/plugins/builtin/tool-plugins.test.ts +529 -0
  122. package/public/favicon.svg +7 -0
  123. package/public/sw.js +91 -0
  124. package/pyproject.toml +49 -0
  125. package/scripts/eslint-wave-a-list.sh +24 -0
  126. package/scripts/eslint-wave-a-status.sh +25 -0
  127. package/scripts/fix-index-signature-access.py +127 -0
  128. package/scripts/fix-unchecked-index.py +128 -0
  129. package/scripts/fix-unchecked-vars.py +127 -0
  130. package/scripts/fix-wave-a-bangs.py +36 -0
  131. package/scripts/fix-wave-a-templates.py +108 -0
  132. package/scripts/fix-wave-b-void-expr.py +167 -0
  133. package/scripts/generate-command-params.py +540 -0
  134. package/scripts/migrate-single-frame-to-multi.py +171 -0
  135. package/scripts/smoke-test.sh +77 -0
  136. package/selfdoc.json +10 -0
  137. package/server/README.md +0 -0
  138. package/server/src/pixelweaver/__init__.py +1 -0
  139. package/server/src/pixelweaver/__main__.py +4 -0
  140. package/server/src/pixelweaver/autosave.py +114 -0
  141. package/server/src/pixelweaver/bridge.py +127 -0
  142. package/server/src/pixelweaver/cli.py +199 -0
  143. package/server/src/pixelweaver/config.py +24 -0
  144. package/server/src/pixelweaver/connections.py +54 -0
  145. package/server/src/pixelweaver/main.py +271 -0
  146. package/server/src/pixelweaver/mcp_bridge.py +189 -0
  147. package/server/src/pixelweaver/mcp_drawing_tools.py +178 -0
  148. package/server/src/pixelweaver/mcp_export_tools.py +291 -0
  149. package/server/src/pixelweaver/mcp_frame_tools.py +423 -0
  150. package/server/src/pixelweaver/mcp_history_tools.py +106 -0
  151. package/server/src/pixelweaver/mcp_layer_tools.py +64 -0
  152. package/server/src/pixelweaver/mcp_lock.py +37 -0
  153. package/server/src/pixelweaver/mcp_project_tools.py +302 -0
  154. package/server/src/pixelweaver/mcp_read_tools.py +163 -0
  155. package/server/src/pixelweaver/mcp_registry.py +247 -0
  156. package/server/src/pixelweaver/mcp_resources.py +312 -0
  157. package/server/src/pixelweaver/mcp_server.py +234 -0
  158. package/server/src/pixelweaver/protocol.py +219 -0
  159. package/server/src/pixelweaver/state.py +267 -0
  160. package/server/src/pixelweaver/storage.py +293 -0
  161. package/server/src/pixelweaver/websocket.py +282 -0
  162. package/server/tests/__init__.py +0 -0
  163. package/server/tests/conftest.py +17 -0
  164. package/server/tests/test_api.py +96 -0
  165. package/server/tests/test_bridge.py +161 -0
  166. package/server/tests/test_health.py +9 -0
  167. package/server/tests/test_integration.py +86 -0
  168. package/server/tests/test_mcp_bridge.py +293 -0
  169. package/server/tests/test_mcp_lock.py +34 -0
  170. package/server/tests/test_mcp_registry.py +679 -0
  171. package/server/tests/test_mcp_resources.py +648 -0
  172. package/server/tests/test_protocol.py +125 -0
  173. package/server/tests/test_state.py +87 -0
  174. package/server/tests/test_storage.py +306 -0
  175. package/server/tests/test_websocket.py +275 -0
  176. package/src/App.svelte +107 -0
  177. package/src/app.css +215 -0
  178. package/src/lib/animation/AnimationPreviewPanel.svelte +667 -0
  179. package/src/lib/animation/animation-commands.test.ts +228 -0
  180. package/src/lib/animation/animation-commands.ts +540 -0
  181. package/src/lib/animation/animation-preview-panel-plugin.ts +25 -0
  182. package/src/lib/animation/animation-preview.svelte.ts +151 -0
  183. package/src/lib/animation/clipboard.ts +134 -0
  184. package/src/lib/animation/frame-model.svelte.ts +437 -0
  185. package/src/lib/animation/frame-model.test.ts +314 -0
  186. package/src/lib/animation/frame-selection.svelte.ts +77 -0
  187. package/src/lib/animation/frame-tags.svelte.ts +238 -0
  188. package/src/lib/animation/frame-tags.test.ts +136 -0
  189. package/src/lib/animation/import.test.ts +141 -0
  190. package/src/lib/animation/import.ts +112 -0
  191. package/src/lib/animation/spritesheet-export.test.ts +239 -0
  192. package/src/lib/animation/spritesheet-export.ts +314 -0
  193. package/src/lib/canvas/CanvasViewport.svelte +216 -0
  194. package/src/lib/canvas/canvas-init-plugin.ts +178 -0
  195. package/src/lib/canvas/canvas-renderer.ts +408 -0
  196. package/src/lib/canvas/canvas-state.svelte.ts +232 -0
  197. package/src/lib/canvas/canvas-state.test.ts +139 -0
  198. package/src/lib/canvas/input-handler.ts +221 -0
  199. package/src/lib/canvas/input-plugin.ts +150 -0
  200. package/src/lib/canvas/onion-skin.ts +94 -0
  201. package/src/lib/canvas/pixel-buffer.test.ts +249 -0
  202. package/src/lib/canvas/pixel-buffer.ts +151 -0
  203. package/src/lib/canvas/render-state.svelte.ts +18 -0
  204. package/src/lib/canvas/shape-preview-state.svelte.ts +36 -0
  205. package/src/lib/canvas/tile-mode.test.ts +53 -0
  206. package/src/lib/canvas/tile-mode.ts +92 -0
  207. package/src/lib/canvas/viewport-utils.ts +24 -0
  208. package/src/lib/canvas/zoom-utils.ts +31 -0
  209. package/src/lib/color/.gitkeep +0 -0
  210. package/src/lib/color/color-commands.ts +87 -0
  211. package/src/lib/color/color-state.svelte.ts +98 -0
  212. package/src/lib/color/color-state.test.ts +91 -0
  213. package/src/lib/color/color-utils.test.ts +220 -0
  214. package/src/lib/color/color-utils.ts +243 -0
  215. package/src/lib/color/palette-state.svelte.ts +127 -0
  216. package/src/lib/color/palette-state.test.ts +154 -0
  217. package/src/lib/color/palette.ts +79 -0
  218. package/src/lib/core/bootstrap.ts +66 -0
  219. package/src/lib/core/command-params.generated.ts +1549 -0
  220. package/src/lib/core/command-params.ts +20 -0
  221. package/src/lib/core/command-runner.ts +79 -0
  222. package/src/lib/core/commands.ts +134 -0
  223. package/src/lib/core/dispatcher.test.ts +548 -0
  224. package/src/lib/core/dispatcher.ts +361 -0
  225. package/src/lib/core/index.test.ts +7 -0
  226. package/src/lib/core/notification-state.svelte.ts +119 -0
  227. package/src/lib/core/plugin-api.ts +210 -0
  228. package/src/lib/core/plugin-discovery.test.ts +53 -0
  229. package/src/lib/core/plugin-discovery.ts +65 -0
  230. package/src/lib/core/plugin-loader.test.ts +159 -0
  231. package/src/lib/core/plugin-loader.ts +240 -0
  232. package/src/lib/core/plugin-types.ts +286 -0
  233. package/src/lib/core/registries.svelte.ts +74 -0
  234. package/src/lib/core/tool-options-state.svelte.ts +61 -0
  235. package/src/lib/dock/DockLayout.svelte +375 -0
  236. package/src/lib/dock/TabAddMenu.svelte +90 -0
  237. package/src/lib/dock/dock-persistence.ts +46 -0
  238. package/src/lib/dock/dock-plugin.ts +49 -0
  239. package/src/lib/dock/dock-presets.ts +156 -0
  240. package/src/lib/dock/dock-state.svelte.ts +77 -0
  241. package/src/lib/dock/dock-types.ts +2 -0
  242. package/src/lib/dock/dockview-theme.css +226 -0
  243. package/src/lib/dock/dockview-theme.ts +17 -0
  244. package/src/lib/dock/header-action-renderer.ts +77 -0
  245. package/src/lib/edit/clipboard-state.ts +34 -0
  246. package/src/lib/export/download.ts +201 -0
  247. package/src/lib/export/png-metadata.ts +181 -0
  248. package/src/lib/history/ActionLogPanel.svelte +418 -0
  249. package/src/lib/history/action-log-panel-plugin.ts +24 -0
  250. package/src/lib/history/action-log.svelte.ts +172 -0
  251. package/src/lib/history/action-log.test.ts +168 -0
  252. package/src/lib/history/history-commands.ts +403 -0
  253. package/src/lib/history/macros.svelte.ts +320 -0
  254. package/src/lib/history/macros.test.ts +224 -0
  255. package/src/lib/history/replay-engine.test.ts +241 -0
  256. package/src/lib/history/replay-engine.ts +149 -0
  257. package/src/lib/history/spec-format.test.ts +250 -0
  258. package/src/lib/history/spec-format.ts +210 -0
  259. package/src/lib/iso/SeamCheckerPanel.svelte +251 -0
  260. package/src/lib/iso/iso-math.test.ts +77 -0
  261. package/src/lib/iso/iso-math.ts +77 -0
  262. package/src/lib/iso/seam-checker-panel-plugin.ts +25 -0
  263. package/src/lib/iso/seam-checker.test.ts +126 -0
  264. package/src/lib/iso/seam-checker.ts +93 -0
  265. package/src/lib/iso/tessellation.ts +67 -0
  266. package/src/lib/layers/compositor.test.ts +193 -0
  267. package/src/lib/layers/compositor.ts +175 -0
  268. package/src/lib/layers/layer-commands.test.ts +263 -0
  269. package/src/lib/layers/layer-commands.ts +429 -0
  270. package/src/lib/layers/layer-tree.svelte.ts +516 -0
  271. package/src/lib/layers/layer-tree.test.ts +383 -0
  272. package/src/lib/layers/layer-types.ts +56 -0
  273. package/src/lib/leveleditor/LevelEditorViewport.svelte +1808 -0
  274. package/src/lib/leveleditor/MapPropertiesPanel.svelte +266 -0
  275. package/src/lib/leveleditor/TilePickerPanel.svelte +324 -0
  276. package/src/lib/leveleditor/depth-sort.test.ts +70 -0
  277. package/src/lib/leveleditor/depth-sort.ts +39 -0
  278. package/src/lib/leveleditor/level-editor-commands.ts +353 -0
  279. package/src/lib/leveleditor/level-editor-viewport-plugin.ts +25 -0
  280. package/src/lib/leveleditor/map-properties-panel-plugin.ts +25 -0
  281. package/src/lib/leveleditor/map-state.svelte.ts +372 -0
  282. package/src/lib/leveleditor/map-state.test.ts +243 -0
  283. package/src/lib/leveleditor/tile-picker-panel-plugin.ts +25 -0
  284. package/src/lib/leveleditor/tile-source-registry.svelte.ts +91 -0
  285. package/src/lib/leveleditor/tiled-export.test.ts +144 -0
  286. package/src/lib/leveleditor/tiled-export.ts +374 -0
  287. package/src/lib/pywebview.d.ts +15 -0
  288. package/src/lib/recovery/.gitkeep +0 -0
  289. package/src/lib/recovery/idb-store.ts +118 -0
  290. package/src/lib/recovery/recovery-manager.test.ts +321 -0
  291. package/src/lib/recovery/recovery-manager.ts +115 -0
  292. package/src/lib/recovery/recovery-plugin.ts +55 -0
  293. package/src/lib/recovery/recovery-state.svelte.ts +21 -0
  294. package/src/lib/recovery/sw-plugin.ts +18 -0
  295. package/src/lib/recovery/sw-register.ts +17 -0
  296. package/src/lib/save/directory-format.ts +42 -0
  297. package/src/lib/save/project-snapshot.ts +139 -0
  298. package/src/lib/save/recent-projects.ts +56 -0
  299. package/src/lib/save/save-state.svelte.ts +35 -0
  300. package/src/lib/save/storage.ts +167 -0
  301. package/src/lib/save/zip-format.ts +45 -0
  302. package/src/lib/shortcuts/.gitkeep +0 -0
  303. package/src/lib/shortcuts/ShortcutEditorPanel.svelte +690 -0
  304. package/src/lib/shortcuts/default-bindings.ts +61 -0
  305. package/src/lib/shortcuts/shortcut-editor-panel-plugin.ts +25 -0
  306. package/src/lib/shortcuts/shortcut-init.ts +380 -0
  307. package/src/lib/shortcuts/shortcut-manager.test.ts +466 -0
  308. package/src/lib/shortcuts/shortcut-manager.ts +281 -0
  309. package/src/lib/shortcuts/shortcut-state.svelte.ts +78 -0
  310. package/src/lib/shortcuts/shortcuts-plugin.ts +17 -0
  311. package/src/lib/sync/patch-applicator.ts +300 -0
  312. package/src/lib/sync/patch-types.ts +65 -0
  313. package/src/lib/sync/project-manager.ts +108 -0
  314. package/src/lib/sync/sync-init.ts +152 -0
  315. package/src/lib/sync/sync-plugin.ts +19 -0
  316. package/src/lib/sync/sync-state.svelte.ts +56 -0
  317. package/src/lib/sync/ws-client.test.ts +604 -0
  318. package/src/lib/sync/ws-client.ts +574 -0
  319. package/src/lib/tools/.gitkeep +0 -0
  320. package/src/lib/ui/.gitkeep +0 -0
  321. package/src/lib/ui/AboutDialog.svelte +113 -0
  322. package/src/lib/ui/ColorPicker.svelte +761 -0
  323. package/src/lib/ui/ContextMenu.svelte +216 -0
  324. package/src/lib/ui/ExportDialog.svelte +747 -0
  325. package/src/lib/ui/FrameStrip.svelte +854 -0
  326. package/src/lib/ui/LayerPanel.svelte +810 -0
  327. package/src/lib/ui/MenuBar.svelte +590 -0
  328. package/src/lib/ui/NewProjectDialog.svelte +803 -0
  329. package/src/lib/ui/PluginManagerPanel.svelte +475 -0
  330. package/src/lib/ui/PromptDialog.svelte +252 -0
  331. package/src/lib/ui/RecoveryDialog.svelte +295 -0
  332. package/src/lib/ui/ResizeDialog.svelte +416 -0
  333. package/src/lib/ui/StatusBar.svelte +145 -0
  334. package/src/lib/ui/ToolbarPanel.svelte +488 -0
  335. package/src/lib/ui/animation-menu-commands.ts +194 -0
  336. package/src/lib/ui/command-palette/CommandPalette.svelte +232 -0
  337. package/src/lib/ui/command-palette/command-palette-plugin.ts +30 -0
  338. package/src/lib/ui/command-palette/command-palette-state.svelte.ts +190 -0
  339. package/src/lib/ui/command-palette/command-palette.test.ts +129 -0
  340. package/src/lib/ui/dialog-state.svelte.ts +70 -0
  341. package/src/lib/ui/edit-commands.ts +271 -0
  342. package/src/lib/ui/file-commands.ts +275 -0
  343. package/src/lib/ui/file-open.ts +99 -0
  344. package/src/lib/ui/help-commands.ts +93 -0
  345. package/src/lib/ui/image-commands.ts +181 -0
  346. package/src/lib/ui/layer-menu-commands.ts +420 -0
  347. package/src/lib/ui/menu-builder.ts +224 -0
  348. package/src/lib/ui/notifications/NotificationBanner.svelte +137 -0
  349. package/src/lib/ui/notifications/notification-plugin.ts +29 -0
  350. package/src/lib/ui/notifications/notification-state.svelte.ts +9 -0
  351. package/src/lib/ui/plugin-manager-panel-plugin.ts +26 -0
  352. package/src/lib/ui/plugin-state.svelte.ts +62 -0
  353. package/src/lib/ui/select-commands.ts +75 -0
  354. package/src/lib/ui/theme-plugin.ts +18 -0
  355. package/src/lib/ui/theme.svelte.ts +45 -0
  356. package/src/lib/ui/theme.test.ts +51 -0
  357. package/src/lib/ui/toolbar-config.ts +90 -0
  358. package/src/lib/ui/toolbar-plugin.ts +39 -0
  359. package/src/lib/ui/view-commands.ts +629 -0
  360. package/src/lib/variants/BisectionExportDialog.svelte +500 -0
  361. package/src/lib/variants/VariantPanel.svelte +822 -0
  362. package/src/lib/variants/bisection-export.test.ts +113 -0
  363. package/src/lib/variants/bisection-export.ts +148 -0
  364. package/src/lib/variants/palette-extraction.test.ts +111 -0
  365. package/src/lib/variants/palette-extraction.ts +84 -0
  366. package/src/lib/variants/palette-interpolation.test.ts +113 -0
  367. package/src/lib/variants/palette-interpolation.ts +87 -0
  368. package/src/lib/variants/palette-swap.test.ts +101 -0
  369. package/src/lib/variants/palette-swap.ts +114 -0
  370. package/src/lib/variants/variant-commands.ts +594 -0
  371. package/src/lib/variants/variant-panel-plugin.ts +27 -0
  372. package/src/lib/variants/variant-randomizer.ts +101 -0
  373. package/src/lib/variants/variant-state.svelte.ts +166 -0
  374. package/src/lib/variants/variant-state.test.ts +138 -0
  375. package/src/main.ts +14 -0
  376. package/src/vite-env.d.ts +3 -0
  377. package/svelte.config.js +2 -0
  378. package/todo/.done/audit-design-decisions.md +812 -0
  379. package/todo/.done/audit-implementation-plan.md +1235 -0
  380. package/todo/.done/happy-path-polish.md +177 -0
  381. package/todo/.done/pixelweaver-full-build.md +937 -0
  382. package/todo/.done/server-multi-frame-design.md +405 -0
  383. package/todo/.done/typed-dispatcher-design.md +435 -0
  384. package/todo/.done/unified-toolbar-and-action-system.md +323 -0
  385. package/todo/.obsolete/comprehensive-audit-obsolete-items.md +33 -0
  386. package/todo/.obsolete/tauri-desktop-bundle.md +424 -0
  387. package/todo/comprehensive-audit.md +1085 -0
  388. package/tsconfig.app.json +26 -0
  389. package/tsconfig.json +7 -0
  390. package/tsconfig.node.json +20 -0
  391. package/uv.lock +1167 -0
  392. package/vite.config.ts +32 -0
@@ -0,0 +1,679 @@
1
+ """Tests for the MCP command registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ from io import BytesIO
7
+
8
+ import pytest
9
+ from PIL import Image
10
+
11
+ from pixelweaver.connections import ConnectionManager
12
+ from pixelweaver.mcp_registry import MCPCommandRegistry
13
+ from pixelweaver.state import ServerState
14
+
15
+
16
+ @pytest.fixture()
17
+ def state() -> ServerState:
18
+ """ServerState with one active project."""
19
+ s = ServerState()
20
+ s.create_project("test-project", 32, 32)
21
+ return s
22
+
23
+
24
+ @pytest.fixture()
25
+ def connections() -> ConnectionManager:
26
+ return ConnectionManager()
27
+
28
+
29
+ @pytest.fixture()
30
+ def registry(state: ServerState, connections: ConnectionManager) -> MCPCommandRegistry:
31
+ return MCPCommandRegistry(state, connections)
32
+
33
+
34
+ class TestToolDefinitions:
35
+ def test_has_all_expected_tools(self, registry: MCPCommandRegistry):
36
+ """Tool definitions include all expected drawing, layer, and project tools."""
37
+ defs = registry.get_tool_definitions()
38
+ names = {d["name"] for d in defs}
39
+
40
+ # Drawing tools
41
+ for tool in [
42
+ "draw_pixels", "erase_pixels", "flood_fill", "draw_line",
43
+ "draw_rect", "draw_ellipse", "draw_diamond", "draw_gradient",
44
+ "draw_noise", "draw_dither",
45
+ ]:
46
+ assert tool in names, f"Missing drawing tool: {tool}"
47
+
48
+ # Layer tools
49
+ for tool in [
50
+ "add_layer", "remove_layer", "rename_layer", "move_layer",
51
+ "set_layer_visibility", "set_layer_opacity",
52
+ ]:
53
+ assert tool in names, f"Missing layer tool: {tool}"
54
+
55
+ # Frame tools (mutation)
56
+ for tool in [
57
+ "add_frame", "remove_frame", "duplicate_frame", "reorder_frame",
58
+ "set_frame_duration", "set_global_fps", "set_current_frame",
59
+ ]:
60
+ assert tool in names, f"Missing frame tool: {tool}"
61
+
62
+ # Frame tools (read-only)
63
+ for tool in ["get_frame_count", "get_current_frame"]:
64
+ assert tool in names, f"Missing frame read tool: {tool}"
65
+
66
+ # Project tools
67
+ for tool in [
68
+ "create_project", "list_projects", "open_project",
69
+ "save_project", "get_project_info",
70
+ ]:
71
+ assert tool in names, f"Missing project tool: {tool}"
72
+
73
+ # Canvas tools
74
+ for tool in ["get_canvas_size", "resize_canvas", "get_pixel", "get_region"]:
75
+ assert tool in names, f"Missing canvas tool: {tool}"
76
+
77
+ # History tools
78
+ for tool in ["undo", "redo", "get_action_log"]:
79
+ assert tool in names, f"Missing history tool: {tool}"
80
+
81
+ # Export tools
82
+ for tool in ["export_png", "export_spritesheet"]:
83
+ assert tool in names, f"Missing export tool: {tool}"
84
+
85
+ # Curated tools
86
+ for tool in ["draw_shape", "fill_area", "apply_effect"]:
87
+ assert tool in names, f"Missing curated tool: {tool}"
88
+
89
+ # Read tools
90
+ for tool in [
91
+ "get_canvas_info", "get_layer_tree", "get_palette", "get_canvas_thumbnail",
92
+ ]:
93
+ assert tool in names, f"Missing read tool: {tool}"
94
+
95
+ def test_tool_definitions_have_required_fields(self, registry: MCPCommandRegistry):
96
+ """Every tool def has name, description, and inputSchema."""
97
+ for td in registry.get_tool_definitions():
98
+ assert "name" in td
99
+ assert "description" in td
100
+ assert "inputSchema" in td
101
+ assert td["inputSchema"]["type"] == "object"
102
+
103
+ def test_get_tool_def_by_name(self, registry: MCPCommandRegistry):
104
+ """get_tool_def returns the correct ToolDef for a known tool."""
105
+ td = registry.get_tool_def("draw_pixels")
106
+ assert td is not None
107
+ assert td.name == "draw_pixels"
108
+ assert td.mutates is True
109
+
110
+ td_read = registry.get_tool_def("get_canvas_info")
111
+ assert td_read is not None
112
+ assert td_read.mutates is False
113
+
114
+
115
+ class TestDrawingToolExecution:
116
+ @pytest.mark.asyncio
117
+ async def test_draw_pixels_creates_command(
118
+ self, registry: MCPCommandRegistry, state: ServerState,
119
+ ):
120
+ """Executing draw_pixels stores a command in project history."""
121
+ result = await registry.execute_tool("draw_pixels", {
122
+ "pixels": [{"x": 0, "y": 0}],
123
+ "color": "#ff0000ff",
124
+ })
125
+ assert result["success"] is True
126
+ assert "command_id" in result
127
+
128
+ project = state.get_active_project()
129
+ assert project is not None
130
+ assert len(project.command_history) == 1
131
+ assert project.command_history[0]["type"] == "draw_pixels"
132
+
133
+ @pytest.mark.asyncio
134
+ async def test_draw_pixels_no_project(self, connections: ConnectionManager):
135
+ """Drawing without an active project returns an error."""
136
+ empty_state = ServerState()
137
+ reg = MCPCommandRegistry(empty_state, connections)
138
+ result = await reg.execute_tool("draw_pixels", {
139
+ "pixels": [{"x": 0, "y": 0}],
140
+ "color": "#ff0000ff",
141
+ })
142
+ assert result["success"] is False
143
+ assert "No active project" in result["error"]
144
+
145
+
146
+ class TestReadToolExecution:
147
+ @pytest.mark.asyncio
148
+ async def test_get_canvas_info_returns_data(self, registry: MCPCommandRegistry):
149
+ """get_canvas_info returns canvas metadata."""
150
+ result = await registry.execute_tool("get_canvas_info", {})
151
+ assert result["success"] is True
152
+ assert "canvases" in result
153
+ assert len(result["canvases"]) == 1
154
+ canvas = result["canvases"][0]
155
+ assert canvas["width"] == 32
156
+ assert canvas["height"] == 32
157
+
158
+ @pytest.mark.asyncio
159
+ async def test_get_layer_tree_returns_layers(self, registry: MCPCommandRegistry):
160
+ """get_layer_tree returns the layer structure."""
161
+ result = await registry.execute_tool("get_layer_tree", {})
162
+ assert result["success"] is True
163
+ assert len(result["layers"]) == 1
164
+ assert result["layers"][0]["name"] == "Layer 0"
165
+
166
+ @pytest.mark.asyncio
167
+ async def test_get_pixel_returns_color(self, registry: MCPCommandRegistry):
168
+ """get_pixel reads from pixel data (default is transparent)."""
169
+ result = await registry.execute_tool("get_pixel", {"x": 0, "y": 0})
170
+ assert result["success"] is True
171
+ # Default canvas is all transparent
172
+ assert result["rgba"] == [0, 0, 0, 0]
173
+
174
+ @pytest.mark.asyncio
175
+ async def test_get_pixel_out_of_bounds(self, registry: MCPCommandRegistry):
176
+ """get_pixel with invalid coords returns error."""
177
+ result = await registry.execute_tool("get_pixel", {"x": 999, "y": 999})
178
+ assert result["success"] is False
179
+ assert "out of bounds" in result["error"]
180
+
181
+
182
+ class TestUnknownTool:
183
+ @pytest.mark.asyncio
184
+ async def test_unknown_tool_returns_error(self, registry: MCPCommandRegistry):
185
+ """Calling an unregistered tool returns an error."""
186
+ result = await registry.execute_tool("nonexistent_tool", {})
187
+ assert result["success"] is False
188
+ assert "Unknown tool" in result["error"]
189
+
190
+
191
+ class TestCuratedTools:
192
+ @pytest.mark.asyncio
193
+ async def test_draw_shape_rect(self, registry: MCPCommandRegistry, state: ServerState):
194
+ """draw_shape with shape=rect dispatches draw_rect command."""
195
+ result = await registry.execute_tool("draw_shape", {
196
+ "shape": "rect",
197
+ "x": 0,
198
+ "y": 0,
199
+ "width": 10,
200
+ "height": 10,
201
+ "color": "#00ff00ff",
202
+ })
203
+ assert result["success"] is True
204
+
205
+ project = state.get_active_project()
206
+ assert project is not None
207
+ assert project.command_history[-1]["type"] == "draw_rect"
208
+
209
+ @pytest.mark.asyncio
210
+ async def test_draw_shape_ellipse(self, registry: MCPCommandRegistry, state: ServerState):
211
+ """draw_shape with shape=ellipse dispatches draw_ellipse command."""
212
+ result = await registry.execute_tool("draw_shape", {
213
+ "shape": "ellipse",
214
+ "x": 10,
215
+ "y": 10,
216
+ "width": 5,
217
+ "height": 5,
218
+ "color": "#0000ffff",
219
+ })
220
+ assert result["success"] is True
221
+
222
+ project = state.get_active_project()
223
+ assert project is not None
224
+ cmd = project.command_history[-1]
225
+ assert cmd["type"] == "draw_ellipse"
226
+ # x/y should be remapped to cx/cy
227
+ assert cmd["params"]["cx"] == 10
228
+ assert cmd["params"]["cy"] == 10
229
+
230
+ @pytest.mark.asyncio
231
+ async def test_draw_shape_unknown(self, registry: MCPCommandRegistry):
232
+ """draw_shape with an invalid shape name returns error."""
233
+ result = await registry.execute_tool("draw_shape", {
234
+ "shape": "hexagon",
235
+ "x": 0, "y": 0, "width": 5, "height": 5,
236
+ "color": "#ffffffff",
237
+ })
238
+ assert result["success"] is False
239
+ assert "Unknown shape" in result["error"]
240
+
241
+ @pytest.mark.asyncio
242
+ async def test_fill_area_dispatches_flood_fill(
243
+ self, registry: MCPCommandRegistry, state: ServerState,
244
+ ):
245
+ """fill_area dispatches a flood_fill command."""
246
+ result = await registry.execute_tool("fill_area", {
247
+ "x": 5, "y": 5, "color": "#ff0000ff",
248
+ })
249
+ assert result["success"] is True
250
+
251
+ project = state.get_active_project()
252
+ assert project is not None
253
+ assert project.command_history[-1]["type"] == "flood_fill"
254
+
255
+
256
+ class TestProjectTools:
257
+ @pytest.mark.asyncio
258
+ async def test_list_projects(self, registry: MCPCommandRegistry):
259
+ """list_projects returns the loaded projects."""
260
+ result = await registry.execute_tool("list_projects", {})
261
+ assert result["success"] is True
262
+ assert "test-project" in result["projects"]
263
+
264
+ @pytest.mark.asyncio
265
+ async def test_create_project(self, registry: MCPCommandRegistry, state: ServerState):
266
+ """create_project adds a new project and sets it active."""
267
+ result = await registry.execute_tool("create_project", {
268
+ "name": "new-art",
269
+ "width": 16,
270
+ "height": 16,
271
+ })
272
+ assert result["success"] is True
273
+ assert "new-art" in state.projects
274
+ assert state.active_project == "new-art"
275
+
276
+ @pytest.mark.asyncio
277
+ async def test_create_duplicate_project(self, registry: MCPCommandRegistry):
278
+ """Creating a project with a duplicate name returns error."""
279
+ result = await registry.execute_tool("create_project", {
280
+ "name": "test-project",
281
+ "width": 8,
282
+ "height": 8,
283
+ })
284
+ assert result["success"] is False
285
+ assert "already exists" in result["error"]
286
+
287
+
288
+ class TestHistoryTools:
289
+ @pytest.mark.asyncio
290
+ async def test_undo_redo_cycle(self, registry: MCPCommandRegistry, state: ServerState):
291
+ """undo/redo cycle works correctly via MCP tools."""
292
+ # Add a command
293
+ await registry.execute_tool("draw_pixels", {
294
+ "pixels": [{"x": 0, "y": 0}],
295
+ "color": "#ff0000ff",
296
+ })
297
+ project = state.get_active_project()
298
+ assert project is not None
299
+ assert len(project.command_history) == 1
300
+
301
+ # Undo
302
+ result = await registry.execute_tool("undo", {})
303
+ assert result["success"] is True
304
+ assert len(project.command_history) == 0
305
+ assert len(project.redo_stack) == 1
306
+
307
+ # Redo
308
+ result = await registry.execute_tool("redo", {})
309
+ assert result["success"] is True
310
+ assert len(project.command_history) == 1
311
+ assert len(project.redo_stack) == 0
312
+
313
+ @pytest.mark.asyncio
314
+ async def test_get_action_log(self, registry: MCPCommandRegistry):
315
+ """get_action_log returns history entries."""
316
+ await registry.execute_tool("draw_pixels", {
317
+ "pixels": [{"x": 0, "y": 0}],
318
+ "color": "#ff0000ff",
319
+ })
320
+ result = await registry.execute_tool("get_action_log", {"limit": 10})
321
+ assert result["success"] is True
322
+ assert len(result["actions"]) == 1
323
+ assert result["actions"][0]["type"] == "draw_pixels"
324
+
325
+
326
+ class TestFrameTools:
327
+ """Tests for frame mutation and read tools."""
328
+
329
+ @pytest.mark.asyncio
330
+ async def test_add_frame_increases_count(
331
+ self, registry: MCPCommandRegistry, state: ServerState,
332
+ ):
333
+ """add_frame inserts a new frame and increases frame_count."""
334
+ canvas = next(iter(state.get_active_project().canvases.values()))
335
+ initial_count = canvas.frame_count
336
+ result = await registry.execute_tool("add_frame", {})
337
+ assert result["success"] is True
338
+ assert result["frame_count"] == initial_count + 1
339
+ assert canvas.frame_count == initial_count + 1
340
+
341
+ @pytest.mark.asyncio
342
+ async def test_add_frame_after_index(
343
+ self, registry: MCPCommandRegistry, state: ServerState,
344
+ ):
345
+ """add_frame inserts after the specified index."""
346
+ canvas = next(iter(state.get_active_project().canvases.values()))
347
+ first_frame_id = canvas.frames[0].id
348
+ result = await registry.execute_tool("add_frame", {"after_index": -1})
349
+ assert result["success"] is True
350
+ # New frame should be at index 0, original moves to index 1
351
+ assert canvas.frames[1].id == first_frame_id
352
+ assert canvas.frames[0].id == result["frame_id"]
353
+
354
+ @pytest.mark.asyncio
355
+ async def test_add_frame_no_project(self, connections: ConnectionManager):
356
+ """add_frame without active project returns error."""
357
+ empty = ServerState()
358
+ reg = MCPCommandRegistry(empty, connections)
359
+ result = await reg.execute_tool("add_frame", {})
360
+ assert result["success"] is False
361
+ assert "No active project" in result["error"]
362
+
363
+ @pytest.mark.asyncio
364
+ async def test_remove_frame_decreases_count(
365
+ self, registry: MCPCommandRegistry, state: ServerState,
366
+ ):
367
+ """remove_frame removes a frame and decreases frame_count."""
368
+ # First add a second frame
369
+ await registry.execute_tool("add_frame", {})
370
+ canvas = next(iter(state.get_active_project().canvases.values()))
371
+ assert canvas.frame_count == 2
372
+
373
+ result = await registry.execute_tool("remove_frame", {"frame_index": 1})
374
+ assert result["success"] is True
375
+ assert result["frame_count"] == 1
376
+ assert canvas.frame_count == 1
377
+
378
+ @pytest.mark.asyncio
379
+ async def test_remove_last_frame_fails(
380
+ self, registry: MCPCommandRegistry,
381
+ ):
382
+ """Cannot remove the only remaining frame."""
383
+ result = await registry.execute_tool("remove_frame", {"frame_index": 0})
384
+ assert result["success"] is False
385
+ assert "Cannot remove the last frame" in result["error"]
386
+
387
+ @pytest.mark.asyncio
388
+ async def test_remove_frame_adjusts_current_index(
389
+ self, registry: MCPCommandRegistry, state: ServerState,
390
+ ):
391
+ """Removing a frame adjusts current_frame_index to stay valid."""
392
+ canvas = next(iter(state.get_active_project().canvases.values()))
393
+ # Add two more frames (total 3)
394
+ await registry.execute_tool("add_frame", {})
395
+ await registry.execute_tool("add_frame", {})
396
+ assert canvas.frame_count == 3
397
+
398
+ # Point to the last frame
399
+ canvas.current_frame_index = 2
400
+ # Remove it
401
+ result = await registry.execute_tool("remove_frame", {"frame_index": 2})
402
+ assert result["success"] is True
403
+ assert canvas.current_frame_index == 1 # clamped to new last index
404
+
405
+ @pytest.mark.asyncio
406
+ async def test_duplicate_frame_creates_copy(
407
+ self, registry: MCPCommandRegistry, state: ServerState,
408
+ ):
409
+ """duplicate_frame creates a new frame with a different ID but same data."""
410
+ canvas = next(iter(state.get_active_project().canvases.values()))
411
+ original_id = canvas.frames[0].id
412
+
413
+ result = await registry.execute_tool("duplicate_frame", {"frame_index": 0})
414
+ assert result["success"] is True
415
+ assert result["source_frame_id"] == original_id
416
+ assert result["frame_id"] != original_id
417
+ assert result["frame_count"] == 2
418
+ assert canvas.frame_count == 2
419
+
420
+ # Pixel data should be copied (same content, different objects)
421
+ source = canvas.frames[0]
422
+ dup = canvas.frames[1]
423
+ for layer_id, data in source.pixel_data.items():
424
+ assert layer_id in dup.pixel_data
425
+ assert dup.pixel_data[layer_id] == data
426
+ assert dup.pixel_data[layer_id] is not data # different object
427
+
428
+ @pytest.mark.asyncio
429
+ async def test_duplicate_frame_out_of_range(
430
+ self, registry: MCPCommandRegistry,
431
+ ):
432
+ """duplicate_frame with invalid index returns error."""
433
+ result = await registry.execute_tool("duplicate_frame", {"frame_index": 99})
434
+ assert result["success"] is False
435
+ assert "out of range" in result["error"]
436
+
437
+ @pytest.mark.asyncio
438
+ async def test_reorder_frame_moves_correctly(
439
+ self, registry: MCPCommandRegistry, state: ServerState,
440
+ ):
441
+ """reorder_frame moves a frame from one position to another."""
442
+ canvas = next(iter(state.get_active_project().canvases.values()))
443
+ # Add two frames (total 3: indices 0, 1, 2)
444
+ await registry.execute_tool("add_frame", {})
445
+ await registry.execute_tool("add_frame", {})
446
+ assert canvas.frame_count == 3
447
+
448
+ id_0 = canvas.frames[0].id
449
+ id_1 = canvas.frames[1].id
450
+ id_2 = canvas.frames[2].id
451
+
452
+ # Move frame 0 to position 2
453
+ result = await registry.execute_tool(
454
+ "reorder_frame", {"from_index": 0, "to_index": 2},
455
+ )
456
+ assert result["success"] is True
457
+ assert canvas.frames[0].id == id_1
458
+ assert canvas.frames[1].id == id_2
459
+ assert canvas.frames[2].id == id_0
460
+
461
+ @pytest.mark.asyncio
462
+ async def test_reorder_frame_tracks_current_index(
463
+ self, registry: MCPCommandRegistry, state: ServerState,
464
+ ):
465
+ """reorder_frame adjusts current_frame_index to track the same frame."""
466
+ canvas = next(iter(state.get_active_project().canvases.values()))
467
+ await registry.execute_tool("add_frame", {})
468
+ await registry.execute_tool("add_frame", {})
469
+
470
+ # Current index points to frame 0
471
+ canvas.current_frame_index = 0
472
+ current_frame_id = canvas.frames[0].id
473
+
474
+ # Move frame 0 to position 2
475
+ await registry.execute_tool(
476
+ "reorder_frame", {"from_index": 0, "to_index": 2},
477
+ )
478
+
479
+ # current_frame_index should follow the frame that was at index 0
480
+ assert canvas.frames[canvas.current_frame_index].id == current_frame_id
481
+
482
+ @pytest.mark.asyncio
483
+ async def test_set_frame_duration_updates(
484
+ self, registry: MCPCommandRegistry, state: ServerState,
485
+ ):
486
+ """set_frame_duration updates the frame's duration_ms."""
487
+ canvas = next(iter(state.get_active_project().canvases.values()))
488
+ assert canvas.frames[0].duration_ms is None # default
489
+
490
+ result = await registry.execute_tool(
491
+ "set_frame_duration", {"frame_index": 0, "duration_ms": 200},
492
+ )
493
+ assert result["success"] is True
494
+ assert canvas.frames[0].duration_ms == 200
495
+
496
+ @pytest.mark.asyncio
497
+ async def test_set_frame_duration_null(
498
+ self, registry: MCPCommandRegistry, state: ServerState,
499
+ ):
500
+ """set_frame_duration with null resets to global FPS."""
501
+ canvas = next(iter(state.get_active_project().canvases.values()))
502
+ canvas.frames[0].duration_ms = 200
503
+
504
+ result = await registry.execute_tool(
505
+ "set_frame_duration", {"frame_index": 0, "duration_ms": None},
506
+ )
507
+ assert result["success"] is True
508
+ assert canvas.frames[0].duration_ms is None
509
+
510
+ @pytest.mark.asyncio
511
+ async def test_set_global_fps_updates(
512
+ self, registry: MCPCommandRegistry, state: ServerState,
513
+ ):
514
+ """set_global_fps updates canvas.global_fps with clamping."""
515
+ canvas = next(iter(state.get_active_project().canvases.values()))
516
+ assert canvas.global_fps == 12.0 # default
517
+
518
+ result = await registry.execute_tool("set_global_fps", {"fps": 24})
519
+ assert result["success"] is True
520
+ assert canvas.global_fps == 24.0
521
+
522
+ @pytest.mark.asyncio
523
+ async def test_set_global_fps_clamps(
524
+ self, registry: MCPCommandRegistry, state: ServerState,
525
+ ):
526
+ """set_global_fps clamps to [1, 120]."""
527
+ canvas = next(iter(state.get_active_project().canvases.values()))
528
+
529
+ await registry.execute_tool("set_global_fps", {"fps": 0.1})
530
+ assert canvas.global_fps == 1.0 # clamped to min
531
+
532
+ await registry.execute_tool("set_global_fps", {"fps": 999})
533
+ assert canvas.global_fps == 120.0 # clamped to max
534
+
535
+ @pytest.mark.asyncio
536
+ async def test_get_frame_count(self, registry: MCPCommandRegistry):
537
+ """get_frame_count returns count, current index, and fps."""
538
+ result = await registry.execute_tool("get_frame_count", {})
539
+ assert result["success"] is True
540
+ assert result["count"] == 1
541
+ assert result["current_index"] == 0
542
+ assert result["global_fps"] == 12.0
543
+
544
+ @pytest.mark.asyncio
545
+ async def test_get_current_frame(self, registry: MCPCommandRegistry):
546
+ """get_current_frame returns info about the current frame."""
547
+ result = await registry.execute_tool("get_current_frame", {})
548
+ assert result["success"] is True
549
+ assert result["index"] == 0
550
+ assert "id" in result
551
+ assert result["duration_ms"] is None
552
+
553
+ @pytest.mark.asyncio
554
+ async def test_set_current_frame(
555
+ self, registry: MCPCommandRegistry, state: ServerState,
556
+ ):
557
+ """set_current_frame changes the active frame index."""
558
+ canvas = next(iter(state.get_active_project().canvases.values()))
559
+ # Add a frame so we have two
560
+ await registry.execute_tool("add_frame", {})
561
+ assert canvas.frame_count == 2
562
+
563
+ result = await registry.execute_tool("set_current_frame", {"frame_index": 1})
564
+ assert result["success"] is True
565
+ assert canvas.current_frame_index == 1
566
+ assert result["current_frame_index"] == 1
567
+
568
+ @pytest.mark.asyncio
569
+ async def test_set_current_frame_out_of_range(
570
+ self, registry: MCPCommandRegistry,
571
+ ):
572
+ """set_current_frame with invalid index returns error."""
573
+ result = await registry.execute_tool("set_current_frame", {"frame_index": 99})
574
+ assert result["success"] is False
575
+ assert "out of range" in result["error"]
576
+
577
+
578
+ class TestExportSpritesheet:
579
+ """Tests for export_spritesheet iterating frames within a canvas."""
580
+
581
+ @pytest.mark.asyncio
582
+ async def test_spritesheet_multi_frame_horizontal(
583
+ self, registry: MCPCommandRegistry, state: ServerState,
584
+ ):
585
+ """Spritesheet from 3 frames produces correct horizontal strip dimensions."""
586
+ project = state.get_active_project()
587
+ assert project is not None
588
+ canvas = next(iter(project.canvases.values()))
589
+
590
+ # Draw distinct pixels into frame 0 so it's non-empty
591
+ await registry.execute_tool("draw_pixels", {
592
+ "pixels": [{"x": 0, "y": 0}],
593
+ "color": "#ff0000ff",
594
+ })
595
+
596
+ # Add frame 1 with a different color
597
+ await registry.execute_tool("add_frame", {})
598
+ await registry.execute_tool("set_current_frame", {"frame_index": 1})
599
+ await registry.execute_tool("draw_pixels", {
600
+ "pixels": [{"x": 1, "y": 1}],
601
+ "color": "#00ff00ff",
602
+ })
603
+
604
+ # Add frame 2 with yet another color
605
+ await registry.execute_tool("add_frame", {})
606
+ await registry.execute_tool("set_current_frame", {"frame_index": 2})
607
+ await registry.execute_tool("draw_pixels", {
608
+ "pixels": [{"x": 2, "y": 2}],
609
+ "color": "#0000ffff",
610
+ })
611
+
612
+ assert canvas.frame_count == 3
613
+
614
+ result = await registry.execute_tool("export_spritesheet", {})
615
+ assert result["success"] is True
616
+ assert result["frame_count"] == canvas.frame_count
617
+ assert result["direction"] == "horizontal"
618
+ assert result["frame_width"] == canvas.width
619
+ assert result["frame_height"] == canvas.height
620
+
621
+ # Decode and verify the full image dimensions
622
+ img = Image.open(BytesIO(base64.b64decode(result["image_base64"])))
623
+ assert img.width == canvas.width * 3
624
+ assert img.height == canvas.height
625
+
626
+ @pytest.mark.asyncio
627
+ async def test_spritesheet_vertical(
628
+ self, registry: MCPCommandRegistry, state: ServerState,
629
+ ):
630
+ """Vertical spritesheet stacks frames top-to-bottom."""
631
+ project = state.get_active_project()
632
+ assert project is not None
633
+ canvas = next(iter(project.canvases.values()))
634
+
635
+ # Add a second frame
636
+ await registry.execute_tool("add_frame", {})
637
+ assert canvas.frame_count == 2
638
+
639
+ result = await registry.execute_tool(
640
+ "export_spritesheet", {"direction": "vertical"},
641
+ )
642
+ assert result["success"] is True
643
+ assert result["frame_count"] == 2
644
+ assert result["direction"] == "vertical"
645
+
646
+ img = Image.open(BytesIO(base64.b64decode(result["image_base64"])))
647
+ assert img.width == canvas.width
648
+ assert img.height == canvas.height * 2
649
+
650
+ @pytest.mark.asyncio
651
+ async def test_spritesheet_single_frame(
652
+ self, registry: MCPCommandRegistry,
653
+ ):
654
+ """Spritesheet with one frame returns a single-frame image."""
655
+ result = await registry.execute_tool("export_spritesheet", {})
656
+ assert result["success"] is True
657
+ assert result["frame_count"] == 1
658
+
659
+ img = Image.open(BytesIO(base64.b64decode(result["image_base64"])))
660
+ assert img.width == 32
661
+ assert img.height == 32
662
+
663
+ @pytest.mark.asyncio
664
+ async def test_spritesheet_no_project(self, connections: ConnectionManager):
665
+ """export_spritesheet without active project returns error."""
666
+ empty = ServerState()
667
+ reg = MCPCommandRegistry(empty, connections)
668
+ result = await reg.execute_tool("export_spritesheet", {})
669
+ assert result["success"] is False
670
+ assert "No active project" in result["error"]
671
+
672
+ @pytest.mark.asyncio
673
+ async def test_spritesheet_unknown_canvas(self, registry: MCPCommandRegistry):
674
+ """export_spritesheet with a non-existent canvas_name returns error."""
675
+ result = await registry.execute_tool(
676
+ "export_spritesheet", {"canvas_name": "no-such-canvas"},
677
+ )
678
+ assert result["success"] is False
679
+ assert "not found" in result["error"]