@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.
- package/README.md +4 -0
- package/manifest.json +22 -0
- package/package.json +11 -0
- package/python/agent-harness/GIMP.md +301 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/__init__.py +1 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/__main__.py +3 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/core/__init__.py +1 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/core/canvas.py +193 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/core/export.py +479 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/core/filters.py +382 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/core/layers.py +249 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/core/media.py +174 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/core/project.py +131 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/core/session.py +130 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/gimp_cli.py +788 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/tests/__init__.py +1 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/tests/test_core.py +478 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/tests/test_full_e2e.py +578 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/utils/__init__.py +1 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/utils/gimp_backend.py +208 -0
- package/python/agent-harness/build/lib/cli_anything/gimp/utils/repl_skin.py +498 -0
- package/python/agent-harness/cli_anything/gimp/README.md +202 -0
- package/python/agent-harness/cli_anything/gimp/__init__.py +1 -0
- package/python/agent-harness/cli_anything/gimp/__main__.py +3 -0
- package/python/agent-harness/cli_anything/gimp/core/__init__.py +1 -0
- package/python/agent-harness/cli_anything/gimp/core/canvas.py +193 -0
- package/python/agent-harness/cli_anything/gimp/core/export.py +479 -0
- package/python/agent-harness/cli_anything/gimp/core/filters.py +382 -0
- package/python/agent-harness/cli_anything/gimp/core/layers.py +249 -0
- package/python/agent-harness/cli_anything/gimp/core/media.py +174 -0
- package/python/agent-harness/cli_anything/gimp/core/project.py +131 -0
- package/python/agent-harness/cli_anything/gimp/core/session.py +130 -0
- package/python/agent-harness/cli_anything/gimp/gimp_cli.py +788 -0
- package/python/agent-harness/cli_anything/gimp/tests/TEST.md +137 -0
- package/python/agent-harness/cli_anything/gimp/tests/__init__.py +1 -0
- package/python/agent-harness/cli_anything/gimp/tests/test_core.py +478 -0
- package/python/agent-harness/cli_anything/gimp/tests/test_full_e2e.py +578 -0
- package/python/agent-harness/cli_anything/gimp/utils/__init__.py +1 -0
- package/python/agent-harness/cli_anything/gimp/utils/gimp_backend.py +208 -0
- package/python/agent-harness/cli_anything/gimp/utils/repl_skin.py +498 -0
- package/python/agent-harness/cli_anything_gimp.egg-info/PKG-INFO +236 -0
- package/python/agent-harness/cli_anything_gimp.egg-info/SOURCES.txt +25 -0
- package/python/agent-harness/cli_anything_gimp.egg-info/dependency_links.txt +1 -0
- package/python/agent-harness/cli_anything_gimp.egg-info/entry_points.txt +2 -0
- package/python/agent-harness/cli_anything_gimp.egg-info/not-zip-safe +1 -0
- package/python/agent-harness/cli_anything_gimp.egg-info/requires.txt +7 -0
- package/python/agent-harness/cli_anything_gimp.egg-info/top_level.txt +1 -0
- package/python/agent-harness/setup.py +54 -0
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
"""End-to-end tests for GIMP CLI with real images.
|
|
2
|
+
|
|
3
|
+
These tests create actual images, apply filters, and verify pixel-level results.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import subprocess
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
|
14
|
+
|
|
15
|
+
from PIL import Image, ImageDraw
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from cli_anything.gimp.core.project import create_project, save_project, open_project, get_project_info
|
|
19
|
+
from cli_anything.gimp.core.layers import add_layer, add_from_file, list_layers, remove_layer
|
|
20
|
+
from cli_anything.gimp.core.filters import add_filter, list_filters
|
|
21
|
+
from cli_anything.gimp.core.canvas import resize_canvas, scale_canvas, crop_canvas, set_mode
|
|
22
|
+
from cli_anything.gimp.core.media import probe_image, check_media
|
|
23
|
+
from cli_anything.gimp.core.export import render
|
|
24
|
+
from cli_anything.gimp.core.session import Session
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def tmp_dir():
|
|
29
|
+
with tempfile.TemporaryDirectory() as d:
|
|
30
|
+
yield d
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture
|
|
34
|
+
def sample_image(tmp_dir):
|
|
35
|
+
"""Create a simple test image (red/green/blue stripes)."""
|
|
36
|
+
img = Image.new("RGB", (300, 200))
|
|
37
|
+
draw = ImageDraw.Draw(img)
|
|
38
|
+
draw.rectangle([0, 0, 100, 200], fill=(255, 0, 0)) # Red stripe
|
|
39
|
+
draw.rectangle([100, 0, 200, 200], fill=(0, 255, 0)) # Green stripe
|
|
40
|
+
draw.rectangle([200, 0, 300, 200], fill=(0, 0, 255)) # Blue stripe
|
|
41
|
+
path = os.path.join(tmp_dir, "test_image.png")
|
|
42
|
+
img.save(path)
|
|
43
|
+
return path
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.fixture
|
|
47
|
+
def gradient_image(tmp_dir):
|
|
48
|
+
"""Create a gradient test image (black to white horizontal)."""
|
|
49
|
+
img = Image.new("L", (256, 100))
|
|
50
|
+
for x in range(256):
|
|
51
|
+
for y in range(100):
|
|
52
|
+
img.putpixel((x, y), x)
|
|
53
|
+
path = os.path.join(tmp_dir, "gradient.png")
|
|
54
|
+
img.save(path)
|
|
55
|
+
return path
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ── Project Lifecycle ────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
class TestProjectLifecycle:
|
|
61
|
+
def test_create_save_open_roundtrip(self, tmp_dir):
|
|
62
|
+
proj = create_project(name="roundtrip")
|
|
63
|
+
path = os.path.join(tmp_dir, "project.gimp-cli.json")
|
|
64
|
+
save_project(proj, path)
|
|
65
|
+
loaded = open_project(path)
|
|
66
|
+
assert loaded["name"] == "roundtrip"
|
|
67
|
+
assert loaded["canvas"]["width"] == 1920
|
|
68
|
+
|
|
69
|
+
def test_project_with_layers_roundtrip(self, tmp_dir, sample_image):
|
|
70
|
+
proj = create_project(name="with_layers")
|
|
71
|
+
add_from_file(proj, sample_image, name="Photo")
|
|
72
|
+
add_filter(proj, "brightness", 0, {"factor": 1.3})
|
|
73
|
+
path = os.path.join(tmp_dir, "project.json")
|
|
74
|
+
save_project(proj, path)
|
|
75
|
+
loaded = open_project(path)
|
|
76
|
+
assert len(loaded["layers"]) == 1
|
|
77
|
+
assert loaded["layers"][0]["filters"][0]["name"] == "brightness"
|
|
78
|
+
|
|
79
|
+
def test_project_info_with_layers(self, sample_image):
|
|
80
|
+
proj = create_project()
|
|
81
|
+
add_from_file(proj, sample_image)
|
|
82
|
+
info = get_project_info(proj)
|
|
83
|
+
assert info["layer_count"] == 1
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ── Layer Operations ─────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
class TestLayerOperations:
|
|
89
|
+
def test_add_from_file(self, sample_image):
|
|
90
|
+
proj = create_project()
|
|
91
|
+
layer = add_from_file(proj, sample_image)
|
|
92
|
+
assert layer["source"] == os.path.abspath(sample_image)
|
|
93
|
+
assert layer["width"] == 300
|
|
94
|
+
assert layer["height"] == 200
|
|
95
|
+
|
|
96
|
+
def test_multiple_layers_order(self, tmp_dir):
|
|
97
|
+
img1 = Image.new("RGB", (100, 100), "red")
|
|
98
|
+
img2 = Image.new("RGB", (100, 100), "blue")
|
|
99
|
+
p1 = os.path.join(tmp_dir, "red.png")
|
|
100
|
+
p2 = os.path.join(tmp_dir, "blue.png")
|
|
101
|
+
img1.save(p1)
|
|
102
|
+
img2.save(p2)
|
|
103
|
+
|
|
104
|
+
proj = create_project(width=100, height=100)
|
|
105
|
+
add_from_file(proj, p1, name="Red")
|
|
106
|
+
add_from_file(proj, p2, name="Blue")
|
|
107
|
+
layers = list_layers(proj)
|
|
108
|
+
assert layers[0]["name"] == "Blue" # Top
|
|
109
|
+
assert layers[1]["name"] == "Red" # Bottom
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ── Filter Rendering ─────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
class TestFilterRendering:
|
|
115
|
+
def test_brightness_increases_pixels(self, tmp_dir, gradient_image):
|
|
116
|
+
proj = create_project(width=256, height=100, color_mode="RGB")
|
|
117
|
+
add_from_file(proj, gradient_image)
|
|
118
|
+
add_filter(proj, "brightness", 0, {"factor": 1.5})
|
|
119
|
+
out = os.path.join(tmp_dir, "bright.png")
|
|
120
|
+
render(proj, out, preset="png", overwrite=True)
|
|
121
|
+
|
|
122
|
+
original = np.array(Image.open(gradient_image).convert("RGB"), dtype=float)
|
|
123
|
+
result = np.array(Image.open(out).convert("RGB"), dtype=float)
|
|
124
|
+
assert result.mean() > original.mean()
|
|
125
|
+
|
|
126
|
+
def test_contrast_increases_spread(self, tmp_dir, gradient_image):
|
|
127
|
+
proj = create_project(width=256, height=100, color_mode="RGB")
|
|
128
|
+
add_from_file(proj, gradient_image)
|
|
129
|
+
add_filter(proj, "contrast", 0, {"factor": 2.0})
|
|
130
|
+
out = os.path.join(tmp_dir, "contrast.png")
|
|
131
|
+
render(proj, out, preset="png", overwrite=True)
|
|
132
|
+
|
|
133
|
+
result = np.array(Image.open(out).convert("L"), dtype=float)
|
|
134
|
+
original = np.array(Image.open(gradient_image), dtype=float)
|
|
135
|
+
# Higher contrast = larger std deviation
|
|
136
|
+
assert result.std() >= original.std() * 0.9
|
|
137
|
+
|
|
138
|
+
def test_invert_flips_colors(self, tmp_dir, sample_image):
|
|
139
|
+
proj = create_project(width=300, height=200)
|
|
140
|
+
add_from_file(proj, sample_image)
|
|
141
|
+
add_filter(proj, "invert", 0, {})
|
|
142
|
+
out = os.path.join(tmp_dir, "inverted.png")
|
|
143
|
+
render(proj, out, preset="png", overwrite=True)
|
|
144
|
+
|
|
145
|
+
original = np.array(Image.open(sample_image).convert("RGB"), dtype=float)
|
|
146
|
+
result = np.array(Image.open(out).convert("RGB"), dtype=float)
|
|
147
|
+
# Inverted + original should sum to ~255
|
|
148
|
+
total = original + result
|
|
149
|
+
assert abs(total.mean() - 255.0) < 5.0
|
|
150
|
+
|
|
151
|
+
def test_gaussian_blur(self, tmp_dir, sample_image):
|
|
152
|
+
proj = create_project(width=300, height=200)
|
|
153
|
+
add_from_file(proj, sample_image)
|
|
154
|
+
add_filter(proj, "gaussian_blur", 0, {"radius": 10.0})
|
|
155
|
+
out = os.path.join(tmp_dir, "blurred.png")
|
|
156
|
+
render(proj, out, preset="png", overwrite=True)
|
|
157
|
+
|
|
158
|
+
result = Image.open(out)
|
|
159
|
+
assert result.size == (300, 200)
|
|
160
|
+
|
|
161
|
+
def test_sepia_applies(self, tmp_dir, sample_image):
|
|
162
|
+
proj = create_project(width=300, height=200)
|
|
163
|
+
add_from_file(proj, sample_image)
|
|
164
|
+
add_filter(proj, "sepia", 0, {"strength": 1.0})
|
|
165
|
+
out = os.path.join(tmp_dir, "sepia.png")
|
|
166
|
+
render(proj, out, preset="png", overwrite=True)
|
|
167
|
+
|
|
168
|
+
result = np.array(Image.open(out).convert("RGB"), dtype=float)
|
|
169
|
+
r, g, b = result[:,:,0].mean(), result[:,:,1].mean(), result[:,:,2].mean()
|
|
170
|
+
# Sepia: R > G > B
|
|
171
|
+
assert r >= g >= b
|
|
172
|
+
|
|
173
|
+
def test_multiple_filters_chain(self, tmp_dir, sample_image):
|
|
174
|
+
proj = create_project(width=300, height=200)
|
|
175
|
+
add_from_file(proj, sample_image)
|
|
176
|
+
add_filter(proj, "brightness", 0, {"factor": 1.2})
|
|
177
|
+
add_filter(proj, "contrast", 0, {"factor": 1.3})
|
|
178
|
+
add_filter(proj, "saturation", 0, {"factor": 0.5})
|
|
179
|
+
out = os.path.join(tmp_dir, "multi.png")
|
|
180
|
+
render(proj, out, preset="png", overwrite=True)
|
|
181
|
+
assert os.path.exists(out)
|
|
182
|
+
|
|
183
|
+
def test_flip_horizontal(self, tmp_dir, sample_image):
|
|
184
|
+
proj = create_project(width=300, height=200)
|
|
185
|
+
add_from_file(proj, sample_image)
|
|
186
|
+
add_filter(proj, "flip_h", 0, {})
|
|
187
|
+
out = os.path.join(tmp_dir, "flipped.png")
|
|
188
|
+
render(proj, out, preset="png", overwrite=True)
|
|
189
|
+
|
|
190
|
+
original = np.array(Image.open(sample_image).convert("RGB"))
|
|
191
|
+
result = np.array(Image.open(out).convert("RGB"))
|
|
192
|
+
# First column of result should match last column of original
|
|
193
|
+
np.testing.assert_array_equal(result[:, 0, :], original[:, -1, :])
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ── Export Formats ───────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
class TestExportFormats:
|
|
199
|
+
def test_export_jpeg(self, tmp_dir, sample_image):
|
|
200
|
+
proj = create_project(width=300, height=200)
|
|
201
|
+
add_from_file(proj, sample_image)
|
|
202
|
+
out = os.path.join(tmp_dir, "output.jpg")
|
|
203
|
+
result = render(proj, out, preset="jpeg-high", overwrite=True)
|
|
204
|
+
assert os.path.exists(out)
|
|
205
|
+
assert result["format"] == "JPEG"
|
|
206
|
+
|
|
207
|
+
def test_export_webp(self, tmp_dir, sample_image):
|
|
208
|
+
proj = create_project(width=300, height=200)
|
|
209
|
+
add_from_file(proj, sample_image)
|
|
210
|
+
out = os.path.join(tmp_dir, "output.webp")
|
|
211
|
+
result = render(proj, out, preset="webp", overwrite=True)
|
|
212
|
+
assert os.path.exists(out)
|
|
213
|
+
assert result["format"] == "WEBP"
|
|
214
|
+
|
|
215
|
+
def test_export_bmp(self, tmp_dir, sample_image):
|
|
216
|
+
proj = create_project(width=300, height=200)
|
|
217
|
+
add_from_file(proj, sample_image)
|
|
218
|
+
out = os.path.join(tmp_dir, "output.bmp")
|
|
219
|
+
result = render(proj, out, preset="bmp", overwrite=True)
|
|
220
|
+
assert os.path.exists(out)
|
|
221
|
+
|
|
222
|
+
def test_export_overwrite_protection(self, tmp_dir, sample_image):
|
|
223
|
+
proj = create_project(width=300, height=200)
|
|
224
|
+
add_from_file(proj, sample_image)
|
|
225
|
+
out = os.path.join(tmp_dir, "output.png")
|
|
226
|
+
render(proj, out, preset="png", overwrite=True)
|
|
227
|
+
with pytest.raises(FileExistsError):
|
|
228
|
+
render(proj, out, preset="png", overwrite=False)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ── Blend Modes ──────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
class TestBlendModes:
|
|
234
|
+
def _two_layer_project(self, tmp_dir, color1, color2, mode):
|
|
235
|
+
img1 = Image.new("RGBA", (100, 100), color1)
|
|
236
|
+
img2 = Image.new("RGBA", (100, 100), color2)
|
|
237
|
+
p1 = os.path.join(tmp_dir, "layer1.png")
|
|
238
|
+
p2 = os.path.join(tmp_dir, "layer2.png")
|
|
239
|
+
img1.save(p1)
|
|
240
|
+
img2.save(p2)
|
|
241
|
+
|
|
242
|
+
proj = create_project(width=100, height=100, color_mode="RGBA",
|
|
243
|
+
background="transparent")
|
|
244
|
+
add_from_file(proj, p1, name="Bottom")
|
|
245
|
+
add_from_file(proj, p2, name="Top")
|
|
246
|
+
proj["layers"][0]["blend_mode"] = mode
|
|
247
|
+
return proj
|
|
248
|
+
|
|
249
|
+
def test_multiply_darkens(self, tmp_dir):
|
|
250
|
+
proj = self._two_layer_project(tmp_dir, (200, 200, 200, 255),
|
|
251
|
+
(128, 128, 128, 255), "multiply")
|
|
252
|
+
out = os.path.join(tmp_dir, "multiply.png")
|
|
253
|
+
render(proj, out, preset="png", overwrite=True)
|
|
254
|
+
result = np.array(Image.open(out).convert("RGB"), dtype=float)
|
|
255
|
+
# Multiply always darkens
|
|
256
|
+
assert result.mean() < 200
|
|
257
|
+
|
|
258
|
+
def test_screen_brightens(self, tmp_dir):
|
|
259
|
+
proj = self._two_layer_project(tmp_dir, (100, 100, 100, 255),
|
|
260
|
+
(100, 100, 100, 255), "screen")
|
|
261
|
+
out = os.path.join(tmp_dir, "screen.png")
|
|
262
|
+
render(proj, out, preset="png", overwrite=True)
|
|
263
|
+
result = np.array(Image.open(out).convert("RGB"), dtype=float)
|
|
264
|
+
# Screen always brightens
|
|
265
|
+
assert result.mean() > 100
|
|
266
|
+
|
|
267
|
+
def test_difference(self, tmp_dir):
|
|
268
|
+
proj = self._two_layer_project(tmp_dir, (200, 100, 50, 255),
|
|
269
|
+
(100, 100, 100, 255), "difference")
|
|
270
|
+
out = os.path.join(tmp_dir, "diff.png")
|
|
271
|
+
render(proj, out, preset="png", overwrite=True)
|
|
272
|
+
result = np.array(Image.open(out).convert("RGB"), dtype=float)
|
|
273
|
+
# Difference of (200,100,50) and (100,100,100) = (100,0,50)
|
|
274
|
+
assert abs(result[:,:,0].mean() - 100) < 5
|
|
275
|
+
assert abs(result[:,:,1].mean() - 0) < 5
|
|
276
|
+
assert abs(result[:,:,2].mean() - 50) < 5
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ── Canvas Operations ────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
class TestCanvasRendering:
|
|
282
|
+
def test_scale_and_export(self, tmp_dir, sample_image):
|
|
283
|
+
proj = create_project(width=300, height=200)
|
|
284
|
+
add_from_file(proj, sample_image)
|
|
285
|
+
scale_canvas(proj, 150, 100)
|
|
286
|
+
out = os.path.join(tmp_dir, "scaled.png")
|
|
287
|
+
render(proj, out, preset="png", overwrite=True)
|
|
288
|
+
result = Image.open(out)
|
|
289
|
+
assert result.size == (150, 100)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ── Media Probing ────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
class TestMediaProbing:
|
|
295
|
+
def test_probe_png(self, sample_image):
|
|
296
|
+
info = probe_image(sample_image)
|
|
297
|
+
assert info["width"] == 300
|
|
298
|
+
assert info["height"] == 200
|
|
299
|
+
assert info["format"] == "PNG"
|
|
300
|
+
assert info["mode"] == "RGB"
|
|
301
|
+
|
|
302
|
+
def test_probe_jpeg(self, tmp_dir):
|
|
303
|
+
img = Image.new("RGB", (100, 100), "red")
|
|
304
|
+
path = os.path.join(tmp_dir, "test.jpg")
|
|
305
|
+
img.save(path, "JPEG")
|
|
306
|
+
info = probe_image(path)
|
|
307
|
+
assert info["format"] == "JPEG"
|
|
308
|
+
assert info["width"] == 100
|
|
309
|
+
|
|
310
|
+
def test_probe_nonexistent(self):
|
|
311
|
+
with pytest.raises(FileNotFoundError):
|
|
312
|
+
probe_image("/nonexistent/image.png")
|
|
313
|
+
|
|
314
|
+
def test_check_media(self, sample_image):
|
|
315
|
+
proj = create_project()
|
|
316
|
+
add_from_file(proj, sample_image)
|
|
317
|
+
result = check_media(proj)
|
|
318
|
+
assert result["status"] == "ok"
|
|
319
|
+
assert result["missing"] == 0
|
|
320
|
+
|
|
321
|
+
def test_check_media_missing(self, sample_image):
|
|
322
|
+
proj = create_project()
|
|
323
|
+
add_from_file(proj, sample_image)
|
|
324
|
+
proj["layers"][0]["source"] = "/nonexistent/file.png"
|
|
325
|
+
result = check_media(proj)
|
|
326
|
+
assert result["status"] == "missing_files"
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ── Session Integration ──────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
class TestSessionIntegration:
|
|
332
|
+
def test_undo_layer_add(self, sample_image):
|
|
333
|
+
sess = Session()
|
|
334
|
+
proj = create_project()
|
|
335
|
+
sess.set_project(proj)
|
|
336
|
+
|
|
337
|
+
sess.snapshot("add layer")
|
|
338
|
+
add_from_file(proj, sample_image)
|
|
339
|
+
assert len(proj["layers"]) == 1
|
|
340
|
+
|
|
341
|
+
sess.undo()
|
|
342
|
+
assert len(sess.get_project()["layers"]) == 0
|
|
343
|
+
|
|
344
|
+
def test_undo_filter_add(self, sample_image):
|
|
345
|
+
sess = Session()
|
|
346
|
+
proj = create_project()
|
|
347
|
+
add_from_file(proj, sample_image)
|
|
348
|
+
sess.set_project(proj)
|
|
349
|
+
|
|
350
|
+
sess.snapshot("add filter")
|
|
351
|
+
add_filter(proj, "brightness", 0, {"factor": 1.5})
|
|
352
|
+
assert len(proj["layers"][0]["filters"]) == 1
|
|
353
|
+
|
|
354
|
+
sess.undo()
|
|
355
|
+
assert len(sess.get_project()["layers"][0]["filters"]) == 0
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# ── CLI Subprocess Tests ─────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
def _resolve_cli(name):
|
|
361
|
+
"""Resolve installed CLI command; falls back to python -m for dev.
|
|
362
|
+
|
|
363
|
+
Set env CLI_ANYTHING_FORCE_INSTALLED=1 to require the installed command.
|
|
364
|
+
"""
|
|
365
|
+
import shutil
|
|
366
|
+
force = os.environ.get("CLI_ANYTHING_FORCE_INSTALLED", "").strip() == "1"
|
|
367
|
+
path = shutil.which(name)
|
|
368
|
+
if path:
|
|
369
|
+
print(f"[_resolve_cli] Using installed command: {path}")
|
|
370
|
+
return [path]
|
|
371
|
+
if force:
|
|
372
|
+
raise RuntimeError(f"{name} not found in PATH. Install with: pip install -e .")
|
|
373
|
+
module = name.replace("cli-anything-", "cli_anything.") + "." + name.split("-")[-1] + "_cli"
|
|
374
|
+
print(f"[_resolve_cli] Falling back to: {sys.executable} -m {module}")
|
|
375
|
+
return [sys.executable, "-m", module]
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
class TestCLISubprocess:
|
|
379
|
+
CLI_BASE = _resolve_cli("cli-anything-gimp")
|
|
380
|
+
|
|
381
|
+
def _run(self, args, check=True):
|
|
382
|
+
return subprocess.run(
|
|
383
|
+
self.CLI_BASE + args,
|
|
384
|
+
capture_output=True, text=True,
|
|
385
|
+
check=check,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
def test_help(self):
|
|
389
|
+
result = self._run(["--help"])
|
|
390
|
+
assert result.returncode == 0
|
|
391
|
+
assert "GIMP CLI" in result.stdout
|
|
392
|
+
|
|
393
|
+
def test_project_new(self, tmp_dir):
|
|
394
|
+
out = os.path.join(tmp_dir, "test.json")
|
|
395
|
+
result = self._run(["project", "new", "-o", out])
|
|
396
|
+
assert result.returncode == 0
|
|
397
|
+
assert os.path.exists(out)
|
|
398
|
+
|
|
399
|
+
def test_project_new_json(self, tmp_dir):
|
|
400
|
+
out = os.path.join(tmp_dir, "test.json")
|
|
401
|
+
result = self._run(["--json", "project", "new", "-o", out])
|
|
402
|
+
assert result.returncode == 0
|
|
403
|
+
data = json.loads(result.stdout)
|
|
404
|
+
assert data["canvas"]["width"] == 1920
|
|
405
|
+
|
|
406
|
+
def test_project_profiles(self):
|
|
407
|
+
result = self._run(["project", "profiles"])
|
|
408
|
+
assert result.returncode == 0
|
|
409
|
+
assert "hd1080p" in result.stdout
|
|
410
|
+
|
|
411
|
+
def test_filter_list_available(self):
|
|
412
|
+
result = self._run(["filter", "list-available"])
|
|
413
|
+
assert result.returncode == 0
|
|
414
|
+
assert "brightness" in result.stdout
|
|
415
|
+
|
|
416
|
+
def test_export_presets(self):
|
|
417
|
+
result = self._run(["export", "presets"])
|
|
418
|
+
assert result.returncode == 0
|
|
419
|
+
assert "png" in result.stdout
|
|
420
|
+
|
|
421
|
+
def test_full_workflow_json(self, tmp_dir, sample_image):
|
|
422
|
+
proj_path = os.path.join(tmp_dir, "workflow.json")
|
|
423
|
+
out_path = os.path.join(tmp_dir, "output.png")
|
|
424
|
+
|
|
425
|
+
# Create project
|
|
426
|
+
self._run(["--json", "project", "new", "-o", proj_path, "-w", "300", "-h", "200"])
|
|
427
|
+
|
|
428
|
+
# Add layer
|
|
429
|
+
self._run(["--json", "--project", proj_path,
|
|
430
|
+
"layer", "add-from-file", sample_image])
|
|
431
|
+
|
|
432
|
+
# Save
|
|
433
|
+
self._run(["--project", proj_path, "project", "save"])
|
|
434
|
+
|
|
435
|
+
# Export
|
|
436
|
+
self._run(["--project", proj_path,
|
|
437
|
+
"export", "render", out_path, "--overwrite"])
|
|
438
|
+
|
|
439
|
+
assert os.path.exists(out_path)
|
|
440
|
+
result = Image.open(out_path)
|
|
441
|
+
assert result.size == (300, 200)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# ── Real-World Workflow Tests ────────────────────────────────────
|
|
445
|
+
|
|
446
|
+
class TestRealWorldWorkflows:
|
|
447
|
+
def test_photo_editing_workflow(self, tmp_dir, sample_image):
|
|
448
|
+
"""Simulate a photo editing workflow: open, adjust, export."""
|
|
449
|
+
proj = create_project(width=300, height=200, name="photo_edit")
|
|
450
|
+
add_from_file(proj, sample_image, name="Photo")
|
|
451
|
+
add_filter(proj, "brightness", 0, {"factor": 1.15})
|
|
452
|
+
add_filter(proj, "contrast", 0, {"factor": 1.1})
|
|
453
|
+
add_filter(proj, "saturation", 0, {"factor": 1.2})
|
|
454
|
+
add_filter(proj, "sharpness", 0, {"factor": 1.5})
|
|
455
|
+
|
|
456
|
+
out = os.path.join(tmp_dir, "edited.jpg")
|
|
457
|
+
result = render(proj, out, preset="jpeg-high", overwrite=True)
|
|
458
|
+
assert os.path.exists(out)
|
|
459
|
+
assert result["layers_rendered"] == 1
|
|
460
|
+
|
|
461
|
+
def test_collage_workflow(self, tmp_dir):
|
|
462
|
+
"""Create a collage from multiple images."""
|
|
463
|
+
images = []
|
|
464
|
+
colors = ["red", "green", "blue", "yellow"]
|
|
465
|
+
for color in colors:
|
|
466
|
+
img = Image.new("RGB", (100, 100), color)
|
|
467
|
+
path = os.path.join(tmp_dir, f"{color}.png")
|
|
468
|
+
img.save(path)
|
|
469
|
+
images.append(path)
|
|
470
|
+
|
|
471
|
+
proj = create_project(width=200, height=200, name="collage")
|
|
472
|
+
add_from_file(proj, images[0], name="TL")
|
|
473
|
+
proj["layers"][0]["offset_x"] = 0
|
|
474
|
+
proj["layers"][0]["offset_y"] = 0
|
|
475
|
+
add_from_file(proj, images[1], name="TR")
|
|
476
|
+
proj["layers"][0]["offset_x"] = 100
|
|
477
|
+
proj["layers"][0]["offset_y"] = 0
|
|
478
|
+
add_from_file(proj, images[2], name="BL")
|
|
479
|
+
proj["layers"][0]["offset_x"] = 0
|
|
480
|
+
proj["layers"][0]["offset_y"] = 100
|
|
481
|
+
add_from_file(proj, images[3], name="BR")
|
|
482
|
+
proj["layers"][0]["offset_x"] = 100
|
|
483
|
+
proj["layers"][0]["offset_y"] = 100
|
|
484
|
+
|
|
485
|
+
out = os.path.join(tmp_dir, "collage.png")
|
|
486
|
+
render(proj, out, preset="png", overwrite=True)
|
|
487
|
+
|
|
488
|
+
result = Image.open(out)
|
|
489
|
+
assert result.size == (200, 200)
|
|
490
|
+
|
|
491
|
+
def test_text_overlay_workflow(self, tmp_dir, sample_image):
|
|
492
|
+
"""Add text overlay to an image."""
|
|
493
|
+
proj = create_project(width=300, height=200)
|
|
494
|
+
add_from_file(proj, sample_image, name="Background")
|
|
495
|
+
add_layer(proj, name="Title", layer_type="text")
|
|
496
|
+
proj["layers"][0]["text"] = "Hello World"
|
|
497
|
+
proj["layers"][0]["font_size"] = 32
|
|
498
|
+
proj["layers"][0]["color"] = "#ffffff"
|
|
499
|
+
|
|
500
|
+
out = os.path.join(tmp_dir, "text_overlay.png")
|
|
501
|
+
render(proj, out, preset="png", overwrite=True)
|
|
502
|
+
assert os.path.exists(out)
|
|
503
|
+
|
|
504
|
+
def test_batch_filter_workflow(self, tmp_dir, sample_image):
|
|
505
|
+
"""Apply multiple artistic filters in sequence."""
|
|
506
|
+
proj = create_project(width=300, height=200)
|
|
507
|
+
add_from_file(proj, sample_image)
|
|
508
|
+
add_filter(proj, "grayscale", 0, {})
|
|
509
|
+
add_filter(proj, "contrast", 0, {"factor": 1.5})
|
|
510
|
+
add_filter(proj, "find_edges", 0, {})
|
|
511
|
+
|
|
512
|
+
out = os.path.join(tmp_dir, "artistic.png")
|
|
513
|
+
render(proj, out, preset="png", overwrite=True)
|
|
514
|
+
assert os.path.exists(out)
|
|
515
|
+
|
|
516
|
+
def test_save_load_complex_project(self, tmp_dir, sample_image):
|
|
517
|
+
"""Create complex project, save, reload, verify integrity."""
|
|
518
|
+
proj = create_project(width=300, height=200, name="complex")
|
|
519
|
+
add_from_file(proj, sample_image, name="Photo")
|
|
520
|
+
add_layer(proj, name="Overlay", layer_type="solid", fill="#ff000080", opacity=0.5)
|
|
521
|
+
add_layer(proj, name="Text", layer_type="text")
|
|
522
|
+
add_filter(proj, "brightness", 2, {"factor": 1.3}) # On bottom layer (Photo)
|
|
523
|
+
add_filter(proj, "gaussian_blur", 2, {"radius": 2.0})
|
|
524
|
+
|
|
525
|
+
path = os.path.join(tmp_dir, "complex.json")
|
|
526
|
+
save_project(proj, path)
|
|
527
|
+
|
|
528
|
+
loaded = open_project(path)
|
|
529
|
+
assert len(loaded["layers"]) == 3
|
|
530
|
+
assert loaded["layers"][2]["filters"][0]["name"] == "brightness"
|
|
531
|
+
assert loaded["layers"][2]["filters"][1]["name"] == "gaussian_blur"
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
# ── True Backend E2E Tests (requires GIMP installed) ─────────────
|
|
535
|
+
|
|
536
|
+
class TestGIMPBackend:
|
|
537
|
+
"""Tests that verify GIMP is installed and accessible."""
|
|
538
|
+
|
|
539
|
+
def test_gimp_is_installed(self):
|
|
540
|
+
from cli_anything.gimp.utils.gimp_backend import find_gimp
|
|
541
|
+
path = find_gimp()
|
|
542
|
+
assert os.path.exists(path)
|
|
543
|
+
print(f"\n GIMP binary: {path}")
|
|
544
|
+
|
|
545
|
+
def test_gimp_version(self):
|
|
546
|
+
from cli_anything.gimp.utils.gimp_backend import get_version
|
|
547
|
+
version = get_version()
|
|
548
|
+
assert "image manipulation" in version.lower() or "gimp" in version.lower()
|
|
549
|
+
print(f"\n GIMP version: {version}")
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
class TestGIMPRenderE2E:
|
|
553
|
+
"""True E2E tests using GIMP batch mode."""
|
|
554
|
+
|
|
555
|
+
def test_create_and_export_png(self):
|
|
556
|
+
"""Create a blank image in GIMP and export as PNG."""
|
|
557
|
+
from cli_anything.gimp.utils.gimp_backend import create_and_export
|
|
558
|
+
|
|
559
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
560
|
+
output = os.path.join(tmp_dir, "test.png")
|
|
561
|
+
result = create_and_export(200, 150, output, fill_color="red", timeout=60)
|
|
562
|
+
|
|
563
|
+
assert os.path.exists(result["output"])
|
|
564
|
+
assert result["file_size"] > 0
|
|
565
|
+
assert result["method"] == "gimp-batch"
|
|
566
|
+
print(f"\n GIMP PNG: {result['output']} ({result['file_size']:,} bytes)")
|
|
567
|
+
|
|
568
|
+
def test_create_and_export_jpeg(self):
|
|
569
|
+
"""Create a blank image in GIMP and export as JPEG."""
|
|
570
|
+
from cli_anything.gimp.utils.gimp_backend import create_and_export
|
|
571
|
+
|
|
572
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
573
|
+
output = os.path.join(tmp_dir, "test.jpg")
|
|
574
|
+
result = create_and_export(200, 150, output, fill_color="blue", timeout=60)
|
|
575
|
+
|
|
576
|
+
assert os.path.exists(result["output"])
|
|
577
|
+
assert result["file_size"] > 0
|
|
578
|
+
print(f"\n GIMP JPEG: {result['output']} ({result['file_size']:,} bytes)")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""GIMP CLI - Utility modules."""
|