from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
if TYPE_CHECKING:
from conftest import MockAXElement, TestApp
class TestHealingByDataTestId:
def test_healing_prefers_data_testid(
self, mock_calculator_tree: MockAXElement
) -> None:
tree = mock_calculator_tree
def find_by_data_testid(
node: MockAXElement, testid: str
) -> MockAXElement | None:
if node.data_testid == testid:
return node
for child in node.get_children():
result = find_by_data_testid(child, testid)
if result:
return result
return None
tree.get_children()[0].get_children()[1].get_children()[
5
].data_testid = "submit-button"
button = find_by_data_testid(tree, "submit-button")
assert button is not None
assert button.title == "5"
def test_data_testid_is_default_first_strategy(self) -> None:
import axterminator as ax
config = ax.HealingConfig()
assert config.strategies[0] == "data_testid"
def test_data_testid_stable_across_ui_changes(self) -> None:
pass
class TestHealingByAriaLabel:
def test_healing_by_aria_label(self, mock_calculator_tree: MockAXElement) -> None:
tree = mock_calculator_tree
def find_by_aria_label(node: MockAXElement, label: str) -> MockAXElement | None:
if node.aria_label == label:
return node
for child in node.get_children():
result = find_by_aria_label(child, label)
if result:
return result
return None
tree.get_children()[0].get_children()[1].get_children()[
7
].aria_label = "Clear all entries"
button = find_by_aria_label(tree, "Clear all entries")
assert button is not None
def test_aria_label_is_second_strategy(self) -> None:
import axterminator as ax
config = ax.HealingConfig()
assert config.strategies[1] == "aria_label"
def test_aria_label_for_accessibility(self) -> None:
pass
class TestHealingByIdentifier:
def test_healing_by_identifier(self, mock_calculator_tree: MockAXElement) -> None:
tree = mock_calculator_tree
def find_by_identifier(
node: MockAXElement, identifier: str
) -> MockAXElement | None:
if node.identifier == identifier:
return node
for child in node.get_children():
result = find_by_identifier(child, identifier)
if result:
return result
return None
button = find_by_identifier(tree, "calc_btn_5")
assert button is not None
assert button.title == "5"
def test_identifier_is_third_strategy(self) -> None:
import axterminator as ax
config = ax.HealingConfig()
assert config.strategies[2] == "identifier"
@pytest.mark.requires_app
def test_native_apps_have_identifiers(self, calculator_app: TestApp) -> None:
import axterminator as ax
app = ax.app(name="Calculator")
try:
element = app.find("1")
identifier = element.identifier()
assert identifier is None or isinstance(identifier, str)
except RuntimeError:
pass
class TestHealingByTitle:
def test_healing_by_title(self, mock_calculator_tree: MockAXElement) -> None:
tree = mock_calculator_tree
def find_by_title(node: MockAXElement, title: str) -> MockAXElement | None:
if node.title == title:
return node
for child in node.get_children():
result = find_by_title(child, title)
if result:
return result
return None
button = find_by_title(tree, "AC")
assert button is not None
def test_title_is_fourth_strategy(self) -> None:
import axterminator as ax
config = ax.HealingConfig()
assert config.strategies[3] == "title"
def test_title_may_change_with_localization(self) -> None:
pass
class TestHealingByXPath:
def test_healing_by_xpath(self, mock_calculator_tree: MockAXElement) -> None:
tree = mock_calculator_tree
def find_by_path(
node: MockAXElement,
path_parts: list[tuple[str, dict[str, str] | None]],
index: int = 0,
) -> MockAXElement | None:
if index >= len(path_parts):
return None
role, attrs = path_parts[index]
if node.role != role:
return None
if attrs:
for key, value in attrs.items():
if getattr(node, key, None) != value:
return None
if index == len(path_parts) - 1:
return node
for child in node.get_children():
result = find_by_path(child, path_parts, index + 1)
if result:
return result
return None
path = [
("AXApplication", None),
("AXWindow", None),
("AXGroup", None),
("AXButton", {"title": "5"}),
]
button = find_by_path(tree, path)
assert button is not None
assert button.title == "5"
def test_xpath_is_fifth_strategy(self) -> None:
import axterminator as ax
config = ax.HealingConfig()
assert config.strategies[4] == "xpath"
def test_xpath_sensitive_to_structure_changes(self) -> None:
pass
class TestHealingByPosition:
def test_healing_by_position(self, mock_calculator_tree: MockAXElement) -> None:
tree = mock_calculator_tree
def find_by_position(
node: MockAXElement,
target_x: float,
target_y: float,
best: tuple[MockAXElement | None, float] = (None, float("inf")),
) -> tuple[MockAXElement | None, float]:
if node.bounds:
x, y, w, h = node.bounds
center_x = x + w / 2
center_y = y + h / 2
distance = (
(center_x - target_x) ** 2 + (center_y - target_y) ** 2
) ** 0.5
if distance < best[1]:
best = (node, distance)
for child in node.get_children():
best = find_by_position(child, target_x, target_y, best)
return best
tree.get_children()[0].get_children()[1].get_children()[5].bounds = (
100,
200,
50,
50,
)
element, distance = find_by_position(tree, 125, 225)
assert element is not None
def test_position_is_sixth_strategy(self) -> None:
import axterminator as ax
config = ax.HealingConfig()
assert config.strategies[5] == "position"
def test_position_fragile_on_resize(self) -> None:
pass
class TestHealingConfig:
def test_default_config(self) -> None:
import axterminator as ax
config = ax.HealingConfig()
assert len(config.strategies) == 7
assert config.max_heal_time_ms == 100
assert config.cache_healed is True
def test_custom_strategies(self) -> None:
import axterminator as ax
config = ax.HealingConfig(strategies=["data_testid", "identifier"])
assert len(config.strategies) == 2
assert "title" not in config.strategies
def test_custom_timeout(self) -> None:
import axterminator as ax
config = ax.HealingConfig(max_heal_time_ms=500)
assert config.max_heal_time_ms == 500
def test_disable_caching(self) -> None:
import axterminator as ax
config = ax.HealingConfig(cache_healed=False)
assert config.cache_healed is False
def test_configure_healing_global(self) -> None:
import axterminator as ax
config = ax.HealingConfig(max_heal_time_ms=200)
ax.configure_healing(config)
def test_strategy_order_matters(self) -> None:
import axterminator as ax
config = ax.HealingConfig(strategies=["title", "identifier", "data_testid"])
assert config.strategies[0] == "title"
def test_empty_strategies_list(self) -> None:
import axterminator as ax
config = ax.HealingConfig(strategies=[])
assert len(config.strategies) == 0
def test_config_strategies_getter(self) -> None:
import axterminator as ax
config = ax.HealingConfig()
strategies = config.strategies
assert isinstance(strategies, list)
assert all(isinstance(s, str) for s in strategies)
def test_config_strategies_setter(self) -> None:
import axterminator as ax
config = ax.HealingConfig()
config.strategies = ["identifier", "title"]
assert len(config.strategies) == 2
def test_config_max_heal_time_getter(self) -> None:
import axterminator as ax
config = ax.HealingConfig(max_heal_time_ms=250)
assert config.max_heal_time_ms == 250
def test_config_max_heal_time_setter(self) -> None:
import axterminator as ax
config = ax.HealingConfig()
config.max_heal_time_ms = 300
assert config.max_heal_time_ms == 300
class TestHealingTimeoutBudget:
def test_healing_respects_timeout(self) -> None:
import axterminator as ax
config = ax.HealingConfig(max_heal_time_ms=50)
assert config.max_heal_time_ms == 50
def test_budget_divided_among_strategies(self) -> None:
import axterminator as ax
config = ax.HealingConfig(max_heal_time_ms=100)
per_strategy = config.max_heal_time_ms / len(config.strategies)
assert per_strategy > 0
@pytest.mark.slow
def test_healing_stops_at_timeout(self) -> None:
pass
def test_successful_heal_within_budget(self) -> None:
import axterminator as ax
config = ax.HealingConfig(max_heal_time_ms=5000)
assert config.max_heal_time_ms == 5000
class TestHealingStrategies:
def test_all_seven_strategies_exist(self) -> None:
import axterminator as ax
config = ax.HealingConfig()
expected = [
"data_testid",
"aria_label",
"identifier",
"title",
"xpath",
"position",
"visual_vlm",
]
for strategy in expected:
assert strategy in config.strategies
def test_visual_vlm_is_last_resort(self) -> None:
import axterminator as ax
config = ax.HealingConfig()
assert config.strategies[-1] == "visual_vlm"
def test_strategies_are_strings(self) -> None:
import axterminator as ax
config = ax.HealingConfig()
for strategy in config.strategies:
assert isinstance(strategy, str)
def test_unknown_strategy_ignored(self) -> None:
import axterminator as ax
config = ax.HealingConfig(
strategies=["data_testid", "unknown_strategy", "title"]
)
assert len(config.strategies) == 3
class TestHealingCache:
def test_cache_enabled_by_default(self) -> None:
import axterminator as ax
config = ax.HealingConfig()
assert config.cache_healed is True
def test_cache_can_be_disabled(self) -> None:
import axterminator as ax
config = ax.HealingConfig(cache_healed=False)
assert config.cache_healed is False
def test_cached_heals_are_faster(self) -> None:
pass
class TestHealingFallback:
def test_fallback_to_next_strategy(self) -> None:
pass
def test_all_strategies_exhausted(self) -> None:
pass
def test_partial_budget_to_next_strategy(self) -> None:
pass
class TestHealingPerformance:
@pytest.mark.slow
def test_healing_under_100ms(self) -> None:
import axterminator as ax
config = ax.HealingConfig()
assert config.max_heal_time_ms == 100
def test_first_strategy_fastest(self) -> None:
import axterminator as ax
config = ax.HealingConfig(max_heal_time_ms=1000)
assert config.max_heal_time_ms == 1000
class TestHealingIntegration:
@pytest.mark.requires_app
def test_healing_finds_moved_element(self, calculator_app: TestApp) -> None:
import axterminator as ax
app = ax.app(name="Calculator")
try:
element = app.find("5")
assert element is not None
element2 = app.find("5")
assert element2 is not None
except RuntimeError:
pass
@pytest.mark.requires_app
def test_healing_with_custom_config(self, calculator_app: TestApp) -> None:
import axterminator as ax
config = ax.HealingConfig(
strategies=["title", "identifier"],
max_heal_time_ms=50,
)
ax.configure_healing(config)
app = ax.app(name="Calculator")
try:
element = app.find("5")
assert element is not None
except RuntimeError:
pass
ax.configure_healing(ax.HealingConfig())