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,648 @@
1
+ """Tests for MCP resources and subscription tracking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from unittest.mock import AsyncMock, MagicMock, patch
7
+
8
+ import pytest
9
+ from pydantic import AnyUrl
10
+
11
+ from pixelweaver.mcp_bridge import CollabServerUnreachableError
12
+ from pixelweaver.state import CanvasState, FrameState, ProjectState, ServerState
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Fixtures
17
+ # ---------------------------------------------------------------------------
18
+
19
+ @pytest.fixture(autouse=True)
20
+ def _reset_subscriptions():
21
+ """Clear the module-level subscription set before and after each test."""
22
+ from pixelweaver import mcp_resources
23
+ mcp_resources._subscribed_uris.clear()
24
+ yield
25
+ mcp_resources._subscribed_uris.clear()
26
+
27
+
28
+ @pytest.fixture()
29
+ def state_with_project() -> ServerState:
30
+ """ServerState with a single 4x4 project, one canvas, one layer, one frame."""
31
+ s = ServerState()
32
+ s.create_project("test-project", 4, 4)
33
+ return s
34
+
35
+
36
+ @pytest.fixture()
37
+ def state_with_painted_pixel(state_with_project: ServerState) -> ServerState:
38
+ """State with a red pixel at (0,0) so the palette resource has colors."""
39
+ canvas = next(iter(state_with_project.get_active_project().canvases.values()))
40
+ layer_id = canvas.layers[0]["id"]
41
+ data = bytearray(canvas.current_frame().pixel_data[layer_id])
42
+ data[0:4] = [255, 0, 0, 255] # red pixel at (0,0)
43
+ canvas.current_frame().pixel_data[layer_id] = bytes(data)
44
+ return state_with_project
45
+
46
+
47
+ def _patch_synced_state(state: ServerState):
48
+ """Return a context manager that patches _synced_state to return the given state."""
49
+ return patch(
50
+ "pixelweaver.mcp_resources._synced_state",
51
+ new_callable=AsyncMock,
52
+ return_value=state,
53
+ )
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Helpers to extract resource handlers from a FastMCP mock
58
+ # ---------------------------------------------------------------------------
59
+
60
+ def _register_resources_on_mock() -> tuple[MagicMock, dict[str, Any]]:
61
+ """Create a FastMCP mock, call init_mcp_resources, return (mock, handlers).
62
+
63
+ handlers is a dict mapping resource URI templates to (handler_fn, kwargs).
64
+ """
65
+ from pixelweaver.mcp_resources import init_mcp_resources
66
+
67
+ mcp = MagicMock()
68
+ registered: dict[str, tuple] = {}
69
+
70
+ def fake_resource(uri_template: str, **kwargs):
71
+ """Capture the decorator registration."""
72
+ def decorator(fn):
73
+ registered[uri_template] = (fn, kwargs)
74
+ return fn
75
+ return decorator
76
+
77
+ mcp.resource = fake_resource
78
+ init_mcp_resources(mcp)
79
+ return mcp, registered
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Tests: init_mcp_resources registration
84
+ # ---------------------------------------------------------------------------
85
+
86
+ class TestInitMcpResources:
87
+ def test_registers_all_four_resources(self):
88
+ """init_mcp_resources registers all four expected resource URIs."""
89
+ _, registered = _register_resources_on_mock()
90
+ expected = {
91
+ "project://state",
92
+ "project://canvas/{name}",
93
+ "project://canvas/{name}/frame/{index}",
94
+ "project://palette",
95
+ }
96
+ assert set(registered.keys()) == expected
97
+
98
+ def test_resource_names(self):
99
+ """Each resource has the expected name kwarg."""
100
+ _, registered = _register_resources_on_mock()
101
+ assert registered["project://state"][1]["name"] == "project_state"
102
+ assert registered["project://canvas/{name}"][1]["name"] == "canvas_info"
103
+ assert registered["project://canvas/{name}/frame/{index}"][1]["name"] == "frame_png"
104
+ assert registered["project://palette"][1]["name"] == "palette"
105
+
106
+ def test_resource_mime_types(self):
107
+ """Each resource specifies the correct mime_type."""
108
+ _, registered = _register_resources_on_mock()
109
+ assert registered["project://state"][1]["mime_type"] == "application/json"
110
+ assert registered["project://canvas/{name}"][1]["mime_type"] == "application/json"
111
+ assert registered["project://canvas/{name}/frame/{index}"][1]["mime_type"] == "image/png"
112
+ assert registered["project://palette"][1]["mime_type"] == "application/json"
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Tests: project://state resource handler
117
+ # ---------------------------------------------------------------------------
118
+
119
+ class TestProjectStateResource:
120
+ async def test_returns_project_overview(self, state_with_project: ServerState):
121
+ """project://state returns project name, dimensions, and canvas info."""
122
+ _, registered = _register_resources_on_mock()
123
+ handler = registered["project://state"][0]
124
+
125
+ with _patch_synced_state(state_with_project):
126
+ result = await handler()
127
+
128
+ data = json.loads(result)
129
+ assert data["name"] == "test-project"
130
+ assert data["width"] == 4
131
+ assert data["height"] == 4
132
+ assert "canvas-0" in data["canvases"]
133
+
134
+ async def test_canvas_info_structure(self, state_with_project: ServerState):
135
+ """Canvas entries contain layers, frame_count, and playback settings."""
136
+ _, registered = _register_resources_on_mock()
137
+ handler = registered["project://state"][0]
138
+
139
+ with _patch_synced_state(state_with_project):
140
+ result = await handler()
141
+
142
+ canvas = json.loads(result)["canvases"]["canvas-0"]
143
+ assert canvas["name"] == "canvas-0"
144
+ assert canvas["width"] == 4
145
+ assert canvas["height"] == 4
146
+ assert canvas["layer_count"] == 1
147
+ assert len(canvas["layers"]) == 1
148
+ assert canvas["frame_count"] == 1
149
+ assert canvas["current_frame_index"] == 0
150
+ assert canvas["global_fps"] == 12.0
151
+
152
+ async def test_layer_info_structure(self, state_with_project: ServerState):
153
+ """Layer entries contain id, name, visible, and opacity."""
154
+ _, registered = _register_resources_on_mock()
155
+ handler = registered["project://state"][0]
156
+
157
+ with _patch_synced_state(state_with_project):
158
+ result = await handler()
159
+
160
+ layer = json.loads(result)["canvases"]["canvas-0"]["layers"][0]
161
+ assert "id" in layer
162
+ assert layer["name"] == "Layer 0"
163
+ assert layer["visible"] is True
164
+ assert layer["opacity"] == 1.0
165
+
166
+ async def test_no_active_project(self):
167
+ """Returns error JSON when no active project exists."""
168
+ empty_state = ServerState()
169
+ _, registered = _register_resources_on_mock()
170
+ handler = registered["project://state"][0]
171
+
172
+ with _patch_synced_state(empty_state):
173
+ result = await handler()
174
+
175
+ data = json.loads(result)
176
+ assert "error" in data
177
+ assert "No active project" in data["error"]
178
+
179
+ async def test_collab_server_unreachable(self):
180
+ """Returns error JSON when the collab server is unreachable."""
181
+ _, registered = _register_resources_on_mock()
182
+ handler = registered["project://state"][0]
183
+
184
+ with patch(
185
+ "pixelweaver.mcp_resources._synced_state",
186
+ new_callable=AsyncMock,
187
+ side_effect=CollabServerUnreachableError("connection refused"),
188
+ ):
189
+ result = await handler()
190
+
191
+ data = json.loads(result)
192
+ assert "error" in data
193
+ assert "Collab server unreachable" in data["error"]
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Tests: project://canvas/{name} resource handler
198
+ # ---------------------------------------------------------------------------
199
+
200
+ class TestCanvasInfoResource:
201
+ async def test_returns_canvas_metadata(self, state_with_project: ServerState):
202
+ """canvas_info returns dimensions, layers, frames, and playback."""
203
+ _, registered = _register_resources_on_mock()
204
+ handler = registered["project://canvas/{name}"][0]
205
+
206
+ with _patch_synced_state(state_with_project):
207
+ result = await handler("canvas-0")
208
+
209
+ data = json.loads(result)
210
+ assert data["name"] == "canvas-0"
211
+ assert data["width"] == 4
212
+ assert data["height"] == 4
213
+ assert len(data["layers"]) == 1
214
+ assert data["frame_count"] == 1
215
+ assert data["current_frame_index"] == 0
216
+ assert data["global_fps"] == 12.0
217
+
218
+ async def test_layer_includes_extra_fields(self, state_with_project: ServerState):
219
+ """Layer entries in canvas_info include locked and blend_mode."""
220
+ _, registered = _register_resources_on_mock()
221
+ handler = registered["project://canvas/{name}"][0]
222
+
223
+ with _patch_synced_state(state_with_project):
224
+ result = await handler("canvas-0")
225
+
226
+ layer = json.loads(result)["layers"][0]
227
+ assert "locked" in layer
228
+ assert "blend_mode" in layer
229
+ assert layer["locked"] is False
230
+ assert layer["blend_mode"] == "normal"
231
+
232
+ async def test_canvas_not_found(self, state_with_project: ServerState):
233
+ """Returns error when canvas name does not exist."""
234
+ _, registered = _register_resources_on_mock()
235
+ handler = registered["project://canvas/{name}"][0]
236
+
237
+ with _patch_synced_state(state_with_project):
238
+ result = await handler("nonexistent")
239
+
240
+ data = json.loads(result)
241
+ assert "error" in data
242
+ assert "not found" in data["error"]
243
+
244
+ async def test_no_active_project(self):
245
+ """Returns error when no active project exists."""
246
+ empty_state = ServerState()
247
+ _, registered = _register_resources_on_mock()
248
+ handler = registered["project://canvas/{name}"][0]
249
+
250
+ with _patch_synced_state(empty_state):
251
+ result = await handler("canvas-0")
252
+
253
+ data = json.loads(result)
254
+ assert "error" in data
255
+ assert "No active project" in data["error"]
256
+
257
+ async def test_collab_server_unreachable(self):
258
+ """Returns error JSON when the collab server is unreachable."""
259
+ _, registered = _register_resources_on_mock()
260
+ handler = registered["project://canvas/{name}"][0]
261
+
262
+ with patch(
263
+ "pixelweaver.mcp_resources._synced_state",
264
+ new_callable=AsyncMock,
265
+ side_effect=CollabServerUnreachableError("timeout"),
266
+ ):
267
+ result = await handler("canvas-0")
268
+
269
+ data = json.loads(result)
270
+ assert "error" in data
271
+ assert "Collab server unreachable" in data["error"]
272
+
273
+
274
+ # ---------------------------------------------------------------------------
275
+ # Tests: project://palette resource handler
276
+ # ---------------------------------------------------------------------------
277
+
278
+ class TestPaletteResource:
279
+ async def test_returns_colors(self, state_with_painted_pixel: ServerState):
280
+ """palette returns sorted hex colors from canvas pixel data."""
281
+ _, registered = _register_resources_on_mock()
282
+ handler = registered["project://palette"][0]
283
+
284
+ with _patch_synced_state(state_with_painted_pixel):
285
+ result = await handler()
286
+
287
+ data = json.loads(result)
288
+ assert "colors" in data
289
+ assert "#ff0000ff" in data["colors"]
290
+
291
+ async def test_empty_canvas_returns_no_colors(self, state_with_project: ServerState):
292
+ """palette returns empty color list for a fully transparent canvas."""
293
+ _, registered = _register_resources_on_mock()
294
+ handler = registered["project://palette"][0]
295
+
296
+ with _patch_synced_state(state_with_project):
297
+ result = await handler()
298
+
299
+ data = json.loads(result)
300
+ assert data["colors"] == []
301
+
302
+ async def test_no_active_project(self):
303
+ """Returns error when no active project."""
304
+ empty_state = ServerState()
305
+ _, registered = _register_resources_on_mock()
306
+ handler = registered["project://palette"][0]
307
+
308
+ with _patch_synced_state(empty_state):
309
+ result = await handler()
310
+
311
+ data = json.loads(result)
312
+ assert "error" in data
313
+ assert "No active project" in data["error"]
314
+
315
+ async def test_no_canvas_in_project(self):
316
+ """Returns error when the project has no canvases."""
317
+ state = ServerState()
318
+ state.create_project("empty-proj", 4, 4)
319
+ # Remove all canvases
320
+ state.projects["empty-proj"].canvases.clear()
321
+
322
+ _, registered = _register_resources_on_mock()
323
+ handler = registered["project://palette"][0]
324
+
325
+ with _patch_synced_state(state):
326
+ result = await handler()
327
+
328
+ data = json.loads(result)
329
+ assert "error" in data
330
+ assert "No canvas" in data["error"]
331
+
332
+ async def test_collab_server_unreachable(self):
333
+ """Returns error JSON when the collab server is unreachable."""
334
+ _, registered = _register_resources_on_mock()
335
+ handler = registered["project://palette"][0]
336
+
337
+ with patch(
338
+ "pixelweaver.mcp_resources._synced_state",
339
+ new_callable=AsyncMock,
340
+ side_effect=CollabServerUnreachableError("down"),
341
+ ):
342
+ result = await handler()
343
+
344
+ data = json.loads(result)
345
+ assert "error" in data
346
+ assert "Collab server unreachable" in data["error"]
347
+
348
+ async def test_max_colors_cap(self, state_with_project: ServerState):
349
+ """palette caps at 256 colors even when more exist."""
350
+ canvas = next(iter(state_with_project.get_active_project().canvases.values()))
351
+ layer_id = canvas.layers[0]["id"]
352
+
353
+ # Create a canvas large enough to hold >256 unique colors.
354
+ # We need at least 257 unique RGBA values with a > 0.
355
+ # Resize canvas to 18x18 = 324 pixels (enough for 257+ unique colors).
356
+ canvas.width = 18
357
+ canvas.height = 18
358
+ big_data = bytearray(18 * 18 * 4)
359
+ for i in range(18 * 18):
360
+ # Generate unique color per pixel (vary R and G, keep A=255)
361
+ r = i % 256
362
+ g = (i // 256) % 256
363
+ big_data[i * 4] = r
364
+ big_data[i * 4 + 1] = g
365
+ big_data[i * 4 + 2] = 0
366
+ big_data[i * 4 + 3] = 255
367
+ canvas.current_frame().pixel_data[layer_id] = bytes(big_data)
368
+
369
+ _, registered = _register_resources_on_mock()
370
+ handler = registered["project://palette"][0]
371
+
372
+ with _patch_synced_state(state_with_project):
373
+ result = await handler()
374
+
375
+ data = json.loads(result)
376
+ assert len(data["colors"]) == 256
377
+
378
+
379
+ # ---------------------------------------------------------------------------
380
+ # Tests: project://canvas/{name}/frame/{index} resource handler
381
+ # ---------------------------------------------------------------------------
382
+
383
+ class TestFramePngResource:
384
+ async def test_returns_png_bytes(self, state_with_project: ServerState):
385
+ """frame_png returns bytes that look like a PNG."""
386
+ _, registered = _register_resources_on_mock()
387
+ handler = registered["project://canvas/{name}/frame/{index}"][0]
388
+
389
+ with _patch_synced_state(state_with_project), patch(
390
+ "pixelweaver.storage.export_frame_png",
391
+ return_value=b"\x89PNG\r\n\x1a\nfake",
392
+ ) as mock_export:
393
+ result = await handler("canvas-0", "0")
394
+
395
+ assert isinstance(result, bytes)
396
+ assert result.startswith(b"\x89PNG")
397
+ mock_export.assert_called_once_with(
398
+ state_with_project.get_active_project(), "canvas-0", 0,
399
+ )
400
+
401
+ async def test_invalid_frame_index_raises(self, state_with_project: ServerState):
402
+ """frame_png raises RuntimeError for non-integer index."""
403
+ _, registered = _register_resources_on_mock()
404
+ handler = registered["project://canvas/{name}/frame/{index}"][0]
405
+
406
+ with _patch_synced_state(state_with_project), pytest.raises(
407
+ RuntimeError, match="Invalid frame index",
408
+ ):
409
+ await handler("canvas-0", "not-a-number")
410
+
411
+ async def test_no_active_project_raises(self):
412
+ """frame_png raises RuntimeError when no active project."""
413
+ empty_state = ServerState()
414
+ _, registered = _register_resources_on_mock()
415
+ handler = registered["project://canvas/{name}/frame/{index}"][0]
416
+
417
+ with _patch_synced_state(empty_state), pytest.raises(
418
+ RuntimeError, match="No active project",
419
+ ):
420
+ await handler("canvas-0", "0")
421
+
422
+ async def test_collab_server_unreachable_raises(self):
423
+ """frame_png raises RuntimeError when collab server is unreachable."""
424
+ _, registered = _register_resources_on_mock()
425
+ handler = registered["project://canvas/{name}/frame/{index}"][0]
426
+
427
+ with patch(
428
+ "pixelweaver.mcp_resources._synced_state",
429
+ new_callable=AsyncMock,
430
+ side_effect=CollabServerUnreachableError("refused"),
431
+ ), pytest.raises(RuntimeError, match="Collab server unreachable"):
432
+ await handler("canvas-0", "0")
433
+
434
+
435
+ # ---------------------------------------------------------------------------
436
+ # Tests: Subscription tracking
437
+ # ---------------------------------------------------------------------------
438
+
439
+ class TestSubscriptionTracking:
440
+ async def test_subscribe_adds_uri(self):
441
+ """Subscribing adds the URI string to the tracked set."""
442
+ from pixelweaver.mcp_resources import _subscribed_uris, init_mcp_subscriptions
443
+
444
+ mcp = MagicMock()
445
+ low_level = MagicMock()
446
+ mcp._mcp_server = low_level
447
+
448
+ # Capture the subscribe handler
449
+ subscribe_handler = None
450
+
451
+ def capture_subscribe():
452
+ def decorator(fn):
453
+ nonlocal subscribe_handler
454
+ subscribe_handler = fn
455
+ return fn
456
+ return decorator
457
+
458
+ low_level.subscribe_resource = capture_subscribe
459
+ low_level.unsubscribe_resource = lambda: lambda fn: fn
460
+ low_level.get_capabilities = MagicMock(return_value=MagicMock(resources=MagicMock(subscribe=False)))
461
+
462
+ init_mcp_subscriptions(mcp)
463
+ assert subscribe_handler is not None
464
+
465
+ uri = AnyUrl("project://state")
466
+ await subscribe_handler(uri)
467
+ assert str(uri) in _subscribed_uris
468
+
469
+ async def test_unsubscribe_removes_uri(self):
470
+ """Unsubscribing removes the URI from the tracked set."""
471
+ from pixelweaver.mcp_resources import _subscribed_uris, init_mcp_subscriptions
472
+
473
+ mcp = MagicMock()
474
+ low_level = MagicMock()
475
+ mcp._mcp_server = low_level
476
+
477
+ # Capture both handlers
478
+ subscribe_handler = None
479
+ unsubscribe_handler = None
480
+
481
+ def capture_subscribe():
482
+ def decorator(fn):
483
+ nonlocal subscribe_handler
484
+ subscribe_handler = fn
485
+ return fn
486
+ return decorator
487
+
488
+ def capture_unsubscribe():
489
+ def decorator(fn):
490
+ nonlocal unsubscribe_handler
491
+ unsubscribe_handler = fn
492
+ return fn
493
+ return decorator
494
+
495
+ low_level.subscribe_resource = capture_subscribe
496
+ low_level.unsubscribe_resource = capture_unsubscribe
497
+ low_level.get_capabilities = MagicMock(return_value=MagicMock(resources=MagicMock(subscribe=False)))
498
+
499
+ init_mcp_subscriptions(mcp)
500
+
501
+ uri = AnyUrl("project://palette")
502
+ await subscribe_handler(uri)
503
+ assert str(uri) in _subscribed_uris
504
+
505
+ await unsubscribe_handler(uri)
506
+ assert str(uri) not in _subscribed_uris
507
+
508
+ async def test_unsubscribe_nonexistent_is_safe(self):
509
+ """Unsubscribing a URI that was never subscribed does not raise."""
510
+ from pixelweaver.mcp_resources import _subscribed_uris, init_mcp_subscriptions
511
+
512
+ mcp = MagicMock()
513
+ low_level = MagicMock()
514
+ mcp._mcp_server = low_level
515
+
516
+ unsubscribe_handler = None
517
+
518
+ def capture_unsubscribe():
519
+ def decorator(fn):
520
+ nonlocal unsubscribe_handler
521
+ unsubscribe_handler = fn
522
+ return fn
523
+ return decorator
524
+
525
+ low_level.subscribe_resource = lambda: lambda fn: fn
526
+ low_level.unsubscribe_resource = capture_unsubscribe
527
+ low_level.get_capabilities = MagicMock(return_value=MagicMock(resources=MagicMock(subscribe=False)))
528
+
529
+ init_mcp_subscriptions(mcp)
530
+
531
+ # Should not raise
532
+ uri = AnyUrl("project://nonexistent")
533
+ await unsubscribe_handler(uri)
534
+ assert len(_subscribed_uris) == 0
535
+
536
+ def test_get_subscribed_uris_returns_frozen_snapshot(self):
537
+ """get_subscribed_uris returns a frozenset copy."""
538
+ from pixelweaver.mcp_resources import _subscribed_uris, get_subscribed_uris
539
+
540
+ _subscribed_uris.add("project://state")
541
+ _subscribed_uris.add("project://palette")
542
+
543
+ snapshot = get_subscribed_uris()
544
+ assert isinstance(snapshot, frozenset)
545
+ assert snapshot == {"project://state", "project://palette"}
546
+
547
+ # Mutations to the module set do not affect the snapshot
548
+ _subscribed_uris.add("project://canvas/foo")
549
+ assert "project://canvas/foo" not in snapshot
550
+
551
+ async def test_capabilities_patched_to_advertise_subscribe(self):
552
+ """init_mcp_subscriptions patches get_capabilities to set subscribe=True."""
553
+ from pixelweaver.mcp_resources import init_mcp_subscriptions
554
+
555
+ mcp = MagicMock()
556
+ low_level = MagicMock()
557
+ mcp._mcp_server = low_level
558
+
559
+ low_level.subscribe_resource = lambda: lambda fn: fn
560
+ low_level.unsubscribe_resource = lambda: lambda fn: fn
561
+
562
+ # Original returns resources with subscribe=False
563
+ resources_cap = MagicMock(subscribe=False)
564
+ original_caps = MagicMock(resources=resources_cap)
565
+ low_level.get_capabilities = MagicMock(return_value=original_caps)
566
+
567
+ init_mcp_subscriptions(mcp)
568
+
569
+ # The patched get_capabilities should set subscribe=True
570
+ caps = low_level.get_capabilities(None, None)
571
+ assert caps.resources.subscribe is True
572
+
573
+
574
+ # ---------------------------------------------------------------------------
575
+ # Tests: notify_resource_subscribers
576
+ # ---------------------------------------------------------------------------
577
+
578
+ class TestNotifyResourceSubscribers:
579
+ async def test_sends_updated_for_each_subscribed_uri(self):
580
+ """notify_resource_subscribers calls send_resource_updated for each URI."""
581
+ from pixelweaver.mcp_resources import _subscribed_uris, notify_resource_subscribers
582
+
583
+ _subscribed_uris.add("project://state")
584
+ _subscribed_uris.add("project://palette")
585
+
586
+ session = AsyncMock()
587
+ context = MagicMock()
588
+ context.session = session
589
+
590
+ mcp = MagicMock()
591
+ mcp._mcp_server.request_context.session = session
592
+
593
+ await notify_resource_subscribers(mcp)
594
+
595
+ assert session.send_resource_updated.call_count == 2
596
+ called_uris = {
597
+ str(call.kwargs["uri"])
598
+ for call in session.send_resource_updated.call_args_list
599
+ }
600
+ assert called_uris == {"project://state", "project://palette"}
601
+
602
+ async def test_no_subscriptions_is_noop(self):
603
+ """notify_resource_subscribers does nothing when no URIs are subscribed."""
604
+ from pixelweaver.mcp_resources import notify_resource_subscribers
605
+
606
+ mcp = MagicMock()
607
+ await notify_resource_subscribers(mcp)
608
+
609
+ # Should not even attempt to access session
610
+ mcp._mcp_server.request_context.session.send_resource_updated.assert_not_called()
611
+
612
+ async def test_handles_missing_request_context(self):
613
+ """notify_resource_subscribers handles LookupError from missing context."""
614
+ from pixelweaver.mcp_resources import _subscribed_uris, notify_resource_subscribers
615
+
616
+ _subscribed_uris.add("project://state")
617
+
618
+ mcp = MagicMock()
619
+ # Simulate the ContextVar raising LookupError
620
+ type(mcp._mcp_server.request_context).session = property(
621
+ lambda self: (_ for _ in ()).throw(LookupError("no context"))
622
+ )
623
+
624
+ # Should not raise
625
+ await notify_resource_subscribers(mcp)
626
+
627
+ async def test_handles_send_failure_gracefully(self):
628
+ """notify_resource_subscribers continues after individual send failures."""
629
+ from pixelweaver.mcp_resources import _subscribed_uris, notify_resource_subscribers
630
+
631
+ _subscribed_uris.add("project://state")
632
+ _subscribed_uris.add("project://palette")
633
+
634
+ session = AsyncMock()
635
+ # First call raises, second succeeds
636
+ session.send_resource_updated.side_effect = [
637
+ Exception("network error"),
638
+ None,
639
+ ]
640
+
641
+ mcp = MagicMock()
642
+ mcp._mcp_server.request_context.session = session
643
+
644
+ # Should not raise despite the first send failing
645
+ await notify_resource_subscribers(mcp)
646
+
647
+ # Both sends were attempted
648
+ assert session.send_resource_updated.call_count == 2