"""Tests offline des dataclasses cognition (Phase 2.1 + workpacks A/B). Pas de runtime, pas de réseau. Sérialisation JSON + invariants. """ from __future__ import annotations import json import pytest from core.cognition import ( Precondition, PreconditionRecovery, SceneExpected, Trace, ) # ============================================================================= # Trace # ============================================================================= def test_trace_empty_default(): t = Trace() assert t.is_empty() assert t.mandate_id == "" assert t.intention_id == "" assert t.level_of_delegation == 0 def test_trace_roundtrip_json(): t = Trace( mandate_id="m1", intention_id="ouvrir_notepad", scene_id="bloc_notes_vierge", affordance_signature="btn:Enregistrer@SaveAs", expected_retour="dialog Enregistrer sous apparaît", level_of_delegation=2, ) payload = json.dumps(t.to_dict()) loaded = Trace.from_dict(json.loads(payload)) assert loaded == t assert not loaded.is_empty() def test_trace_from_dict_none_returns_empty(): assert Trace.from_dict(None) == Trace() def test_trace_from_dict_partial_tolerated(): loaded = Trace.from_dict({"mandate_id": "m1"}) assert loaded.mandate_id == "m1" assert loaded.intention_id == "" assert loaded.level_of_delegation == 0 def test_trace_from_dict_level_must_be_int_parseable(): # Contrat documenté : from_dict tolère absent/None/0, pas une string non-int. # Le caller serveur est responsable de produire un int valide. with pytest.raises(ValueError): Trace.from_dict({"level_of_delegation": "not_a_number"}) # ============================================================================= # SceneExpected # ============================================================================= def test_scene_expected_empty_default(): s = SceneExpected() assert s.is_empty() assert s.monitor_index is None def test_scene_expected_roundtrip_json(): s = SceneExpected( scene_id="bloc_notes_editor", app_name="Notepad", title_patterns=("Sans titre", "Untitled"), title_anti=(".txt", ".md"), monitor_index=1, monitor_geometry=(0, 0, 1920, 1080), window_rect_hint=(100, 100, 1000, 800), scene_role="editor", required=True, stability_ms=200, accepted_transitions=("save_as_dialog", "confirm_save"), ) payload = json.dumps(s.to_dict()) loaded = SceneExpected.from_dict(json.loads(payload)) assert loaded == s def test_scene_expected_matches_title_anti_blocks(): s = SceneExpected( app_name="Notepad", title_patterns=("Sans titre",), title_anti=(".txt",), ) assert s.matches_title("Sans titre – Bloc-notes") is True assert s.matches_title("monfichier.txt – Bloc-notes") is False assert s.matches_title("Sans titre.txt") is False # anti l'emporte def test_scene_expected_matches_title_no_patterns_accepts_all_except_anti(): s = SceneExpected(app_name="Notepad", title_anti=("Chrome",)) assert s.matches_title("Bloc-notes") is True assert s.matches_title("Google Chrome") is False def test_scene_expected_matches_title_empty_observed_false(): s = SceneExpected(title_patterns=("Notepad",)) assert s.matches_title("") is False def test_scene_expected_from_dict_bad_geometry_falls_back_none(): s = SceneExpected.from_dict({"monitor_geometry": [1, 2, 3]}) # pas 4 valeurs assert s.monitor_geometry is None # ============================================================================= # Precondition # ============================================================================= def test_precondition_noop_default(): p = Precondition() assert p.is_noop() assert p.kind == "noop" def test_precondition_invalid_kind_raises(): with pytest.raises(ValueError): Precondition(kind="unknown_kind") def test_precondition_window_title_check_must_contain(): p = Precondition( kind="window_title", window_title_must_contain=("Sans titre", "Untitled"), ) assert p.check_title("Sans titre – Bloc-notes") is True assert p.check_title("Untitled - Notepad") is True assert p.check_title("monfichier.txt – Bloc-notes") is False def test_precondition_window_title_anti_blocks_even_if_must_present(): p = Precondition( kind="window_title", window_title_must_contain=("Bloc-notes",), window_title_must_not_contain=(".txt",), ) assert p.check_title("Sans titre – Bloc-notes") is True assert p.check_title("rapport.txt – Bloc-notes") is False def test_precondition_check_title_noop_returns_true(): """Précondition noop ne bloque jamais — comportement par défaut.""" p = Precondition(kind="noop") assert p.check_title("n'importe quoi") is True def test_precondition_roundtrip_json(): p = Precondition( kind="window_title", window_title_must_contain=("Sans titre",), window_title_must_not_contain=(".txt",), critic_question="Le document est-il vide et non nommé ?", verify_timeout_ms=3000, ) payload = json.dumps(p.to_dict()) loaded = Precondition.from_dict(json.loads(payload)) assert loaded == p # ============================================================================= # PreconditionRecovery # ============================================================================= def test_recovery_default_empty(): r = PreconditionRecovery() assert r.is_empty() assert r.max_attempts == 1 assert r.on_recovery_fail == "pause" def test_recovery_invalid_fail_action_raises(): with pytest.raises(ValueError): PreconditionRecovery(on_recovery_fail="continue_aveuglement") def test_recovery_negative_attempts_raises(): with pytest.raises(ValueError): PreconditionRecovery(max_attempts=-1) def test_recovery_with_actions_serializable(): r = PreconditionRecovery( max_attempts=1, on_recovery_fail="pause", actions=( {"type": "key_combo", "keys": ["ctrl", "n"]}, {"type": "wait", "duration_ms": 500}, ), ) payload = json.dumps(r.to_dict()) loaded = PreconditionRecovery.from_dict(json.loads(payload)) assert loaded == r assert not loaded.is_empty() def test_recovery_from_dict_filters_non_dict_actions(): r = PreconditionRecovery.from_dict({ "actions": [ {"type": "key_combo"}, "ignored_string", None, 42, {"type": "wait"}, ], }) assert len(r.actions) == 2