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.
156 lines
5.7 KiB
Python
156 lines
5.7 KiB
Python
"""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)
|