"""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})" )