git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

verify/image-selection-test (10.4K)

  1 #!/usr/bin/env bash
  2 # image-selection-test — behavioral test for the overview drag-selection
  3 # picking up the desktop IMAGES (cce-grid's pinned items) beside windows,
  4 # and a group move carrying them (src/server/selection.rs, since
  5 # 2026-09-30).
  6 #
  7 # Setup: a private cce-shadow instance whose seeded desktop holds two
  8 # images (a 100x100 red one right of where cce-terminal maps, a 60x100 blue
  9 # one left of it), cce-grid running against the shadow so it reports them
 10 # (`grid-items` on the control socket), and one cce-terminal window. The
 11 # camera stays at zoom 1 in overview with one window, so everything is
 12 # driven in virtual units and converted through `ccectl camera`. The
 13 # assertions, in order:
 14 #
 15 #   1. the grid's report landed (`ccectl selection` desk=...)
 16 #   2. a band over the red image picks it, live, and the pick survives the
 17 #      release
 18 #   3. a press on the selected image drags it (PointerOpType::GroupMove):
 19 #      the compositor's rect follows mid-drag, the grid saves the sidecar
 20 #      on `drop`
 21 #   4. a band over everything, then a drag of the terminal, moves both
 22 #      images by exactly the terminal's offset (snap included) and the
 23 #      sidecar agrees
 24 #   5. a background click drops the selection and stays in overview — also
 25 #      with only images selected (has_selection), where it used to exit
 26 #   6. a press on an UNSELECTED image drops the selection and the grid
 27 #      drags the image itself, then re-reports
 28 #   7. leaving overview clears it
 29 #
 30 # The compositor binary is whatever cce-shadow resolves (installed first,
 31 # then the workspace target/release); extra args go to `cce-shadow start`,
 32 # so `--bin ../target/release/cce-fx` pins the tree's own build. The grid
 33 # is $CCE_GRID, else the workspace's target/release/cce-grid, else the one
 34 # on PATH — build it with `cargo build --release -p cce-grid` to test the
 35 # tree's.
 36 
 37 set -euo pipefail
 38 
 39 here=$(cd "$(dirname "$0")" && pwd)
 40 repo=$(dirname "$here")
 41 shadow="$repo/scripts/cce-shadow"
 42 out=$(mktemp -d)
 43 inst=""
 44 
 45 cleanup() {
 46     [ -n "$inst" ] && "$shadow" --instance "$inst" stop >/dev/null 2>&1 || true
 47     rm -rf "$out"
 48 }
 49 trap cleanup EXIT
 50 
 51 fail() {
 52     echo "FAIL: $*" >&2
 53     echo "--- grid log ---" >&2
 54     cat "$out/grid.log" >&2 || true
 55     echo "--- compositor log tail ---" >&2
 56     [ -n "$inst" ] && "$shadow" --instance "$inst" logs 2>/dev/null | tail -20 >&2 || true
 57     exit 1
 58 }
 59 
 60 grid=${CCE_GRID:-}
 61 if [ -z "$grid" ]; then
 62     if [ -x "$repo/../target/release/cce-grid" ]; then
 63         grid="$repo/../target/release/cce-grid"
 64     else
 65         grid=$(command -v cce-grid) || fail "no cce-grid: build it or set CCE_GRID"
 66     fi
 67 fi
 68 command -v cce-terminal >/dev/null || fail "cce-terminal is not installed"
 69 
 70 echo "==> starting shadow instance"
 71 inst=$("$shadow" start --new "$@" 2>&1 | sed -n "s/.*claimed instance '\([^']*\)'.*/\1/p")
 72 [ -n "$inst" ] || fail "could not claim a shadow instance"
 73 sc() { "$shadow" --instance "$inst" "$@"; }
 74 home=$(sc env | sed -n 's/^export HOME=//p')
 75 [ -n "$home" ] || fail "could not read the shadow's HOME"
 76 
 77 echo "==> seeding two desktop images"
 78 mkdir -p "$home/Desktop" "$home/.local/share/cce"
 79 python3 - "$home/Desktop" <<'EOF'
 80 import struct, sys, zlib
 81 def png(path, w, h, rgb):
 82     raw = b"".join(b"\0" + bytes(rgb) * w for _ in range(h))
 83     def chunk(t, d): return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xffffffff)
 84     open(path, "wb").write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
 85                             + chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b""))
 86 png(sys.argv[1] + "/red.png", 100, 100, (220, 60, 60))
 87 png(sys.argv[1] + "/blue.png", 60, 100, (60, 120, 220))
 88 EOF
 89 cat > "$home/.local/share/cce/desktop-items.json" <<EOF
 90 [{"path":"$home/Desktop/red.png","x":960,"y":150,"w":100,"h":100},
 91  {"path":"$home/Desktop/blue.png","x":-90,"y":150,"w":60,"h":100}]
 92 EOF
 93 
 94 echo "==> starting the grid ($grid) and a terminal"
 95 sc run "$grid" >"$out/grid.log" 2>&1 &
 96 sc ctl spawn cce-terminal >/dev/null
 97 sleep 4
 98 
 99 echo "==> driving"
