Files
rpa_vision_v3/tests/unit/test_capture_io.py
Dom cac965cef9 test(coords+capture): coords write-only gap (10 tests) + capture I/O + image_chat_cli
test_coords_consumption_gap.py documents 3 structural gaps where
NavigateCoords are written but never consumed. test_capture_io.py and
test_image_chat_cli.py cover capture and chat CLI paths.
2026-07-02 13:01:49 +02:00

156 lines
5.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests unitaires de la politique de sauvegarde des captures (agent_v1).
Objectif : réduire le poids disque des captures (90 Go / 13 sessions = trop)
sans casser la précision du grounding. La politique distingue le *type* de
shot :
- ``crop`` → PNG lossless (cible de grounding qwen3-vl, précision pixel) ;
- ``full`` / ``window`` / ``context`` → JPEG ``optimize=True`` (vue humaine /
contexte, compression ~5-10x acceptable) ;
- ``heartbeat`` → JPEG **downscalé** (liveness, pas de grounding → on peut
réduire la résolution).
La fonction ``save_capture`` retourne le chemin RÉELLEMENT écrit (extension
ajustée selon le format), pour que l'appelant streame le bon fichier.
Branche feat/push-log-dgx — réduction du poids de capture (unité testée,
non encore câblée dans capturer.py).
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from PIL import Image
_ROOT = str(Path(__file__).resolve().parents[2])
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
def _noisy_image(width: int, height: int) -> Image.Image:
"""Image RGB avec du bruit réel.
Un aplat uni se compresse à quasi-zéro en PNG comme en JPEG : la
comparaison de poids serait truquée. On injecte du bruit pour que la
différence PNG/JPEG soit représentative d'un vrai screenshot.
"""
return Image.frombytes("RGB", (width, height), os.urandom(width * height * 3))
def test_crop_reste_png_et_dimensions_identiques(tmp_path):
"""Un crop est sauvé en PNG lossless, dimensions inchangées."""
from agent_v0.agent_v1.vision.capture_io import save_capture
img = _noisy_image(80, 80)
base = str(tmp_path / "shot_0001_crop")
out_path = save_capture(img, base, kind="crop")
assert out_path.endswith(".png"), f"crop doit rester PNG, obtenu {out_path}"
assert os.path.exists(out_path)
reread = Image.open(out_path)
assert reread.size == (80, 80)
# PNG lossless : les pixels doivent être identiques au bruit d'origine.
assert list(reread.convert("RGB").getdata()) == list(img.getdata())
def test_full_est_jpeg(tmp_path):
"""Un full est sauvé en JPEG (.jpg)."""
from agent_v0.agent_v1.vision.capture_io import save_capture
img = _noisy_image(640, 480)
base = str(tmp_path / "shot_0001_full")
out_path = save_capture(img, base, kind="full")
assert out_path.endswith(".jpg"), f"full doit être JPEG, obtenu {out_path}"
assert os.path.exists(out_path)
def test_full_jpeg_significativement_plus_leger_que_png(tmp_path):
"""Le JPEG full doit peser nettement moins que le PNG équivalent.
On génère une image bruitée plein écran (2560×1600) et on compare le
poids du JPEG produit par la politique au poids d'un PNG lossless du
même contenu. Le gain doit être substantiel (au moins 2x plus léger).
"""
from agent_v0.agent_v1.vision.capture_io import save_capture
img = _noisy_image(2560, 1600)
jpeg_path = save_capture(img, str(tmp_path / "full_jpeg"), kind="full")
png_ref = tmp_path / "full_ref.png"
img.save(png_ref, "PNG")
jpeg_size = os.path.getsize(jpeg_path)
png_size = os.path.getsize(png_ref)
assert jpeg_size < png_size / 2, (
f"JPEG ({jpeg_size}o) doit peser < moitié du PNG ({png_size}o)"
)
def test_context_et_window_sont_jpeg(tmp_path):
"""context et window suivent la même politique JPEG que full."""
from agent_v0.agent_v1.vision.capture_io import save_capture
img = _noisy_image(320, 240)
for kind in ("context", "window"):
out_path = save_capture(img, str(tmp_path / f"x_{kind}"), kind=kind)
assert out_path.endswith(".jpg"), f"{kind} doit être JPEG, obtenu {out_path}"
assert os.path.exists(out_path)
def test_heartbeat_est_downscale(tmp_path):
"""Un heartbeat est downscalé (largeur réduite) et reste JPEG."""
from agent_v0.agent_v1.vision.capture_io import save_capture
img = _noisy_image(2560, 1600)
out_path = save_capture(img, str(tmp_path / "heartbeat_1234"), kind="heartbeat")
assert out_path.endswith(".jpg"), f"heartbeat doit être JPEG, obtenu {out_path}"
reread = Image.open(out_path)
assert reread.width < 2560, "heartbeat doit être downscalé en largeur"
# Ratio préservé (16:10 → la hauteur doit suivre la largeur réduite).
ratio_src = 2560 / 1600
ratio_out = reread.width / reread.height
assert abs(ratio_src - ratio_out) < 0.02, "le ratio doit être préservé"
def test_heartbeat_plus_leger_que_full_jpeg(tmp_path):
"""Le downscale du heartbeat le rend plus léger que le full JPEG plein res."""
from agent_v0.agent_v1.vision.capture_io import save_capture
img = _noisy_image(2560, 1600)
hb = save_capture(img, str(tmp_path / "heartbeat_5678"), kind="heartbeat")
full = save_capture(img, str(tmp_path / "shot_9999_full"), kind="full")
assert os.path.getsize(hb) < os.path.getsize(full), (
"le heartbeat downscalé doit peser moins que le full JPEG plein res"
)
def test_kind_inconnu_leve_erreur(tmp_path):
"""Un kind non reconnu doit échouer explicitement (fail-closed)."""
from agent_v0.agent_v1.vision.capture_io import save_capture
img = _noisy_image(40, 40)
try:
save_capture(img, str(tmp_path / "x"), kind="inexistant")
except ValueError:
return
raise AssertionError("un kind inconnu doit lever ValueError")
def test_rgba_converti_pour_jpeg(tmp_path):
"""Une image RGBA doit être convertie avant l'encodage JPEG (pas d'alpha)."""
from agent_v0.agent_v1.vision.capture_io import save_capture
img = Image.new("RGBA", (64, 64), (10, 20, 30, 128))
out_path = save_capture(img, str(tmp_path / "shot_rgba_full"), kind="full")
assert out_path.endswith(".jpg")
assert os.path.exists(out_path)