- agent_v0/server_v1/core/dialog/ : catalogue compact + DialogResolver stateless (match titre + evidence, trichotomie stricte auto/pause/skip). - 10 entrées P0 : confirm-save-overwrite, notepad-unsaved-changes, windows-file-explorer (fallback replay 4c38dbb8), easily-save/overwrite/ confirm-action/clinical-warning, windows-uac, windows-hello-credui, edge-update. - Validateur déclaratif `system_modals_cannot_be_overridden` : rejette toute surcharge auto/skip sur modaux SYSTÈME (windows-/defender-). - Endpoint POST /api/v1/dialog/resolve derrière flag RPA_DIALOG_RESOLVER_ENABLED (OFF par défaut → 503). Aucun rebranchement côté agent_v1 (executor.py inchangé, P1 plus tard). - 25 tests pytest passants (19 unit + 6 intégration HTTP). Spec : docs/recherche/SPEC_POPUPS_CATALOGUE.md §2bis / §3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
228 lines
8.0 KiB
Python
228 lines
8.0 KiB
Python
"""Tests unitaires DialogResolver — R2 MVP P0.
|
||
|
||
Couvre :
|
||
- match par titre seul (catalogue de base).
|
||
- match titre + evidence (Bloc-notes : titre ambigu).
|
||
- pas de match → policy conservative "pause".
|
||
- override déclaratif autorisé pour Easily/non-système.
|
||
- validateur sécurité : refuse `auto`/`skip` sur préfixes
|
||
`windows-` / `defender-` (modaux SYSTÈME).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
|
||
_ROOT = str(Path(__file__).resolve().parents[2])
|
||
if _ROOT not in sys.path:
|
||
sys.path.insert(0, _ROOT)
|
||
|
||
|
||
from agent_v0.server_v1.core.dialog import ( # noqa: E402
|
||
DeclarativeOverride,
|
||
DialogResolver,
|
||
KNOWN_DIALOGS,
|
||
SystemModalOverrideError,
|
||
system_modals_cannot_be_overridden,
|
||
)
|
||
|
||
|
||
pytestmark = pytest.mark.unit
|
||
|
||
|
||
class TestCatalog:
|
||
"""Le catalogue P0 contient au moins les 10 entrées requises."""
|
||
|
||
REQUIRED_IDS = {
|
||
"confirm-save-overwrite",
|
||
"notepad-unsaved-changes",
|
||
"windows-file-explorer",
|
||
"easily-save-unconfirmed",
|
||
"easily-overwrite-file",
|
||
"easily-confirm-action",
|
||
"easily-clinical-warning",
|
||
"windows-uac",
|
||
"windows-hello-credui",
|
||
"edge-update",
|
||
}
|
||
|
||
def test_catalog_has_p0_entries(self):
|
||
assert self.REQUIRED_IDS.issubset(KNOWN_DIALOGS.keys())
|
||
|
||
def test_system_modals_not_overridable(self):
|
||
"""UAC + Hello + file-explorer system : `declarative_override` False."""
|
||
for modal_id in ("windows-uac", "windows-hello-credui"):
|
||
assert KNOWN_DIALOGS[modal_id].declarative_override is False
|
||
assert KNOWN_DIALOGS[modal_id].policy == "pause"
|
||
|
||
def test_clinical_warning_not_overridable(self):
|
||
"""Clinical warning : décision médicale, jamais auto."""
|
||
spec = KNOWN_DIALOGS["easily-clinical-warning"]
|
||
assert spec.policy == "pause"
|
||
assert spec.declarative_override is False
|
||
|
||
|
||
class TestResolveByTitleOnly:
|
||
"""Match sur titre seul, sans evidence."""
|
||
|
||
def setup_method(self):
|
||
self.resolver = DialogResolver()
|
||
|
||
def test_confirm_save_overwrite_fr(self):
|
||
res = self.resolver.resolve("Confirmer l'enregistrement")
|
||
assert res.matched
|
||
assert res.dialog_id == "confirm-save-overwrite"
|
||
assert res.policy == "auto"
|
||
assert res.action is not None
|
||
assert res.action["button_label"] == "Oui"
|
||
|
||
def test_confirm_save_overwrite_en(self):
|
||
res = self.resolver.resolve("Confirm Save As")
|
||
assert res.matched
|
||
assert res.dialog_id == "confirm-save-overwrite"
|
||
|
||
def test_windows_uac_pause(self):
|
||
res = self.resolver.resolve(
|
||
"Contrôle de compte d'utilisateur",
|
||
evidence_texts=["Voulez-vous autoriser cette application"],
|
||
)
|
||
assert res.matched
|
||
assert res.dialog_id == "windows-uac"
|
||
assert res.policy == "pause"
|
||
assert res.action is None
|
||
|
||
def test_windows_file_explorer_fallback_pause(self):
|
||
"""Cas replay 4c38dbb8 — fallback file-explorer → pause humaine."""
|
||
res = self.resolver.resolve("rpa_vision : Explorateur de fichiers")
|
||
assert res.matched
|
||
assert res.dialog_id == "windows-file-explorer"
|
||
assert res.policy == "pause"
|
||
|
||
def test_no_match_returns_pause(self):
|
||
res = self.resolver.resolve("Fenêtre métier inconnue 42")
|
||
assert res.matched is False
|
||
assert res.dialog_id == ""
|
||
assert res.policy == "pause"
|
||
|
||
|
||
class TestResolveByTitleAndEvidence:
|
||
"""Match qui exige une evidence en plus du titre (Bloc-notes)."""
|
||
|
||
def setup_method(self):
|
||
self.resolver = DialogResolver()
|
||
|
||
def test_notepad_unsaved_changes_with_evidence(self):
|
||
res = self.resolver.resolve(
|
||
"Bloc-notes",
|
||
evidence_texts=[
|
||
"Voulez-vous enregistrer les modifications ?",
|
||
"Ne pas enregistrer",
|
||
],
|
||
)
|
||
assert res.matched
|
||
assert res.dialog_id == "notepad-unsaved-changes"
|
||
assert res.policy == "auto"
|
||
assert res.action["button_label"] == "Enregistrer"
|
||
|
||
def test_notepad_without_evidence_does_not_match(self):
|
||
"""Titre `Bloc-notes` seul est trop générique : pas de match."""
|
||
res = self.resolver.resolve("test.txt - Bloc-notes")
|
||
# Pas d'evidence → la spec evidence-required ne matche pas → pas matched.
|
||
assert res.matched is False
|
||
assert res.policy == "pause"
|
||
|
||
def test_case_insensitive_and_accent_resilient(self):
|
||
"""Normalisation : casse + apostrophes typographiques tolérées."""
|
||
res = self.resolver.resolve("CONFIRMER L’ENREGISTREMENT")
|
||
assert res.matched
|
||
assert res.dialog_id == "confirm-save-overwrite"
|
||
|
||
|
||
class TestSecurityValidator:
|
||
"""Validateur déclaratif : interdit auto/skip sur SYSTÈME."""
|
||
|
||
def test_uac_auto_override_raises(self):
|
||
override = DeclarativeOverride(dialog_id="windows-uac", policy="auto")
|
||
with pytest.raises(SystemModalOverrideError):
|
||
system_modals_cannot_be_overridden(override)
|
||
|
||
def test_uac_skip_override_raises(self):
|
||
override = DeclarativeOverride(dialog_id="windows-uac", policy="skip")
|
||
with pytest.raises(SystemModalOverrideError):
|
||
system_modals_cannot_be_overridden(override)
|
||
|
||
def test_defender_auto_override_raises(self):
|
||
override = DeclarativeOverride(
|
||
dialog_id="defender-smartscreen-app", policy="auto",
|
||
)
|
||
with pytest.raises(SystemModalOverrideError):
|
||
system_modals_cannot_be_overridden(override)
|
||
|
||
def test_uac_pause_override_passes(self):
|
||
"""`pause` reste valide même sur SYSTÈME (no-op)."""
|
||
override = DeclarativeOverride(dialog_id="windows-uac", policy="pause")
|
||
assert system_modals_cannot_be_overridden(override) is override
|
||
|
||
def test_easily_auto_override_passes(self):
|
||
"""Easily n'est pas SYSTÈME → surcharge auto autorisée par le validateur."""
|
||
override = DeclarativeOverride(
|
||
dialog_id="easily-save-unconfirmed",
|
||
policy="auto",
|
||
button_label="Enregistrer",
|
||
)
|
||
assert system_modals_cannot_be_overridden(override) is override
|
||
|
||
|
||
class TestDeclarativeOverrideAtResolve:
|
||
"""Le DialogResolver applique l'override quand la spec l'autorise."""
|
||
|
||
def setup_method(self):
|
||
self.resolver = DialogResolver()
|
||
|
||
def test_override_applied_on_overridable_spec(self):
|
||
"""edge-update : default `skip`, surchargeable en `auto`."""
|
||
override = DeclarativeOverride(
|
||
dialog_id="edge-update",
|
||
policy="auto",
|
||
button_label="Plus tard",
|
||
)
|
||
res = self.resolver.resolve(
|
||
"Microsoft Edge",
|
||
evidence_texts=["Microsoft Edge a été mis à jour"],
|
||
declarative_override=override,
|
||
)
|
||
assert res.matched
|
||
assert res.policy == "auto"
|
||
assert res.action is not None
|
||
assert res.action["button_label"] == "Plus tard"
|
||
|
||
def test_override_rejected_at_resolve_for_system_modal(self):
|
||
"""Defense in depth : resolve() refuse aussi auto sur SYSTÈME."""
|
||
override = DeclarativeOverride(dialog_id="windows-uac", policy="auto")
|
||
with pytest.raises(SystemModalOverrideError):
|
||
self.resolver.resolve(
|
||
"Contrôle de compte d'utilisateur",
|
||
evidence_texts=["Voulez-vous autoriser cette application"],
|
||
declarative_override=override,
|
||
)
|
||
|
||
def test_override_ignored_on_non_overridable_clinical_warning(self):
|
||
"""Clinical warning : surcharge ignorée silencieusement (declarative_override=False)."""
|
||
override = DeclarativeOverride(
|
||
dialog_id="easily-clinical-warning",
|
||
policy="auto",
|
||
button_label="OK",
|
||
)
|
||
res = self.resolver.resolve(
|
||
"Avertissement clinique",
|
||
evidence_texts=["Allergie connue"],
|
||
declarative_override=override,
|
||
)
|
||
assert res.matched
|
||
# La spec déclare declarative_override=False → policy reste pause.
|
||
assert res.policy == "pause"
|