100 python3 - "$shadow" "$inst" "$home" "$out/grid.log" <<'EOF' || fail "assertions failed"
101 import json, re, subprocess, sys, time
102 S, INST, H, GRIDLOG = sys.argv[1:5]
103 fails = 0
104 
105 def ctl(*a):
106     return subprocess.run([S, "--instance", INST, "ctl", *a], capture_output=True, text=True).stdout.strip()
107 
108 def cam():
109     c = ctl("camera")
110     g = lambda k: re.search(rf"{k}=([-\w.]+)", c).group(1)
111     return float(g("pan_x")), float(g("pan_y")), float(g("zoom")), g("mode")
112 
113 def sel():
114     s = ctl("selection")
115     g = lambda k: re.search(rf"{k}=(\S+)", s).group(1)
116     desk = {}
117     if g("desk") != "-":
118         for tok in g("desk").split(";"):
119             i, rest = tok.split("@")
120             x, y, wh = rest.split(",")
121             w, h = wh.split("x")
122             desk[int(i)] = (float(x), float(y), float(w), float(h))
123     windows = [] if g("selected") == "-" else [int(v) for v in g("selected").split(",")]
124     items = [] if g("items") == "-" else [int(v) for v in g("items").split(",")]
125     return dict(raw=s, windows=windows, items=items, desk=desk, band=g("band"))
126 
127 def term():
128     for line in ctl("windows").splitlines():
129         if "app_id=cce-terminal" in line:
130             g = lambda k: re.search(rf"\b{k}=([-\w.]+)", line).group(1)
131             return float(g("vx")), float(g("vy")), float(g("w")), float(g("h"))
132     return None
133 
134 def move(vx, vy):
135     px, py, z, _ = cam()
136     ctl("pointer-move-to", str(round((vx - px) * z)), str(round((vy - py) * z)))
137     time.sleep(0.08)
138 
139 def press(): ctl("pointer-press", "left"); time.sleep(0.1)
140 def release(): ctl("pointer-release", "left"); time.sleep(0.5)
141 
142 def check(name, ok, detail=""):
143     global fails
144     print(("  ok    " if ok else "  FAIL  ") + name + ("" if ok else f"  [{detail}]"))
145     if not ok: fails += 1
146 
147 def band(x0, y0, x1, y1):
148     move(x0, y0); press()
149     move((x0 + x1) / 2, (y0 + y1) / 2); move(x1, y1); time.sleep(0.2)
150     mid = sel(); release(); return mid, sel()
151 
152 def sidecar():
153     return {d["path"].rsplit("/", 1)[1]: (d["x"], d["y"]) for d in json.load(open(f"{H}/.local/share/cce/desktop-items.json"))}
154 
155 def center(r): return (r[0] + r[2] / 2, r[1] + r[3] / 2)
156 
157 # 1. the report
158 for _ in range(25):
159     s0 = sel()
160     if len(s0["desk"]) == 2 and term(): break
161     time.sleep(0.2)
162 check("grid reported both images", len(s0["desk"]) == 2, s0["raw"])
163 check("terminal mapped", term() is not None)
164 red = next(i for i, r in s0["desk"].items() if r[2] == 100)
165 blue = next(i for i, r in s0["desk"].items() if r[2] == 60)
166 if cam()[3] != "Overview":
167     ctl("overview"); time.sleep(1.5)
168 check("in overview", cam()[3] == "Overview", str(cam()))
169 
170 # 2. band over the red image only (pressed to its right, clear of the terminal)
171 r = s0["desk"][red]
172 mid, after = band(r[0] + r[2] + 30, r[1] - 30, r[0] - 10, r[1] + r[3] + 10)
173 check("band mid-drag picks the red image only", mid["items"] == [red] and mid["windows"] == [] and mid["band"] != "-", mid["raw"])
174 check("selection persists after release", after["items"] == [red] and after["band"] == "-", after["raw"])
175 
176 # 3. press the selected image, drag it down 100
177 cx, cy = center(r)
178 move(cx, cy); press(); move(cx, cy + 50); move(cx, cy + 100); time.sleep(0.3)
179 mid = sel()
180 check("image follows the drag mid-move", abs(mid["desk"][red][1] - (r[1] + 100)) < 1 and mid["items"] == [red], mid["raw"])
181 release(); time.sleep(0.5)
182 after = sel()
183 check("image stays where dropped, still selected", abs(after["desk"][red][1] - (r[1] + 100)) < 1 and after["items"] == [red], after["raw"])
184 check("grid saved the new position", abs(sidecar()["red.png"][1] - (r[1] + 100)) < 1, str(sidecar()))
185 check("grid logged the drop", "group move dropped" in open(GRIDLOG).read())
186 
187 # 4. band over everything, drag the terminal, images follow by its offset
188 t = term(); d = sel()["desk"]
189 x0 = min(d[blue][0], t[0]) - 20; y0 = min(d[blue][1], d[red][1], t[1]) - 20
190 x1 = max(d[red][0] + d[red][2], t[0] + t[2]) + 20; y1 = max(d[red][1] + d[red][3], t[1] + t[3]) + 20
191 mid, after = band(x0, y0, x1, y1)
192 check("band over everything picks the window and both images", len(after["windows"]) == 1 and sorted(after["items"]) == sorted([red, blue]), after["raw"])
193 t0 = term(); d0 = sel()["desk"]
194 tcx, tcy = t0[0] + t0[2] / 2, t0[1] + t0[3] / 2
195 move(tcx, tcy); press(); move(tcx, tcy + 100); move(tcx, tcy + 200); time.sleep(0.3)
196 release(); time.sleep(0.6)
197 t1 = term(); d1 = sel()["desk"]
198 dt = (t1[0] - t0[0], t1[1] - t0[1])
199 check("terminal moved", dt != (0.0, 0.0), f"dt={dt}")
200 for name, i in (("red", red), ("blue", blue)):
201     di = (d1[i][0] - d0[i][0], d1[i][1] - d0[i][1])
202     check(f"{name} image moved by the terminal's offset", abs(di[0] - dt[0]) < 1 and abs(di[1] - dt[1]) < 1, f"image {di} terminal {dt}")
203 sc = sidecar()
204 check("sidecar matches the compositor's rects", abs(sc["red.png"][1] - d1[red][1]) < 1 and abs(sc["blue.png"][1] - d1[blue][1]) < 1, f"{sc} vs {d1}")
205 
206 # 5. background clicks
207 t = term()
208 bx, by = t[0] - 15, t[1] + t[3] / 2
209 move(bx, by); press(); release()
210 s = sel()
211 check("background click drops the selection", s["windows"] == [] and s["items"] == [], s["raw"])
212 check("still in overview after dropping", cam()[3] == "Overview", str(cam()))
213 b = sel()["desk"][blue]
214 mid, after = band(b[0] - 12, b[1] - 12, b[0] + b[2] + 8, b[1] + b[3] + 12)
215 check("band picks the blue image only", after["items"] == [blue] and after["windows"] == [], after["raw"])
216 move(bx, by + 150); press(); release()
217 s = sel()
218 check("click drops an image-only selection", s["items"] == [], s["raw"])
219 check("and stays in overview", cam()[3] == "Overview", str(cam()))
220 
221 # 6. a press on an unselected image
222 mid, after = band(b[0] - 12, b[1] - 12, b[0] + b[2] + 8, b[1] + b[3] + 12)
223 check("blue selected again", after["items"] == [blue], after["raw"])
224 rr = sel()["desk"][red]; cx, cy = center(rr)
225 move(cx, cy); press(); time.sleep(0.2)
226 s = sel()
227 check("press on the unselected red image drops the selection", s["items"] == [], s["raw"])
228 move(cx, cy - 30); move(cx, cy - 60); time.sleep(0.2); release(); time.sleep(0.8)
229 d = sel()["desk"]
230 check("grid dragged the red image itself and re-reported", abs(d[red][1] - (rr[1] - 60)) < 2, f"{d[red]} from {rr}")
231 check("blue untouched by that drag", abs(d[blue][1] - b[1]) < 1, str(d))
232 
233 # 7. leaving overview
234 band(rr[0] - 10, rr[1] - 70, rr[0] + rr[2] + 10, rr[1] + rr[3])
235 check("something selected before exit", sel()["items"] == [red], sel()["raw"])
236 ctl("overview"); time.sleep(1.5)
237 s = sel()
238 check("leaving overview clears the selection", s["items"] == [] and s["windows"] == [] and cam()[3] == "Normal", s["raw"] + " " + str(cam()))
239 sys.exit(1 if fails else 0)
240 EOF
241 
242 echo "PASS"