Files
rpa_vision_v3/tests/integration/test_replay_session_trim_neutral.py

153 lines
6.2 KiB
Python
Raw Permalink 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.
"""Non-régression — trim du préambule redondant pour /replay-session.
Bug fixé le 2026-05-20 (cf. ``docs/AUDIT_FINALIZE_CONTRACT_INTEGRATION_2026-05-20.md``
et ``CR_AUDIT_PAUSED_RESUME_BUS_2026-05-22.md``) : sur la session source
``sess_20260520T102916_066851``, le premier event raw rejoué après le
setup auto Windows était un clic intra-Notepad sur la barre d'onglets
qui basculait de ``http...txt Bloc-notes`` vers ``Sans titre Bloc-notes``.
Comme le setup amène déjà Notepad dans ``Sans titre``, ce clic ne
modifiait rien à l'écran → `retry_threshold`.
Ce test reproduit la chaîne complète d'``api_stream.replay-session``
côté serveur (sans HTTP) sur une fixture synthétique correspondante,
et vérifie que la première action utile post-setup est bien la
saisie de texte ``test`` — pas un clic de bascule d'onglet ``Sans titre``.
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(ROOT))
import pytest
from agent_v0.server_v1.replay_engine import ( # noqa: E402
_extract_required_apps_from_events,
_generate_setup_actions,
_trim_redundant_setup_events,
)
from agent_v0.server_v1.stream_processor import ( # noqa: E402
build_replay_from_raw_events,
)
def _make_session_events() -> list:
"""Reproduit le pattern de ``sess_20260520T102916_066851`` :
Démarrer → Rechercher → Notepad ouvre un fichier .txt → l'utilisateur
clique sur l'onglet ``Sans titre`` → tape ``test`` → Ctrl+S.
L'enregistrement initial passe par un titre non-neutre puis bascule
sur un titre neutre — c'est le scénario qui piégeait le trim."""
return [
# Démarrer
{"event": {
"type": "window_focus_change",
"to": {"app_name": "explorer.exe", "title": "Explorateur"},
}},
{"event": {
"type": "mouse_click", "pos": [50, 1430], "timestamp": 1.0,
"window": {"app_name": "explorer.exe", "title": "Explorateur"},
}},
# SearchHost
{"event": {
"type": "window_focus_change",
"to": {"app_name": "SearchHost.exe", "title": "Rechercher"},
}},
{"event": {
"type": "text_input", "text": "bloc", "timestamp": 2.0,
"window": {"app_name": "SearchHost.exe", "title": "Rechercher"},
}},
{"event": {
"type": "mouse_click", "pos": [681, 448], "timestamp": 2.5,
"window": {"app_name": "SearchHost.exe", "title": "Rechercher"},
}},
# Notepad ouvre un fichier .txt existant (non-neutre)
{"event": {
"type": "window_focus_change",
"to": {
"app_name": "Notepad.exe",
"title": "http192.168.1.408765dossier.htmlid=.txt Bloc-notes",
},
}},
# Clic dans la barre d'onglets (y=40) → bascule vers Sans titre
{"event": {
"type": "mouse_click", "pos": [1191, 40], "timestamp": 4.0,
"window": {
"app_name": "Notepad.exe",
"title": "http192.168.1.408765dossier.htmlid=.txt Bloc-notes",
},
"window_capture": {"click_relative": [1191, 40]},
}},
{"event": {
"type": "window_focus_change",
"to": {"app_name": "Notepad.exe", "title": "Sans titre Bloc-notes"},
}},
# Saisie réelle de l'utilisateur — c'est la première action utile
{"event": {
"type": "text_input", "text": "test", "timestamp": 5.0,
"window": {"app_name": "Notepad.exe",
"title": "Sans titre Bloc-notes"},
}},
]
def test_replay_session_pipeline_skips_redundant_tab_switch(tmp_path):
"""Pipeline complet replay-session : setup auto + trim + build doit
produire un replay dont la première action post-setup est la saisie
``test``, pas le clic de bascule d'onglet ``Sans titre``.
"""
raw_events = _make_session_events()
app_info = _extract_required_apps_from_events(raw_events)
# 1) Setup auto reconnaît Notepad et génère ses actions
assert app_info.get("primary_app") == "Notepad.exe"
assert app_info.get("has_neutral_window_title") is True
setup_actions = _generate_setup_actions(app_info, setup_id_prefix="setup_sess")
assert setup_actions, "le setup auto doit injecter des actions Notepad"
setup_steps = [a.get("_setup_step", "") for a in setup_actions]
assert "open_run_dialog" in setup_steps
assert "ensure_fresh_document" in setup_steps
# 2) Trim : le clic intra-Notepad redondant doit disparaître
trimmed = _trim_redundant_setup_events(raw_events, app_info)
click_titles = [
(ev.get("event") or ev).get("window", {}).get("title", "")
for ev in trimmed
if (ev.get("event") or ev).get("type") == "mouse_click"
]
assert not any(
"http192.168.1.40" in t for t in click_titles
), "le clic intra-Notepad redondant doit être coupé par le trim"
# 3) Build replay propre : la première action utile post-trim est
# la saisie 'test' — pas un click "Sans titre" issu de
# _infer_tab_switch_target.
actions = build_replay_from_raw_events(
trimmed, session_id="sess_synthetic", session_dir=str(tmp_path),
)
actionable = [a for a in actions if a.get("type") in ("click", "type", "key_combo")]
assert actionable, "le replay doit contenir au moins une action utile"
first = actionable[0]
assert first.get("type") == "type", (
f"première action utile doit être 'type', pas '{first.get('type')}' "
f"(target_spec={first.get('target_spec')})"
)
assert first.get("text") == "test"
# Sanity : aucune action click ne doit cibler "Sans titre" (= la
# bascule d'onglet inférée par _infer_tab_switch_target) dans le
# replay nettoyé.
sans_titre_clicks = [
a for a in actions
if a.get("type") == "click"
and a.get("target_spec", {}).get("by_text", "").strip().lower() == "sans titre"
]
assert not sans_titre_clicks, (
"le replay ne doit plus contenir de click ciblant 'Sans titre' "
f"(trouvés : {sans_titre_clicks})"
)