Files
rpa_vision_v3/tests/unit/test_cognition_dataclasses.py
Dom 7bb8d543ab feat(cognition): dataclasses Trace + SceneExpected + Precondition (Phase 2.1)
Crée les 3 dataclasses du modèle Mandat/Protocoles/Scènes v0.3 dans
core/cognition/, standalone (aucun branchement runtime), avec
sérialisation JSON explicite et tests offline.

Préparation des phases :
- Phase 2.1 plan : objet Trace (mandate_id, intention_id, scene_id,
  affordance_signature, expected_retour, level_of_delegation)
- Workpack A : SceneExpected (monitor_index, app_name, title_patterns,
  title_anti, window_rect_hint, scene_role, accepted_transitions,
  stability_ms) + helper matches_title()
- Workpack B : Precondition (kind, window_title_must_contain/anti,
  critic_question, verify_timeout_ms) + PreconditionRecovery
  (max_attempts, on_recovery_fail, actions)

Toutes les dataclasses sont frozen, immutables, avec to_dict/from_dict
tolérants (champs vides/None -> instance vide). Validation au __post_init__
pour Precondition.kind et PreconditionRecovery.on_recovery_fail.

Aucune dépendance runtime obligatoire : si l'objet n'est pas posé sur
une action, fallback comportement actuel. Aucune modif executor /
api_stream / replay_engine / grounding.

Tests : 22/22 passent (sérialisation JSON, contrats from_dict tolérants,
validation kinds, helpers matches_title/check_title, anti-intention).

Tag rollback : rollback/pre-cognition-dataclasses-2026-05-25_0610
2026-05-25 06:08:18 +02:00

226 lines
6.6 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 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