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
226 lines
6.6 KiB
Python
226 lines
6.6 KiB
Python
"""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
|