@agent-webui/ai-desk-harness-gimp 1.0.29-beta1

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 (48) hide show
  1. package/README.md +4 -0
  2. package/manifest.json +22 -0
  3. package/package.json +11 -0
  4. package/python/agent-harness/GIMP.md +301 -0
  5. package/python/agent-harness/build/lib/cli_anything/gimp/__init__.py +1 -0
  6. package/python/agent-harness/build/lib/cli_anything/gimp/__main__.py +3 -0
  7. package/python/agent-harness/build/lib/cli_anything/gimp/core/__init__.py +1 -0
  8. package/python/agent-harness/build/lib/cli_anything/gimp/core/canvas.py +193 -0
  9. package/python/agent-harness/build/lib/cli_anything/gimp/core/export.py +479 -0
  10. package/python/agent-harness/build/lib/cli_anything/gimp/core/filters.py +382 -0
  11. package/python/agent-harness/build/lib/cli_anything/gimp/core/layers.py +249 -0
  12. package/python/agent-harness/build/lib/cli_anything/gimp/core/media.py +174 -0
  13. package/python/agent-harness/build/lib/cli_anything/gimp/core/project.py +131 -0
  14. package/python/agent-harness/build/lib/cli_anything/gimp/core/session.py +130 -0
  15. package/python/agent-harness/build/lib/cli_anything/gimp/gimp_cli.py +788 -0
  16. package/python/agent-harness/build/lib/cli_anything/gimp/tests/__init__.py +1 -0
  17. package/python/agent-harness/build/lib/cli_anything/gimp/tests/test_core.py +478 -0
  18. package/python/agent-harness/build/lib/cli_anything/gimp/tests/test_full_e2e.py +578 -0
  19. package/python/agent-harness/build/lib/cli_anything/gimp/utils/__init__.py +1 -0
  20. package/python/agent-harness/build/lib/cli_anything/gimp/utils/gimp_backend.py +208 -0
  21. package/python/agent-harness/build/lib/cli_anything/gimp/utils/repl_skin.py +498 -0
  22. package/python/agent-harness/cli_anything/gimp/README.md +202 -0
  23. package/python/agent-harness/cli_anything/gimp/__init__.py +1 -0
  24. package/python/agent-harness/cli_anything/gimp/__main__.py +3 -0
  25. package/python/agent-harness/cli_anything/gimp/core/__init__.py +1 -0
  26. package/python/agent-harness/cli_anything/gimp/core/canvas.py +193 -0
  27. package/python/agent-harness/cli_anything/gimp/core/export.py +479 -0
  28. package/python/agent-harness/cli_anything/gimp/core/filters.py +382 -0
  29. package/python/agent-harness/cli_anything/gimp/core/layers.py +249 -0
  30. package/python/agent-harness/cli_anything/gimp/core/media.py +174 -0
  31. package/python/agent-harness/cli_anything/gimp/core/project.py +131 -0
  32. package/python/agent-harness/cli_anything/gimp/core/session.py +130 -0
  33. package/python/agent-harness/cli_anything/gimp/gimp_cli.py +788 -0
  34. package/python/agent-harness/cli_anything/gimp/tests/TEST.md +137 -0
  35. package/python/agent-harness/cli_anything/gimp/tests/__init__.py +1 -0
  36. package/python/agent-harness/cli_anything/gimp/tests/test_core.py +478 -0
  37. package/python/agent-harness/cli_anything/gimp/tests/test_full_e2e.py +578 -0
  38. package/python/agent-harness/cli_anything/gimp/utils/__init__.py +1 -0
  39. package/python/agent-harness/cli_anything/gimp/utils/gimp_backend.py +208 -0
  40. package/python/agent-harness/cli_anything/gimp/utils/repl_skin.py +498 -0
  41. package/python/agent-harness/cli_anything_gimp.egg-info/PKG-INFO +236 -0
  42. package/python/agent-harness/cli_anything_gimp.egg-info/SOURCES.txt +25 -0
  43. package/python/agent-harness/cli_anything_gimp.egg-info/dependency_links.txt +1 -0
  44. package/python/agent-harness/cli_anything_gimp.egg-info/entry_points.txt +2 -0
  45. package/python/agent-harness/cli_anything_gimp.egg-info/not-zip-safe +1 -0
  46. package/python/agent-harness/cli_anything_gimp.egg-info/requires.txt +7 -0
  47. package/python/agent-harness/cli_anything_gimp.egg-info/top_level.txt +1 -0
  48. package/python/agent-harness/setup.py +54 -0
