Ferme Gap C : _edge_to_normalized_actions produit désormais une action navigate (handler serveur atteignable). Ajoute _coerce_action_coords : cast x_pct/y_pct en float APRÈS résolution des templates, JAMAIS de fallback (0,0) — template non résolu / valeur invalide → pause_for_human (safety_level=high). Non-régression prouvée sur mouse_click classiques (idempotent sur floats). ⚠️ NE FERME PAS le write-only : Gap A (P1-B) non livré — aucun step click/type ne déclare encore consommer navigate_login_coords. TestCompilerGapLiteralFloats assert l'état ouvert. Boucle complète = chantier suivant (P1-B + test e2e edge→action). 21 tests verts, boot OK. Revue croisée Claude (GO jalon partiel). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
378 lines
15 KiB
Python
378 lines
15 KiB
Python
"""Tests documenting the coords consumption gap: write-only navigate coords.
|
|
|
|
Test 1 (POSITIVE): _resolve_runtime_vars mechanism works — template strings
|
|
like {{navigate_login_coords.x_pct}} resolve correctly when variables dict
|
|
contains the stored coords.
|
|
|
|
Test 2 (NEGATIVE): _edge_to_normalized_actions bakes coords as literal floats,
|
|
never producing template strings — so runtime variable resolution is never
|
|
triggered for navigate coords, proving the write-only gap.
|
|
|
|
These tests are evidence, not regression guards. Test 2 documents a known
|
|
structural gap; when the gap is fixed, Test 2 should be updated to assert
|
|
templates ARE produced.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
from types import SimpleNamespace
|
|
|
|
os.environ.setdefault("RPA_AUTH_DISABLED", "true")
|
|
|
|
from agent_v0.server_v1.replay_engine import (
|
|
_edge_to_normalized_actions,
|
|
_resolve_runtime_vars,
|
|
_resolve_runtime_vars_in_str,
|
|
_coerce_action_coords,
|
|
)
|
|
|
|
|
|
# ── Fake fixtures (minimal, per test_visual_anchor_semantics.py pattern) ──
|
|
|
|
|
|
class _FakeAction:
|
|
def __init__(self, type_, target=None, parameters=None):
|
|
self.type = type_
|
|
self.target = target
|
|
self.parameters = parameters or {}
|
|
|
|
|
|
class _FakeEdge:
|
|
def __init__(self, action):
|
|
self.edge_id = "edge_coords_gap"
|
|
self.from_node = "node_src"
|
|
self.to_node = "node_dst"
|
|
self.action = action
|
|
|
|
|
|
# ── Test 1: resolve mechanism is viable ──────────────────────────────────
|
|
|
|
|
|
class TestResolveRuntimeVarsViable:
|
|
"""Prove _resolve_runtime_vars infrastructure works with template strings."""
|
|
|
|
VARIABLES = {
|
|
"navigate_login_coords": {
|
|
"x_pct": 0.15,
|
|
"y_pct": 0.07,
|
|
"method": "ocr_anchor",
|
|
}
|
|
}
|
|
|
|
def test_resolve_in_str_dot_path(self):
|
|
"""{{navigate_login_coords.x_pct}} → "0.15" (string, not float)."""
|
|
result = _resolve_runtime_vars_in_str(
|
|
"{{navigate_login_coords.x_pct}}", self.VARIABLES
|
|
)
|
|
assert result == "0.15"
|
|
|
|
def test_resolve_in_str_y_pct(self):
|
|
"""{{navigate_login_coords.y_pct}} → "0.07"."""
|
|
result = _resolve_runtime_vars_in_str(
|
|
"{{navigate_login_coords.y_pct}}", self.VARIABLES
|
|
)
|
|
assert result == "0.07"
|
|
|
|
def test_resolve_dict_with_templates(self):
|
|
"""_resolve_runtime_vars substitutes templates inside dict values."""
|
|
action = {
|
|
"type": "click",
|
|
"x_pct": "{{navigate_login_coords.x_pct}}",
|
|
"y_pct": "{{navigate_login_coords.y_pct}}",
|
|
}
|
|
resolved = _resolve_runtime_vars(action, self.VARIABLES)
|
|
assert resolved["x_pct"] == "0.15"
|
|
assert resolved["y_pct"] == "0.07"
|
|
assert resolved["type"] == "click" # no-template strings unchanged
|
|
|
|
def test_resolve_nested_dict(self):
|
|
"""_resolve_runtime_vars handles nested dicts with templates."""
|
|
action = {
|
|
"parameters": {
|
|
"coords": "{{navigate_login_coords.x_pct}}",
|
|
},
|
|
}
|
|
resolved = _resolve_runtime_vars(action, self.VARIABLES)
|
|
assert resolved["parameters"]["coords"] == "0.15"
|
|
|
|
def test_resolve_missing_var_leaves_template_intact(self):
|
|
"""Missing variable: template string stays unchanged."""
|
|
result = _resolve_runtime_vars_in_str(
|
|
"{{navigate_password_coords.x_pct}}", self.VARIABLES
|
|
)
|
|
assert "{{navigate_password_coords.x_pct}}" in result
|
|
|
|
def test_resolve_float_passthrough(self):
|
|
"""_resolve_runtime_vars returns non-str values unchanged — floats pass through."""
|
|
action = {"x_pct": 0.15, "y_pct": 0.07}
|
|
resolved = _resolve_runtime_vars(action, self.VARIABLES)
|
|
# Floats are NOT substituted — they're not strings containing {{...}}
|
|
assert resolved["x_pct"] == 0.15 # literal float, unchanged
|
|
assert resolved["y_pct"] == 0.07
|
|
|
|
|
|
# ── Test 2: compiler gap — literals not templates ────────────────────────
|
|
|
|
|
|
class TestCompilerGapLiteralFloats:
|
|
"""Document that _edge_to_normalized_actions produces literal floats,
|
|
never template strings — so navigate coords are write-only.
|
|
|
|
This is the STRUCTURAL GAP: the compiler bakes coords as floats,
|
|
_resolve_runtime_vars only operates on strings, so stored navigate
|
|
variables are never consumed downstream.
|
|
"""
|
|
|
|
def test_mouse_click_produces_literal_floats(self):
|
|
"""mouse_click edge: x_pct/y_pct are literal floats, not templates."""
|
|
target = SimpleNamespace(
|
|
by_position=(0.15, 0.07),
|
|
by_role=None,
|
|
by_text=None,
|
|
context_hints={},
|
|
)
|
|
edge = _FakeEdge(
|
|
_FakeAction("mouse_click", target=target, parameters={"button": "left"})
|
|
)
|
|
actions = _edge_to_normalized_actions(edge, params={})
|
|
assert len(actions) == 1
|
|
action = actions[0]
|
|
|
|
# GAP: coords are literal floats, not template strings
|
|
assert isinstance(action["x_pct"], float)
|
|
assert isinstance(action["y_pct"], float)
|
|
assert action["x_pct"] == 0.15
|
|
assert action["y_pct"] == 0.07
|
|
|
|
# Proof: no template string is ever produced by the compiler
|
|
assert not isinstance(action["x_pct"], str)
|
|
assert not isinstance(action["y_pct"], str)
|
|
|
|
def test_literal_floats_not_resolved(self):
|
|
"""Literal floats pass through _resolve_runtime_vars unchanged —
|
|
proving navigate coords stored in variables are NEVER consumed."""
|
|
target = SimpleNamespace(
|
|
by_position=(0.15, 0.07),
|
|
by_role=None,
|
|
by_text=None,
|
|
context_hints={},
|
|
)
|
|
edge = _FakeEdge(
|
|
_FakeAction("mouse_click", target=target, parameters={"button": "left"})
|
|
)
|
|
actions = _edge_to_normalized_actions(edge, params={})
|
|
action = actions[0]
|
|
|
|
# Simulate variables from a prior navigate_login step
|
|
different_coords = {
|
|
"navigate_login_coords": {"x_pct": 0.20, "y_pct": 0.10}
|
|
}
|
|
resolved = _resolve_runtime_vars(action, different_coords)
|
|
|
|
# Coords REMAIN the original literal floats — no substitution
|
|
assert resolved["x_pct"] == 0.15 # NOT 0.20 (no substitution)
|
|
assert resolved["y_pct"] == 0.07 # NOT 0.10 (no substitution)
|
|
|
|
def test_text_input_produces_literal_floats(self):
|
|
"""text_input edge: same literal float pattern for click target."""
|
|
target = SimpleNamespace(
|
|
by_position=(0.30, 0.50),
|
|
by_role=None,
|
|
by_text=None,
|
|
context_hints={},
|
|
)
|
|
edge = _FakeEdge(
|
|
_FakeAction("text_input", target=target, parameters={"text": "admin"})
|
|
)
|
|
actions = _edge_to_normalized_actions(edge, params={})
|
|
assert len(actions) == 1
|
|
action = actions[0]
|
|
|
|
assert isinstance(action["x_pct"], float)
|
|
assert isinstance(action["y_pct"], float)
|
|
assert action["x_pct"] == 0.30
|
|
assert action["y_pct"] == 0.50
|
|
|
|
def test_navigate_action_type_handled(self):
|
|
"""navigate action type IS now handled by _edge_to_normalized_actions —
|
|
produces a normalized action dict with type='navigate' and parameters."""
|
|
edge = _FakeEdge(_FakeAction("navigate", parameters={"action": "login"}))
|
|
actions = _edge_to_normalized_actions(edge, params={})
|
|
|
|
assert len(actions) == 1
|
|
action = actions[0]
|
|
assert action["type"] == "navigate"
|
|
assert "parameters" in action
|
|
assert action["parameters"]["action"] == "login"
|
|
assert action["parameters"]["login_coords_var"] == "navigate_login_coords"
|
|
assert action["parameters"]["password_coords_var"] == "navigate_password_coords"
|
|
assert action["parameters"]["submit_coords_var"] == "navigate_submit_coords"
|
|
|
|
|
|
class TestNavigateBranchNonRegression:
|
|
"""Non-regression tests for the navigate branch in _edge_to_normalized_actions.
|
|
|
|
These verify the D1 fix: navigate action type now produces a proper
|
|
normalized dict that the server-side dispatch can route to
|
|
_handle_navigate_action.
|
|
"""
|
|
|
|
def test_navigate_default_params(self):
|
|
"""Navigate with minimal params fills defaults."""
|
|
edge = _FakeEdge(_FakeAction("navigate", parameters={}))
|
|
actions = _edge_to_normalized_actions(edge, params={})
|
|
|
|
assert len(actions) == 1
|
|
action = actions[0]
|
|
assert action["type"] == "navigate"
|
|
assert action["parameters"]["action"] == "login"
|
|
assert action["parameters"]["login_coords_var"] == "navigate_login_coords"
|
|
assert action["parameters"]["password_coords_var"] == "navigate_password_coords"
|
|
assert action["parameters"]["submit_coords_var"] == "navigate_submit_coords"
|
|
|
|
def test_navigate_custom_vars(self):
|
|
"""Navigate with custom coords_var names propagates them."""
|
|
edge = _FakeEdge(
|
|
_FakeAction(
|
|
"navigate",
|
|
parameters={
|
|
"login_coords_var": "login_pos",
|
|
"password_coords_var": "pwd_pos",
|
|
"submit_coords_var": "btn_pos",
|
|
},
|
|
)
|
|
)
|
|
actions = _edge_to_normalized_actions(edge, params={})
|
|
|
|
assert len(actions) == 1
|
|
params = actions[0]["parameters"]
|
|
assert params["login_coords_var"] == "login_pos"
|
|
assert params["password_coords_var"] == "pwd_pos"
|
|
assert params["submit_coords_var"] == "btn_pos"
|
|
|
|
def test_navigate_login_config_overrides(self):
|
|
"""Navigate forwards login_config keys to parameters."""
|
|
edge = _FakeEdge(
|
|
_FakeAction(
|
|
"navigate",
|
|
parameters={
|
|
"login_field": "username",
|
|
"password_field": "pass",
|
|
"submit_button": "connexion",
|
|
"context": "DPI urgences",
|
|
},
|
|
)
|
|
)
|
|
actions = _edge_to_normalized_actions(edge, params={})
|
|
|
|
assert len(actions) == 1
|
|
params = actions[0]["parameters"]
|
|
assert params["login_field"] == "username"
|
|
assert params["password_field"] == "pass"
|
|
assert params["submit_button"] == "connexion"
|
|
assert params["context"] == "DPI urgences"
|
|
|
|
def test_navigate_base_fields_present(self):
|
|
"""Navigate action retains edge_id, from_node, to_node, action_id."""
|
|
edge = _FakeEdge(_FakeAction("navigate", parameters={"action": "login"}))
|
|
actions = _edge_to_normalized_actions(edge, params={})
|
|
|
|
action = actions[0]
|
|
assert "edge_id" in action
|
|
assert "from_node" in action
|
|
assert "to_node" in action
|
|
assert "action_id" in action
|
|
assert action["edge_id"] == "edge_coords_gap"
|
|
assert action["from_node"] == "node_src"
|
|
assert action["to_node"] == "node_dst"
|
|
|
|
def test_navigate_no_x_y_pct(self):
|
|
"""Navigate action does NOT include x_pct/y_pct — coords come from handler."""
|
|
edge = _FakeEdge(_FakeAction("navigate", parameters={"action": "login"}))
|
|
actions = _edge_to_normalized_actions(edge, params={})
|
|
|
|
action = actions[0]
|
|
assert "x_pct" not in action
|
|
assert "y_pct" not in action
|
|
|
|
|
|
# ── Test P1-C: _coerce_action_coords ──────────────────────────────────
|
|
|
|
|
|
class TestCoerceActionCoords:
|
|
"""P1-C coercion helper: cast x_pct/y_pct strings to floats after
|
|
_resolve_runtime_vars template resolution.
|
|
|
|
Chain: navigate → variables → _resolve_runtime_vars → strings →
|
|
_coerce_action_coords → floats. Fail-safe on unresolved/invalid.
|
|
"""
|
|
|
|
def test_float_idempotent(self):
|
|
"""Float values pass through unchanged — existing mouse_click actions unaffected."""
|
|
action = {"type": "click", "x_pct": 0.15, "y_pct": 0.07}
|
|
result = _coerce_action_coords(action)
|
|
assert result["x_pct"] == 0.15
|
|
assert result["y_pct"] == 0.07
|
|
assert result["type"] == "click"
|
|
|
|
def test_string_to_float_conversion(self):
|
|
"""Resolved template strings "0.35" → 0.35 (float) after _resolve_runtime_vars."""
|
|
action = {"type": "click", "x_pct": "0.35", "y_pct": "0.07"}
|
|
result = _coerce_action_coords(action)
|
|
assert result["x_pct"] == 0.35
|
|
assert isinstance(result["x_pct"], float)
|
|
assert result["y_pct"] == 0.07
|
|
assert isinstance(result["y_pct"], float)
|
|
assert result["type"] == "click"
|
|
|
|
def test_unresolved_template_pause_for_human(self):
|
|
"""Unresolved {{var.field}} template → pause_for_human, never fallback 0.0."""
|
|
action = {"type": "click", "x_pct": "{{navigate_login_coords.x_pct}}", "y_pct": 0.07}
|
|
result = _coerce_action_coords(action)
|
|
assert result["type"] == "pause_for_human"
|
|
assert result["safety_level"] == "high"
|
|
assert "coords_var non résolu" in result["_skip_reason"]
|
|
assert "{{navigate_login_coords.x_pct}}" in result["_skip_reason"]
|
|
|
|
def test_invalid_string_pause_for_human(self):
|
|
"""Non-convertible string "abc" → pause_for_human, no fallback coords."""
|
|
action = {"type": "click", "x_pct": "abc", "y_pct": 0.07}
|
|
result = _coerce_action_coords(action)
|
|
assert result["type"] == "pause_for_human"
|
|
assert result["safety_level"] == "high"
|
|
assert "coords invalide" in result["_skip_reason"]
|
|
assert "abc" in result["_skip_reason"]
|
|
|
|
def test_no_coords_keys_unchanged(self):
|
|
"""Action without x_pct/y_pct passes through unchanged."""
|
|
action = {"type": "navigate", "parameters": {"action": "login"}}
|
|
result = _coerce_action_coords(action)
|
|
assert result == action
|
|
|
|
def test_full_chain_resolve_then_coerce(self):
|
|
"""Full chain: _resolve_runtime_vars → _coerce_action_coords → floats."""
|
|
variables = {
|
|
"navigate_login_coords": {
|
|
"x_pct": 0.15,
|
|
"y_pct": 0.35,
|
|
"method": "ocr_anchor",
|
|
}
|
|
}
|
|
action = {
|
|
"type": "click",
|
|
"x_pct": "{{navigate_login_coords.x_pct}}",
|
|
"y_pct": "{{navigate_login_coords.y_pct}}",
|
|
}
|
|
# Step 1: resolve templates (produces strings)
|
|
resolved = _resolve_runtime_vars(action, variables)
|
|
assert resolved["x_pct"] == "0.15" # string after resolver
|
|
assert resolved["y_pct"] == "0.35" # string after resolver
|
|
|
|
# Step 2: coerce strings to floats
|
|
coerced = _coerce_action_coords(resolved)
|
|
assert coerced["x_pct"] == 0.15 # float after coercion
|
|
assert isinstance(coerced["x_pct"], float)
|
|
assert coerced["y_pct"] == 0.35
|
|
assert isinstance(coerced["y_pct"], float)
|
|
assert coerced["type"] == "click"
|