@@ -0,0 +1,137 @@
1
+ # GIMP CLI Harness - Test Documentation
2
+
3
+ ## Test Inventory
4
+
5
+ | File | Test Classes | Test Count | Focus |
6
+ |------|-------------|------------|-------|
7
+ | `test_core.py` | 5 | 66 | Unit tests for project, layers, filters, canvas, session |
8
+ | `test_full_e2e.py` | 9 | 37 | E2E workflows with real image I/O and pixel verification |
9
+ | **Total** | **14** | **103** | |
10
+
11
+ ## Unit Tests (`test_core.py`)
12
+
13
+ All unit tests use synthetic/in-memory data only. No external files or disk I/O required.
14
+
15
+ ### TestProject (9 tests)
16
+ - Create project with defaults, custom dimensions, and named profiles
17
+ - Reject invalid color modes and negative/zero dimensions
18
+ - Save to JSON and re-open roundtrip
19
+ - Open nonexistent file raises error
20
+ - Get project info and list available profiles
21
+
22
+ ### TestLayers (19 tests)
23
+ - Add single and multiple layers; add at specific position
24
+ - Reject invalid blend mode and out-of-range opacity
25
+ - Remove layer by index; reject invalid index
26
+ - Duplicate layer, move layer between positions
27
+ - Set properties: opacity, visible, name; reject invalid property
28
+ - Get single layer and list all layers
29
+ - Verify layer IDs are unique across additions
30
+ - Solid color layer and text layer creation
31
+
32
+ ### TestFilters (16 tests)
33
+ - List all available filters; list by category
34
+ - Get filter info; unknown filter raises error
35
+ - Validate filter params with defaults; reject out-of-range values; reject unknown filter
36
+ - Add filter to layer; reject invalid layer index; reject unknown filter name
37
+ - Remove filter from layer
38
+ - Set filter param on existing filter
39
+ - List filters on a layer
40
+ - All registered filters have a valid engine field
41
+
42
+ ### TestCanvas (11 tests)
43
+ - Resize canvas with default and custom anchor
44
+ - Reject invalid (zero/negative) canvas size
45
+ - Scale canvas proportionally
46
+ - Crop canvas; reject out-of-bounds and invalid crop regions
47
+ - Set color mode; reject invalid mode
48
+ - Set DPI
49
+ - Get canvas info returns correct dimensions/mode/DPI
50
+
51
+ ### TestSession (11 tests)
52
+ - Create session; set and get project; get project when none set raises error
53
+ - Undo/redo cycle preserves state
54
+ - Undo on empty stack is no-op; redo on empty stack is no-op
55
+ - New snapshot clears redo stack
56
+ - Session status reports undo/redo depth
57
+ - Save session to file
58
+ - List history entries
59
+ - Max undo limit enforced
60
+
61
+ ## End-to-End Tests (`test_full_e2e.py`)
62
+
63
+ E2E tests use real files: PNG images via PIL/Pillow, numpy arrays for pixel-level verification.
64
+
65
+ ### TestProjectLifecycle (3 tests)
66
+ - Create, save, and open project roundtrip preserving all fields
67
+ - Project with layers survives save/load roundtrip
68
+ - Project info reflects accurate layer counts after additions
69
+
70
+ ### TestLayerOperations (2 tests)
71
+ - Add layer from a real image file (PIL Image saved to temp file)
72
+ - Multiple layers maintain correct ordering
73
+
74
+ ### TestFilterRendering (7 tests)
75
+ - Brightness filter increases pixel values (verified with numpy mean)
76
+ - Contrast filter increases pixel spread (verified with numpy std)
77
+ - Invert filter flips all color values
78
+ - Gaussian blur reduces high-frequency content
79
+ - Sepia filter applies correct color cast
80
+ - Multiple filters chain together correctly
81
+ - Horizontal flip mirror-reverses pixel columns
82
+
83
+ ### TestExportFormats (4 tests)
84
+ - Export to JPEG produces valid JPEG file
85
+ - Export to WebP produces valid WebP file
86
+ - Export to BMP produces valid BMP file
87
+ - Overwrite protection prevents clobbering existing files
88
+
89
+ ### TestBlendModes (3 tests)
90
+ - Multiply mode darkens output compared to base layer
91
+ - Screen mode brightens output compared to base layer
92
+ - Difference mode produces expected pixel delta
93
+
94
+ ### TestCanvasRendering (1 test)
95
+ - Scale canvas and export; verify output image dimensions match
96
+
97
+ ### TestMediaProbing (5 tests)
98
+ - Probe PNG file returns correct dimensions and format
99
+ - Probe JPEG file returns correct info
100
+ - Probe nonexistent file raises error
101
+ - Check media reports all files present
102
+ - Check media reports missing files
103
+
104
+ ### TestSessionIntegration (2 tests)
105
+ - Undo reverses a layer addition
106
+ - Undo reverses a filter addition
107
+
108
+ ### TestCLISubprocess (7 tests)
109
+ - `--help` prints usage info
110
+ - `project new` creates a project
111
+ - `project new --json` returns valid JSON output
112
+ - `project profiles` lists available profiles
113
+ - `filter list-available` lists all filters
114
+ - `export presets` lists export presets
115
+ - Full workflow via JSON CLI (create, add layer, add filter, export)
116
+
117
+ ### TestRealWorldWorkflows (5 tests)
118
+ - Photo editing workflow: open image, adjust brightness/contrast, apply sharpen, export
119
+ - Collage workflow: create canvas, add multiple image layers, position them, export
120
+ - Text overlay workflow: add text layer over image, style it, export
121
+ - Batch filter workflow: apply same filter chain to multiple layers
122
+ - Save and load complex project with many layers, filters, and settings
123
+
124
+ ## Test Results
125
+
126
+ ```
127
+ ============================= test session starts ==============================
128
+ platform linux -- Python 3.13.11, pytest-9.0.2, pluggy-1.5.0
129
+ rootdir: /root/cli-anything
130
+ plugins: langsmith-0.5.1, anyio-4.12.0
131
+ collected 103 items
132
+
133
+ test_core.py 66 passed
134
+ test_full_e2e.py 37 passed
135
+
136
+ ============================= 103 passed in 3.05s ==============================
137
+ ```
@@ -0,0 +1 @@
1
+ """GIMP CLI - Tests package."""
@@ -0,0 +1,478 @@
1
+ """Unit tests for GIMP CLI core modules.
2
+
3
+ Tests use synthetic data only — no real images or external dependencies.
4
+ """
5
+
6
+ import json
7
+ import os
8
+ import sys
9
+ import tempfile
10
+ import pytest
11
+
12
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
13
+
14
+ from cli_anything.gimp.core.project import create_project, open_project, save_project, get_project_info, list_profiles
15
+ from cli_anything.gimp.core.layers import (
16
+ add_layer, add_from_file, remove_layer, duplicate_layer, move_layer,
17
+ set_layer_property, get_layer, list_layers, BLEND_MODES,
18
+ )
19
+ from cli_anything.gimp.core.filters import (
20
+ list_available, get_filter_info, validate_params, add_filter,
21
+ remove_filter, set_filter_param, list_filters, FILTER_REGISTRY,
22
+ )
23
+ from cli_anything.gimp.core.canvas import (
24
+ resize_canvas, scale_canvas, crop_canvas, set_mode, set_dpi, get_canvas_info,
25
+ )
26
+ from cli_anything.gimp.core.session import Session
27
+
28
+
29
+ # ── Project Tests ────────────────────────────────────────────────
30
+
31
+ class TestProject:
32
+ def test_create_default(self):
33
+ proj = create_project()
34
+ assert proj["canvas"]["width"] == 1920
35
+ assert proj["canvas"]["height"] == 1080
36
+ assert proj["canvas"]["color_mode"] == "RGB"
37
+ assert proj["version"] == "1.0"
38
+
39
+ def test_create_with_dimensions(self):
40
+ proj = create_project(width=800, height=600, dpi=150)
41
+ assert proj["canvas"]["width"] == 800
42
+ assert proj["canvas"]["height"] == 600
43
+ assert proj["canvas"]["dpi"] == 150
44
+
45
+ def test_create_with_profile(self):
46
+ proj = create_project(profile="hd720p")
47
+ assert proj["canvas"]["width"] == 1280
48
+ assert proj["canvas"]["height"] == 720
49
+
50
+ def test_create_invalid_mode(self):
51
+ with pytest.raises(ValueError, match="Invalid color mode"):
52
+ create_project(color_mode="XYZ")
53
+
54
+ def test_create_invalid_dimensions(self):
55
+ with pytest.raises(ValueError, match="must be positive"):
56
+ create_project(width=0, height=100)
57
+
58
+ def test_save_and_open(self):
59
+ proj = create_project(name="test_project")
60
+ with tempfile.NamedTemporaryFile(suffix=".json", delete=False, mode="w") as f:
61
+ path = f.name
62
+ try:
63
+ save_project(proj, path)
64
+ loaded = open_project(path)
65
+ assert loaded["name"] == "test_project"
66
+ assert loaded["canvas"]["width"] == 1920
67
+ finally:
68
+ os.unlink(path)
69
+
70
+ def test_open_nonexistent(self):
71
+ with pytest.raises(FileNotFoundError):
72
+ open_project("/nonexistent/path.json")
73
+
74
+ def test_get_info(self):
75
+ proj = create_project(name="info_test")
76
+ info = get_project_info(proj)
77
+ assert info["name"] == "info_test"
78
+ assert info["layer_count"] == 0
79
+ assert "canvas" in info
80
+
81
+ def test_list_profiles(self):
82
+ profiles = list_profiles()
83
+ assert len(profiles) > 0
84
+ names = [p["name"] for p in profiles]
85
+ assert "hd1080p" in names
86
+ assert "4k" in names
87
+
88
+
89
+ # ── Layer Tests ──────────────────────────────────────────────────
90
+
91
+ class TestLayers:
92
+ def _make_project(self):
93
+ return create_project()
94
+
95
+ def test_add_layer(self):
96
+ proj = self._make_project()
97
+ layer = add_layer(proj, name="Test", layer_type="image")
98
+ assert layer["name"] == "Test"
99
+ assert layer["type"] == "image"
100
+ assert len(proj["layers"]) == 1
101
+
102
+ def test_add_multiple_layers(self):
103
+ proj = self._make_project()
104
+ add_layer(proj, name="Bottom")
105
+ add_layer(proj, name="Top")
106
+ assert len(proj["layers"]) == 2
107
+ assert proj["layers"][0]["name"] == "Top" # Top of stack
108
+
109
+ def test_add_layer_with_position(self):
110
+ proj = self._make_project()
111
+ add_layer(proj, name="First")
112
+ add_layer(proj, name="Second", position=1)
113
+ assert proj["layers"][1]["name"] == "Second"
114
+
115
+ def test_add_layer_invalid_mode(self):
116
+ proj = self._make_project()
117
+ with pytest.raises(ValueError, match="Invalid blend mode"):
118
+ add_layer(proj, blend_mode="invalid")
119
+
120
+ def test_add_layer_invalid_opacity(self):
121
+ proj = self._make_project()
122
+ with pytest.raises(ValueError, match="Opacity"):
123
+ add_layer(proj, opacity=1.5)
124
+
125
+ def test_remove_layer(self):
126
+ proj = self._make_project()
127
+ add_layer(proj, name="A")
128
+ add_layer(proj, name="B")
129
+ removed = remove_layer(proj, 0)
130
+ assert removed["name"] == "B"
131
+ assert len(proj["layers"]) == 1
132
+
133
+ def test_remove_layer_invalid_index(self):
134
+ proj = self._make_project()
135
+ with pytest.raises(ValueError, match="No layers"):
136
+ remove_layer(proj, 0)
137
+
138
+ def test_duplicate_layer(self):
139
+ proj = self._make_project()
140
+ add_layer(proj, name="Original")
141
+ dup = duplicate_layer(proj, 0)
142
+ assert dup["name"] == "Original copy"
143
+ assert len(proj["layers"]) == 2
144
+
145
+ def test_move_layer(self):
146
+ proj = self._make_project()
147
+ add_layer(proj, name="A")
148
+ add_layer(proj, name="B")
149
+ add_layer(proj, name="C")
150
+ move_layer(proj, 0, 2)
151
+ assert proj["layers"][2]["name"] == "C"
152
+
153
+ def test_set_property_opacity(self):
154
+ proj = self._make_project()
155
+ add_layer(proj, name="Test")
156
+ set_layer_property(proj, 0, "opacity", 0.5)
157
+ assert proj["layers"][0]["opacity"] == 0.5
158
+
159
+ def test_set_property_visible(self):
160
+ proj = self._make_project()
161
+ add_layer(proj, name="Test")
162
+ set_layer_property(proj, 0, "visible", "false")
163
+ assert proj["layers"][0]["visible"] is False
164
+
165
+ def test_set_property_name(self):
166
+ proj = self._make_project()
167
+ add_layer(proj, name="Old")
168
+ set_layer_property(proj, 0, "name", "New")
169
+ assert proj["layers"][0]["name"] == "New"
170
+
171
+ def test_set_property_invalid(self):
172
+ proj = self._make_project()
173
+ add_layer(proj, name="Test")
174
+ with pytest.raises(ValueError, match="Unknown property"):
175
+ set_layer_property(proj, 0, "bogus", "value")
176
+
177
+ def test_get_layer(self):
178
+ proj = self._make_project()
179
+ add_layer(proj, name="Test")
180
+ layer = get_layer(proj, 0)
181
+ assert layer["name"] == "Test"
182
+
183
+ def test_list_layers(self):
184
+ proj = self._make_project()
185
+ add_layer(proj, name="A")
186
+ add_layer(proj, name="B")
187
+ result = list_layers(proj)
188
+ assert len(result) == 2
189
+ assert result[0]["name"] == "B"
190
+
191
+ def test_layer_ids_unique(self):
192
+ proj = self._make_project()
193
+ l1 = add_layer(proj, name="A")
194
+ l2 = add_layer(proj, name="B")
195
+ assert l1["id"] != l2["id"]
196
+
197
+ def test_solid_layer(self):
198
+ proj = self._make_project()
199
+ layer = add_layer(proj, name="Red", layer_type="solid", fill="#ff0000")
200
+ assert layer["type"] == "solid"
201
+ assert layer["fill"] == "#ff0000"
202
+
203
+ def test_text_layer(self):
204
+ proj = self._make_project()
205
+ layer = add_layer(proj, name="Title", layer_type="text")
206
+ assert layer["type"] == "text"
207
+ assert "text" in layer
208
+ assert "font_size" in layer
209
+
210
+
211
+ # ── Filter Tests ─────────────────────────────────────────────────
212
+
213
+ class TestFilters:
214
+ def _make_project_with_layer(self):
215
+ proj = create_project()
216
+ add_layer(proj, name="Test")
217
+ return proj
218
+
219
+ def test_list_available(self):
220
+ filters = list_available()
221
+ assert len(filters) > 10
222
+ names = [f["name"] for f in filters]
223
+ assert "brightness" in names
224
+ assert "gaussian_blur" in names
225
+
226
+ def test_list_by_category(self):
227
+ blurs = list_available(category="blur")
228
+ assert all(f["category"] == "blur" for f in blurs)
229
+ assert len(blurs) >= 3
230
+
231
+ def test_get_filter_info(self):
232
+ info = get_filter_info("brightness")
233
+ assert info["name"] == "brightness"
234
+ assert "factor" in info["params"]
235
+
236
+ def test_get_filter_info_unknown(self):
237
+ with pytest.raises(ValueError, match="Unknown filter"):
238
+ get_filter_info("nonexistent")
239
+
240
+ def test_validate_params(self):
241
+ params = validate_params("brightness", {"factor": 1.5})
242
+ assert params["factor"] == 1.5
243
+
244
+ def test_validate_params_defaults(self):
245
+ params = validate_params("brightness", {})
246
+ assert params["factor"] == 1.0
247
+
248
+ def test_validate_params_out_of_range(self):
249
+ with pytest.raises(ValueError, match="maximum"):
250
+ validate_params("brightness", {"factor": 100.0})
251
+
252
+ def test_validate_params_unknown(self):
253
+ with pytest.raises(ValueError, match="Unknown parameters"):
254
+ validate_params("brightness", {"bogus": 1.0})
255
+
256
+ def test_add_filter(self):
257
+ proj = self._make_project_with_layer()
258
+ result = add_filter(proj, "brightness", 0, {"factor": 1.2})
259
+ assert result["name"] == "brightness"
260
+ assert proj["layers"][0]["filters"][0]["name"] == "brightness"
261
+
262
+ def test_add_filter_invalid_layer(self):
263
+ proj = self._make_project_with_layer()
264
+ with pytest.raises(IndexError):
265
+ add_filter(proj, "brightness", 5, {})
266
+
267
+ def test_add_filter_unknown(self):
268
+ proj = self._make_project_with_layer()
269
+ with pytest.raises(ValueError, match="Unknown filter"):
270
+ add_filter(proj, "nonexistent", 0, {})
271
+
272
+ def test_remove_filter(self):
273
+ proj = self._make_project_with_layer()
274
+ add_filter(proj, "brightness", 0, {"factor": 1.2})
275
+ removed = remove_filter(proj, 0, 0)
276
+ assert removed["name"] == "brightness"
277
+ assert len(proj["layers"][0]["filters"]) == 0
278
+
279
+ def test_set_filter_param(self):
280
+ proj = self._make_project_with_layer()
281
+ add_filter(proj, "brightness", 0, {"factor": 1.0})
282
+ set_filter_param(proj, 0, "factor", 1.5, 0)
283
+ assert proj["layers"][0]["filters"][0]["params"]["factor"] == 1.5
284
+
285
+ def test_list_filters(self):
286
+ proj = self._make_project_with_layer()
287
+ add_filter(proj, "brightness", 0, {"factor": 1.2})
288
+ add_filter(proj, "contrast", 0, {"factor": 1.1})
289
+ result = list_filters(proj, 0)
290
+ assert len(result) == 2
291
+ assert result[0]["name"] == "brightness"
292
+ assert result[1]["name"] == "contrast"
293
+
294
+ def test_all_filters_have_valid_engine(self):
295
+ valid_engines = {"pillow_enhance", "pillow_ops", "pillow_filter",
296
+ "pillow_transform", "custom"}
297
+ for name, spec in FILTER_REGISTRY.items():
298
+ assert spec["engine"] in valid_engines, f"Filter '{name}' has invalid engine"
299
+
300
+
301
+ # ── Canvas Tests ─────────────────────────────────────────────────
302
+
303
+ class TestCanvas:
304
+ def _make_project(self):
305
+ return create_project(width=800, height=600)
306
+
307
+ def test_resize_canvas(self):
308
+ proj = self._make_project()
309
+ result = resize_canvas(proj, 1000, 800)
310
+ assert proj["canvas"]["width"] == 1000
311
+ assert proj["canvas"]["height"] == 800
312
+ assert "old_size" in result
313
+
314
+ def test_resize_canvas_with_anchor(self):
315
+ proj = self._make_project()
316
+ add_layer(proj, name="Test")
317
+ resize_canvas(proj, 1000, 800, anchor="top-left")
318
+ assert proj["layers"][0]["offset_x"] == 0
319
+ assert proj["layers"][0]["offset_y"] == 0
320
+
321
+ def test_resize_canvas_invalid_size(self):
322
+ proj = self._make_project()
323
+ with pytest.raises(ValueError, match="must be positive"):
324
+ resize_canvas(proj, 0, 100)
325
+
326
+ def test_scale_canvas(self):
327
+ proj = self._make_project()
328
+ add_layer(proj, name="Test", width=800, height=600)
329
+ result = scale_canvas(proj, 400, 300)
330
+ assert proj["canvas"]["width"] == 400
331
+ assert proj["canvas"]["height"] == 300
332
+ assert proj["layers"][0]["width"] == 400
333
+ assert proj["layers"][0]["height"] == 300
334
+
335
+ def test_crop_canvas(self):
336
+ proj = self._make_project()
337
+ result = crop_canvas(proj, 100, 100, 500, 400)
338
+ assert proj["canvas"]["width"] == 400
339
+ assert proj["canvas"]["height"] == 300
340
+
341
+ def test_crop_canvas_out_of_bounds(self):
342
+ proj = self._make_project()
343
+ with pytest.raises(ValueError, match="exceeds canvas"):
344
+ crop_canvas(proj, 0, 0, 1000, 1000)
345
+
346
+ def test_crop_canvas_invalid_region(self):
347
+ proj = self._make_project()
348
+ with pytest.raises(ValueError, match="Invalid crop"):
349
+ crop_canvas(proj, 500, 500, 100, 100)
350
+
351
+ def test_set_mode(self):
352
+ proj = self._make_project()
353
+ result = set_mode(proj, "RGBA")
354
+ assert proj["canvas"]["color_mode"] == "RGBA"
355
+ assert result["old_mode"] == "RGB"
356
+
357
+ def test_set_mode_invalid(self):
358
+ proj = self._make_project()
359
+ with pytest.raises(ValueError, match="Invalid color mode"):
360
+ set_mode(proj, "XYZ")
361
+
362
+ def test_set_dpi(self):
363
+ proj = self._make_project()
364
+ result = set_dpi(proj, 300)
365
+ assert proj["canvas"]["dpi"] == 300
366
+
367
+ def test_get_canvas_info(self):
368
+ proj = self._make_project()
369
+ info = get_canvas_info(proj)
370
+ assert info["width"] == 800
371
+ assert info["height"] == 600
372
+ assert "megapixels" in info
373
+
374
+
375
+ # ── Session Tests ────────────────────────────────────────────────
376
+
377
+ class TestSession:
378
+ def test_create_session(self):
379
+ sess = Session()
380
+ assert not sess.has_project()
381
+
382
+ def test_set_project(self):
383
+ sess = Session()
384
+ proj = create_project()
385
+ sess.set_project(proj)
386
+ assert sess.has_project()
387
+
388
+ def test_get_project_no_project(self):
389
+ sess = Session()
390
+ with pytest.raises(RuntimeError, match="No project loaded"):
391
+ sess.get_project()
392
+
393
+ def test_undo_redo(self):
394
+ sess = Session()
395
+ proj = create_project(name="original")
396
+ sess.set_project(proj)
397
+
398
+ sess.snapshot("change name")
399
+ proj["name"] = "modified"
400
+
401
+ assert proj["name"] == "modified"
402
+ sess.undo()
403
+ assert sess.get_project()["name"] == "original"
404
+ sess.redo()
405
+ assert sess.get_project()["name"] == "modified"
406
+
407
+ def test_undo_empty(self):
408
+ sess = Session()
409
+ sess.set_project(create_project())
410
+ with pytest.raises(RuntimeError, match="Nothing to undo"):
411
+ sess.undo()
412
+
413
+ def test_redo_empty(self):
414
+ sess = Session()
415
+ sess.set_project(create_project())
416
+ with pytest.raises(RuntimeError, match="Nothing to redo"):
417
+ sess.redo()
418
+
419
+ def test_snapshot_clears_redo(self):
420
+ sess = Session()
421
+ proj = create_project(name="v1")
422
+ sess.set_project(proj)
423
+
424
+ sess.snapshot("v2")
425
+ proj["name"] = "v2"
426
+
427
+ sess.undo()
428
+ assert sess.get_project()["name"] == "v1"
429
+
430
+ # New snapshot should clear redo stack
431
+ sess.snapshot("v3")
432
+ sess.get_project()["name"] = "v3"
433
+
434
+ with pytest.raises(RuntimeError, match="Nothing to redo"):
435
+ sess.redo()
436
+
437
+ def test_status(self):
438
+ sess = Session()
439
+ proj = create_project(name="test")
440
+ sess.set_project(proj, "/tmp/test.json")
441
+ status = sess.status()
442
+ assert status["has_project"] is True
443
+ assert status["project_path"] == "/tmp/test.json"
444
+ assert status["undo_count"] == 0
445
+
446
+ def test_save_session(self):
447
+ sess = Session()
448
+ proj = create_project(name="save_test")
449
+ with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
450
+ path = f.name
451
+ try:
452
+ sess.set_project(proj, path)
453
+ saved = sess.save_session()
454
+ assert os.path.exists(saved)
455
+ with open(saved) as f:
456
+ loaded = json.load(f)
457
+ assert loaded["name"] == "save_test"
458
+ finally:
459
+ os.unlink(path)
460
+
461
+ def test_list_history(self):
462
+ sess = Session()
463
+ proj = create_project()
464
+ sess.set_project(proj)
465
+ sess.snapshot("action 1")
466
+ sess.snapshot("action 2")
467
+ history = sess.list_history()
468
+ assert len(history) == 2
469
+ assert history[0]["description"] == "action 2"
470
+
471
+ def test_max_undo(self):
472
+ sess = Session()
473
+ sess.MAX_UNDO = 5
474
+ proj = create_project()
475
+ sess.set_project(proj)
476
+ for i in range(10):
477
+ sess.snapshot(f"action {i}")
478
+ assert len(sess._undo_stack) == 5