From 1ca247f0252cece585e098877bbd822318f56533 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 08:05:40 +0000 Subject: [PATCH] Add unit tests for untested pure-logic modules Co-Authored-By: Gilad Samuels --- tests/agents/test_agent_utils.py | 61 ++++ tests/agents/test_most_basic_agent.py | 103 ++++++ tests/agents/test_reproducibility_agent.py | 130 ++++++++ tests/analyze/test_archive_studies.py | 77 +++++ tests/analyze/test_episode_to_html.py | 189 +++++++++++ tests/analyze/test_inspect_results_utils.py | 280 ++++++++++++++++ tests/benchmarks/test_abstract_env.py | 63 ++++ .../test_osworld_axtree_preprocessing.py | 148 +++++++++ tests/benchmarks/test_setup_benchmark.py | 98 ++++++ tests/experiments/test_exp_utils.py | 131 ++++++++ tests/llm/test_chat_api_unit.py | 284 ++++++++++++++++ tests/llm/test_huggingface_chat_model.py | 143 ++++++++ tests/llm/test_llm_utils_messages.py | 216 ++++++++++++ tests/llm/test_prompt_templates.py | 78 +++++ tests/llm/test_tracking_pricing.py | 310 ++++++++++++++++++ 15 files changed, 2311 insertions(+) create mode 100644 tests/agents/test_agent_utils.py create mode 100644 tests/agents/test_most_basic_agent.py create mode 100644 tests/agents/test_reproducibility_agent.py create mode 100644 tests/analyze/test_archive_studies.py create mode 100644 tests/analyze/test_episode_to_html.py create mode 100644 tests/analyze/test_inspect_results_utils.py create mode 100644 tests/benchmarks/test_abstract_env.py create mode 100644 tests/benchmarks/test_osworld_axtree_preprocessing.py create mode 100644 tests/benchmarks/test_setup_benchmark.py create mode 100644 tests/experiments/test_exp_utils.py create mode 100644 tests/llm/test_chat_api_unit.py create mode 100644 tests/llm/test_huggingface_chat_model.py create mode 100644 tests/llm/test_llm_utils_messages.py create mode 100644 tests/llm/test_prompt_templates.py create mode 100644 tests/llm/test_tracking_pricing.py diff --git a/tests/agents/test_agent_utils.py b/tests/agents/test_agent_utils.py new file mode 100644 index 000000000..5c173f564 --- /dev/null +++ b/tests/agents/test_agent_utils.py @@ -0,0 +1,61 @@ +from unittest.mock import MagicMock + +import pytest +from PIL import Image + +from agentlab.agents.agent_utils import ( + draw_arrowhead, + draw_click_indicator, + draw_mouse_pointer, + zoom_webpage, +) + + +@pytest.fixture +def white_image(): + return Image.new("RGB", (100, 100), (255, 255, 255)) + + +def test_draw_mouse_pointer_returns_new_rgba_image(white_image): + out = draw_mouse_pointer(white_image, 10, 10) + assert out is not white_image + assert out.mode == "RGBA" + assert out.size == white_image.size + # pixel inside the arrow is darkened, pixel far away is untouched + assert out.getpixel((15, 13))[:3] != (255, 255, 255) + assert out.getpixel((90, 90))[:3] == (255, 255, 255) + # original is not modified + assert white_image.getpixel((15, 13)) == (255, 255, 255) + + +def test_draw_click_indicator_leaves_center_gap(white_image): + out = draw_click_indicator(white_image, 50, 50) + assert out.mode == "RGBA" + assert out.getpixel((50, 50))[:3] == (255, 255, 255) # gap at the center + assert out.getpixel((50, 40))[:3] == (0, 0, 0) # top line + assert out.getpixel((60, 50))[:3] == (0, 0, 0) # right line + assert out.getpixel((5, 5))[:3] == (255, 255, 255) + + +def test_draw_arrowhead_draws_two_lines(): + draw = MagicMock() + draw_arrowhead(draw, (0, 0), (100, 0)) + assert draw.line.call_count == 2 + for call in draw.line.call_args_list: + points = call.args[0] + assert points[0] == (100, 0) + assert call.kwargs == {"fill": "red", "width": 4} + + +def test_zoom_webpage_evaluates_js(): + page = MagicMock() + assert zoom_webpage(page, 2.0) is page + page.evaluate.assert_called_once_with("document.documentElement.style.zoom='200.0%'") + + +@pytest.mark.parametrize("factor", [0, -1.5]) +def test_zoom_webpage_invalid_factor(factor): + page = MagicMock() + with pytest.raises(ValueError): + zoom_webpage(page, factor) + page.evaluate.assert_not_called() diff --git a/tests/agents/test_most_basic_agent.py b/tests/agents/test_most_basic_agent.py new file mode 100644 index 000000000..3717d80da --- /dev/null +++ b/tests/agents/test_most_basic_agent.py @@ -0,0 +1,103 @@ +from dataclasses import dataclass, field + +import pytest + +from agentlab.agents.most_basic_agent import most_basic_agent +from agentlab.agents.most_basic_agent.most_basic_agent import MostBasicAgent, MostBasicAgentArgs +from agentlab.llm.llm_utils import AIMessage, Discussion, ParseError + +OBS = {"goal": "Click the OK button", "pruned_html": ""} + + +class FakeChat: + def __init__(self, responses): + self.responses = list(responses) + self.calls = [] + + def __call__(self, messages): + self.calls.append(messages) + return AIMessage(self.responses.pop(0)) + + +@dataclass +class FakeModelArgs: + model_name: str = "fake-model" + responses: list = field(default_factory=list) + prepared: bool = False + closed: bool = False + + def make_model(self): + self.chat = FakeChat(self.responses) + return self.chat + + def prepare_server(self): + self.prepared = True + return "prepared" + + def close_server(self): + self.closed = True + return "closed" + + +def test_args_make_agent_and_lifecycle(): + model_args = FakeModelArgs() + args = MostBasicAgentArgs( + temperature=0.7, use_chain_of_thought=True, chat_model_args=model_args + ) + agent = args.make_agent() + assert isinstance(agent, MostBasicAgent) + assert agent.temperature == 0.7 and agent.use_chain_of_thought + assert agent.chat is model_args.chat + + args.set_reproducibility_mode() + assert args.temperature == 0 + + assert args.prepare() == "prepared" and model_args.prepared + assert args.close() == "closed" and model_args.closed + + +def test_get_action_parses_first_code_block(): + model_args = FakeModelArgs(responses=["I will click\n```\nclick('a1')\n```\n```\nnoop()\n```"]) + agent = MostBasicAgentArgs(chat_model_args=model_args).make_agent() + + action, info = agent.get_action(OBS) + + assert action == "click('a1')" + assert info.think.startswith("I will click") + assert isinstance(info.chat_messages, Discussion) + prompt = str(info.chat_messages[1]) + assert OBS["goal"] in prompt and OBS["pruned_html"] in prompt + assert "chain of thoughts" not in prompt + assert info.stats["prompt_length"] == 3 # system, user, assistant + assert info.stats["response_length"] == len(info.think) + assert {"input_tokens", "output_tokens", "cost"} <= set(info.stats) + assert info.extra_info["chat_model_args"]["model_name"] == "fake-model" + + +def test_get_action_chain_of_thought_prompt(): + model_args = FakeModelArgs(responses=["```\nclick('a1')\n```"]) + agent = MostBasicAgentArgs(use_chain_of_thought=True, chat_model_args=model_args).make_agent() + _, info = agent.get_action(OBS) + assert "chain of thoughts" in str(info.chat_messages[1]) + + +def test_get_action_retries_on_missing_code_block(): + model_args = FakeModelArgs(responses=["no code here", "```\nclick('a1')\n```"]) + agent = MostBasicAgentArgs(chat_model_args=model_args).make_agent() + action, _ = agent.get_action(OBS) + assert action == "click('a1')" + assert len(model_args.chat.calls) == 2 + + +def test_get_action_raises_after_retries(): + model_args = FakeModelArgs(responses=["nope"] * 3) + agent = MostBasicAgentArgs(chat_model_args=model_args).make_agent() + with pytest.raises(ParseError): + agent.get_action(OBS) + + +def test_experiment_config(): + exps = most_basic_agent.experiment_config() + assert len(exps) == 2 + assert [e.agent_args.use_chain_of_thought for e in exps] == [True, False] + assert all(e.env_args.task_name == "miniwob.click-button" for e in exps) diff --git a/tests/agents/test_reproducibility_agent.py b/tests/agents/test_reproducibility_agent.py new file mode 100644 index 000000000..c81ce5af0 --- /dev/null +++ b/tests/agents/test_reproducibility_agent.py @@ -0,0 +1,130 @@ +from types import SimpleNamespace + +import pytest +from browsergym.experiments.agent import AgentInfo +from bgym import HighLevelActionSetArgs + +from agentlab.agents.generic_agent import AGENT_4o_MINI +from agentlab.agents.generic_agent.reproducibility_agent import ( + ReproAgentArgs, + ReproChatModel, + _diff_stats, + _format_messages, + _make_agent_stats, + _make_backward_compatible, + _make_diff, + _set_style, + make_repro_agent, +) +from agentlab.llm.llm_utils import AIMessage, Discussion, HumanMessage, SystemMessage + +OLD_MESSAGES = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "do it"}, + {"role": "assistant", "content": "click('a1')"}, +] + + +def test_repro_chat_model_replays_next_message(): + chat = ReproChatModel(OLD_MESSAGES, delay=0) + response = chat(OLD_MESSAGES[:2]) + assert response == OLD_MESSAGES[2] + assert chat.new_messages == OLD_MESSAGES + assert chat.get_stats() == {} + + +def test_repro_chat_model_does_not_mutate_input(): + chat = ReproChatModel(OLD_MESSAGES, delay=0) + messages = OLD_MESSAGES[:2] + chat(messages) + assert len(messages) == 2 + + +def test_repro_chat_model_exhausted_returns_none_action(): + chat = ReproChatModel(OLD_MESSAGES[:2], delay=0) + response = chat(OLD_MESSAGES[:2]) + assert response["role"] == "assistant" + assert response["content"] == "None" + + +def test_format_messages_list_and_discussion(): + assert _format_messages(OLD_MESSAGES[:2]) == "system message:\nsys\n\nuser message:\ndo it\n" + discussion = Discussion([SystemMessage("sys"), HumanMessage("do it")]) + assert _format_messages(discussion) == discussion.to_string() + + +def test_diff_stats_identical(): + assert _diff_stats("a\nb", "a\nb") == dict(lines_added=0, lines_removed=0, difference_ratio=0.0) + + +def test_diff_stats_changes(): + stats = _diff_stats("a\nb\nc", "a\nB\nc\nd") + assert stats["lines_added"] == 2 + assert stats["lines_removed"] == 1 + assert stats["difference_ratio"] == pytest.approx(3 / 8) + + +def test_make_diff_has_style_and_no_nowrap(): + page = _make_diff("same\nold", "same\nnew") + assert 'nowrap="nowrap"' not in page + assert "", "b{}") + assert "" in out + + +@pytest.mark.parametrize("agent_info", [{"think": "t"}, AgentInfo(think="t")]) +def test_make_agent_stats(agent_info): + old = OLD_MESSAGES + new = OLD_MESSAGES[:2] + [AIMessage("noop()")] + action, info = _make_agent_stats("noop()", agent_info, None, old, new) + assert action == "noop()" + assert isinstance(info, AgentInfo) + assert info.think == "t" + assert " kept + assert not by_name["multi_agent"].should_delete + + +def test_search_uses_most_recent_summary(tmp_path): + import time + + study_dir = _make_study(tmp_path, "study", [{"n_completed": "0/10", "n_err": 0}]) + newer = study_dir / "summary_new.csv" + time.sleep(0.05) + pd.DataFrame([{"n_completed": "10/10", "n_err": 0}]).to_csv(newer, index=False) + + (info,) = search_for_reasons_to_archive(tmp_path) + assert info.summary_df["n_completed"].tolist() == ["10/10"] + assert not info.should_delete + + +def test_min_study_size_default_keeps_small_study(tmp_path): + _make_study(tmp_path, "small", [{"n_completed": "2/2", "n_err": 0}]) + (info,) = search_for_reasons_to_archive(tmp_path) + assert not info.should_delete diff --git a/tests/analyze/test_episode_to_html.py b/tests/analyze/test_episode_to_html.py new file mode 100644 index 000000000..065073735 --- /dev/null +++ b/tests/analyze/test_episode_to_html.py @@ -0,0 +1,189 @@ +from types import SimpleNamespace + +import pytest +from PIL import Image + +from agentlab.analyze.episode_to_html import ( + _escape_html, + _format_chat_messages_like_xray, + _format_goal, + _format_tool_call_like_xray, + _image_to_html, + _path_to_html, + exp_result_to_html, +) +from agentlab.llm.llm_utils import HumanMessage + + +def _step(action, obs=None, agent_info=None): + return SimpleNamespace(action=action, obs=obs or {}, agent_info=agent_info or {}) + + +class FakeExpResult: + def __init__(self, steps_info, screenshot_dir=None, som=True): + self.exp_args = SimpleNamespace( + env_args=SimpleNamespace(task_name="miniwob.click", task_seed=7) + ) + self.steps_info = steps_info + self.screenshot_dir = screenshot_dir + self.som = som + + def get_screenshot(self, step, som=False): + if som and not self.som: + return None + if step >= len(self.steps_info): + raise IndexError + return Image.new("RGB", (4, 4), (0, 0, 255) if som else (255, 0, 0)) + + def get_screenshot_path(self, step, som=False): + if self.screenshot_dir is None: + raise FileNotFoundError + return self.screenshot_dir / f"screenshot{'_som' if som else ''}_step_{step}.png" + + +@pytest.fixture +def steps(): + return [ + _step(None, obs={"goal_object": [{"type": "text", "text": "Click "}]}), + _step( + "click('a<1>')", + obs={"axtree_txt": "[a1] button 'OK'", "dom_txt": ""}, + agent_info={ + "think": "I'll click & go", + "chat_messages": [{"role": "user", "content": "hi"}], + }, + ), + _step(None), + ] + + +def test_escape_html(): + assert _escape_html("'&'") == ( + "<a href="x">'&'</a>" + ) + assert _escape_html(42) == "42" + assert _escape_html("") == "" + + +def test_format_tool_call_like_xray(): + assert ( + _format_tool_call_like_xray({"name": "click", "input": {"bid": "a1"}, "call_id": "c1"}) + == "Tool Call: click `{'bid': 'a1'}` (call_id: c1)" + ) + assert _format_tool_call_like_xray({}) == "Tool Call: unknown `{}` (call_id: unknown)" + + +def test_image_to_html(): + assert _image_to_html(None, "Shot") == "

Shot not available

" + html = _image_to_html(Image.new("RGB", (2, 2)), "Shot") + assert html.startswith('Shot not available

" + assert _path_to_html(tmp_path / "missing.png", "Shot") == "

Shot not available

" + img = tmp_path / "img.png" + Image.new("RGB", (2, 2)).save(img) + assert ( + _path_to_html(img, "Shot") + == f'Shot' + ) + + +def test_format_goal(): + assert "No goal specified" in _format_goal(None) + assert _format_goal("Do ") == "
Do <it>
" + html = _format_goal([{"type": "text", "text": "a"}, {"type": "text", "text": "b"}]) + assert html == "
a\nb
" + + +def test_format_chat_messages_empty(): + assert _format_chat_messages_like_xray([]) == "
No chat messages
" + assert "No chat messages" in _format_chat_messages_like_xray(None) + + +def test_format_chat_messages_variants(): + obj_msg = SimpleNamespace(role="assistant", content="") + messages = [ + {"role": "system", "content": "be "}, + { + "role": "user", + "content": [ + {"type": "text", "text": "look"}, + {"type": "image", "source": "..."}, + {"type": "tool_use", "name": "click", "input": {"bid": "1"}, "call_id": "x"}, + {"type": "other", "value": 1}, + "raw part", + ], + }, + {"content": "no role"}, + obj_msg, + HumanMessage("dict subclass"), + 12345, + ] + html = _format_chat_messages_like_xray(messages) + assert html.count('
') == len(messages) + assert '
SYSTEM
' in html + assert "be <brief>" in html + assert "[IMAGE]" in html + assert "Tool Call: click" in html + assert "raw part" in html + assert "'type': 'other'" in html + assert '
UNKNOWN
' in html + assert '
ASSISTANT
' in html and "<done>" in html + assert '
USER
' in html and "dict subclass" in html + assert "12345" in html + + +def test_exp_result_to_html_embedded(steps): + html = exp_result_to_html(FakeExpResult(steps), som_open=True, axtree_open=True) + assert '
miniwob.click
' in html + assert "Seed: 7" in html + assert "Click <OK>" in html + # step 0 has no action and is skipped; step 2 is rendered without action + assert "Step 0" not in html + assert "Step 1" in html and "Step 2" in html + assert "click('a<1>')" in html + assert "I'll click & go" in html + assert "AXTree" in html + assert "HTML" in html + assert "Prompt" in html + assert "Screenshot_som[1]" in html + assert html.count("data:image/png;base64,") == 4 # main + som for steps 1 and 2 + assert html.count("
") == 2 + assert html.endswith("
") + + +def test_exp_result_to_html_collapsed_without_som(steps): + html = exp_result_to_html(FakeExpResult(steps, som=False), steps_open=False) + assert "
" not in html + assert "
" in html + assert "Screenshot_som" not in html + + +def test_exp_result_to_html_paths(tmp_path, steps): + Image.new("RGB", (2, 2)).save(tmp_path / "screenshot_step_1.png") + Image.new("RGB", (2, 2)).save(tmp_path / "screenshot_som_step_1.png") + html = exp_result_to_html(FakeExpResult(steps, screenshot_dir=tmp_path), embed_images=False) + assert f"file://{(tmp_path / 'screenshot_step_1.png').resolve()}" in html + assert "Screenshot_som[1]" in html + assert "Screenshot 2 not available" in html + assert "Screenshot_som[2]" not in html + + +def test_exp_result_to_html_missing_screenshots(steps): + html = exp_result_to_html(FakeExpResult(steps), embed_images=False) + assert html.count("

Screenshot not available

") == 2 + + +def test_exp_result_to_html_no_steps(): + html = exp_result_to_html(FakeExpResult([])) + assert "Goal:" not in html + assert "Step" not in html + + +def test_exp_result_to_html_goal_default(): + html = exp_result_to_html(FakeExpResult([_step("noop()")])) + assert "No goal specified" in html + assert "Step 0" in html diff --git a/tests/analyze/test_inspect_results_utils.py b/tests/analyze/test_inspect_results_utils.py new file mode 100644 index 000000000..b537ba97d --- /dev/null +++ b/tests/analyze/test_inspect_results_utils.py @@ -0,0 +1,280 @@ +import json +import os + +import numpy as np +import pandas as pd +import pytest + +from agentlab.analyze.inspect_results import ( + _benchmark_from_task_name, + _extract_ablation_study, + _find_diff, + _get_mtimes, + _is_stale, + _rename_bool_flags, + categorize_error, + flag_report, + get_constants_and_variables, + get_sample_std_err, + get_std_err, + map_err_key, + shrink_columns, + split_by_key, + summarize, + summarize_stats, +) + + +@pytest.fixture +def episodes(): + return pd.DataFrame( + { + "cum_reward": [1.0, 0.0, 1.0, 0.0], + "err_msg": [None, None, None, "boom"], + "truncated": [False, False, False, False], + "terminated": [True, True, False, False], + "n_steps": [3, 4, 5, 6], + "stats.cum_cost": [0.1, 0.2, 0.3, 0.4], + } + ) + + +def test_get_constants_and_variables(): + df = pd.DataFrame({"const": [np.int64(1), np.int64(1)], "var": [1, 2], "lst": [[1], [1]]}) + constants, variables, out = get_constants_and_variables(df) + assert constants == {"const": 1} + assert type(constants["const"]) is int + assert variables == ["var", "lst"] # unhashable columns are treated as variables + assert out is df + + _, _, dropped = get_constants_and_variables(df, drop_constants=True) + assert list(dropped.columns) == ["var", "lst"] + + +def test_get_std_err_binary(): + df = pd.DataFrame({"r": [1, 0, 1, 1, np.nan]}) + mean, std_err = get_std_err(df, "r") + assert mean == pytest.approx(0.75) + assert std_err == pytest.approx(np.sqrt(0.75 * 0.25 / 4)) + + +def test_get_std_err_non_binary_uses_sample_std_err(): + df = pd.DataFrame({"r": [0.5, 1.5, 2.5]}) + assert get_std_err(df, "r") == get_sample_std_err(df, "r") + mean, std_err = get_sample_std_err(df, "r") + assert mean == pytest.approx(1.5) + assert std_err == pytest.approx(1 / np.sqrt(3)) + + +def test_get_sample_std_err_single_value(): + mean, std_err = get_sample_std_err(pd.DataFrame({"r": [0.3]}), "r") + assert mean == pytest.approx(0.3) + assert std_err == 0 + + +def test_summarize(episodes): + record = summarize(episodes) + assert record["avg_reward"] == 0.5 + assert record["std_err"] == pytest.approx(0.25) + assert record["avg_steps"] == 4.5 + assert record["n_completed"] == "3/4" # 2 terminated + 1 error + assert record["n_err"] == 1 + assert record["cum_cost"] == pytest.approx(1.0) + + +def test_summarize_effective_cost_replaces_cost(episodes): + episodes["stats.cum_effective_cost"] = [0.5, 0.5, 0.5, 0.5] + record = summarize(episodes) + assert "cum_cost" not in record + assert record["cum_effective_cost"] == 2.0 + + +def test_summarize_without_rewards(): + record = summarize(pd.DataFrame({"n_steps": [1, 2]})) + assert np.isnan(record["avg_reward"]) + assert record["n_completed"] == "0/2" + assert record["n_err"] == 0 + + +def test_summarize_nothing_completed(episodes): + episodes["terminated"] = False + episodes["err_msg"] = None + assert summarize(episodes) is None + + +def test_summarize_error_with_reward_fails_sanity_check(episodes): + episodes.loc[3, "cum_reward"] = 1.0 + with pytest.raises(AssertionError): + summarize(episodes) + + +def test_summarize_stats(episodes): + episodes["stats.max_tokens"] = [10, 30, 20, 5] + record = summarize_stats(episodes) + assert record["avg_reward"] == 0.5 + assert record["cum_cost"] == pytest.approx(1.0) + assert record["max_tokens"] == 30 + + +def test_summarize_stats_unknown_op(episodes): + episodes["stats.avg_tokens"] = 1 + with pytest.raises(ValueError, match="Unknown stats operation: avg"): + summarize_stats(episodes) + + +def test_summarize_stats_nothing_completed(episodes): + episodes["terminated"] = False + episodes["err_msg"] = None + assert summarize_stats(episodes) is None + + +def test_find_diff(): + assert _find_diff((1, 2, 3), (1, 0, 3)) == [1] + assert _find_diff((1, 2), (1, 2)) == [] + assert _find_diff((), ()) == [] + + +@pytest.fixture +def ablation_report(): + index = pd.MultiIndex.from_tuples( + [(True, 1), (False, 1), (True, 2)], names=["use_cot", "n_retry"] + ) + return pd.DataFrame({"avg_reward": [0.5, 0.4, 0.6]}, index=index) + + +def test_extract_ablation_study(ablation_report): + out = _extract_ablation_study(ablation_report) + assert list(out.index) == ["Initial Configuration", "-use_cot", "n_retry←2"] + assert list(out.columns) == ["avg_reward"] + assert out["avg_reward"].tolist() == [0.5, 0.4, 0.6] + + +def test_extract_ablation_study_progression(ablation_report): + out = _extract_ablation_study(ablation_report, progression=True) + assert list(out.index) == [ + "Initial Configuration", + "↳ use_cot=False", + "↳ use_cot=True, n_retry=2", + ] + + +def test_rename_bool_flags(ablation_report): + out = _rename_bool_flags(ablation_report.copy()) + assert set(out.index.levels[0]) == {"✓", "-"} + assert list(out.index.levels[1]) == [1, 2] + + +def test_rename_bool_flags_single_index(): + df = pd.DataFrame({"a": [1]}, index=[True]) + assert _rename_bool_flags(df).index.tolist() == [True] + + +def test_flag_report(): + index = pd.MultiIndex.from_tuples( + [(True, True), (True, False), (False, True), (False, False)], names=["f1", "f2"] + ) + report = pd.DataFrame({"avg_reward": [1.0, 0.6, 0.5, 0.2]}, index=index) + out = flag_report(report) + assert list(out.index) == ["f1", "f2"] # sorted by ratio + assert out.loc["f1", "avg_true"] == 0.8 + assert out.loc["f1", "avg_false"] == 0.35 + assert out.loc["f1", "ratio"] == pytest.approx(2.29) + assert out.loc["f2", "ratio"] == pytest.approx(1.88) + + +def test_flag_report_single_level(): + report = pd.DataFrame({"avg_reward": [1.0]}, index=pd.Index(["a"], name="agent")) + assert flag_report(report) is None + + +def test_shrink_columns(): + df = pd.DataFrame( + {"avg_reward": [0.5000, 0.125], "n_completed": ["1/2", "2/2"]}, + index=pd.Index(["a", "b"], name="agent_name"), + ) + out = shrink_columns(df) + assert list(out.columns) == ["avg\nreward", "n\ncompleted"] + assert out.index.names == ["agent\nname"] + assert out["avg\nreward"].tolist() == ["0.5", "0.125"] + assert out["n\ncompleted"].tolist() == ["1/2", "2/2"] + assert list(df.columns) == ["avg_reward", "n_completed"] # original untouched + + assert shrink_columns(df, also_wrap_index=False).index.names == ["agent_name"] + + +def test_map_err_key(): + assert map_err_key(None) is None + msg = "Error: your messages resulted in 4242 tokens\n=== logs ===\nlots of logs" + assert map_err_key(msg) == "Error: your messages resulted in x tokens" + msg = "Exception uncaught by agent or environment in task miniwob.click-test.\n=== logs ===" + assert map_err_key(msg) == "Exception uncaught by agent or environment in task ." + + +def test_categorize_error_without_error(): + assert categorize_error({"err_msg": None}) is None + assert categorize_error({}) is None + assert categorize_error({"err_msg": np.nan}) is None + + +@pytest.mark.parametrize( + "task, expected", + [ + ("miniwob.click-test", "miniwob"), + ("workarena.servicenow.x", "workarena"), + ("plain", "plain"), + ], +) +def test_benchmark_from_task_name(task, expected): + assert _benchmark_from_task_name(task) == expected + + +def test_split_by_key_column(): + df = pd.DataFrame( + { + "agent.agent_name": ["a", "b", "a"], + "env.task_name": ["t1", "t1", "t2"], + "cum_reward": [1, 0, 1], + } + ) + out = split_by_key(df, "agent.agent_name") + assert set(out) == {"a", "b"} + assert len(out["a"]) == 2 and len(out["b"]) == 1 + + +def test_split_by_key_from_index(): + df = pd.DataFrame( + {"agent.agent_name": ["a", "b"], "env.task_name": ["t", "t"], "cum_reward": [1, 0]} + ).set_index("agent.agent_name") + out = split_by_key(df, "agent.agent_name") + assert set(out) == {"a", "b"} + assert out["b"]["cum_reward"].tolist() == [0] + + +def test_get_mtimes(tmp_path): + (tmp_path / "sub").mkdir() + (tmp_path / "sub" / "file.txt").write_text("x") + (tmp_path / "_hidden.json").write_text("{}") + (tmp_path / ".dot").write_text("") + (tmp_path / "summary.csv").write_text("") + mtimes = _get_mtimes(tmp_path, whitelist=(tmp_path / "summary.csv",)) + assert set(mtimes) == {"sub", os.path.join("sub", "file.txt")} + + +def test_is_stale(tmp_path): + summary = tmp_path / "summary_df.csv" + data = tmp_path / "exp" / "result.json" + data.parent.mkdir() + data.write_text("{}") + + assert _is_stale(tmp_path, summary) # no summary yet + summary.write_text("x") + assert not _is_stale(tmp_path, summary) + saved = json.loads((tmp_path / "_last_modification_times.json").read_text()) + assert os.path.join("exp", "result.json") in saved + + summary.write_text("y") # summary changes are ignored + assert not _is_stale(tmp_path, summary) + + os.utime(data, (1, 1)) + assert _is_stale(tmp_path, summary) + assert not _is_stale(tmp_path, summary) diff --git a/tests/benchmarks/test_abstract_env.py b/tests/benchmarks/test_abstract_env.py new file mode 100644 index 000000000..c6a156662 --- /dev/null +++ b/tests/benchmarks/test_abstract_env.py @@ -0,0 +1,63 @@ +import pytest + +from agentlab.benchmarks.abstract_env import ( + AbstractBenchmark, + AbstractEnv, + add_step_timing_to_env_info_decorator, +) + + +class DummyEnv(AbstractEnv): + def __init__(self, env_info): + self.env_info = env_info + self.actions = [] + + def reset(self, seed=None): + return {}, {} + + @add_step_timing_to_env_info_decorator + def step(self, action): + self.actions.append(action) + return {"obs": action}, 1.0, True, False, self.env_info + + def close(self): + pass + + +def test_abstract_env_cannot_be_instantiated(): + with pytest.raises(TypeError): + AbstractEnv() + + +def test_decorator_adds_timing_when_env_info_none(): + env = DummyEnv(env_info=None) + obs, reward, terminated, truncated, info = env.step("click") + assert obs == {"obs": "click"} and reward == 1.0 and terminated and not truncated + assert env.actions == ["click"] + assert info["action_exec_start"] <= info["action_exec_stop"] + assert info["action_exec_timeout"] == 0.0 + + +def test_decorator_preserves_existing_values(): + preset = {"action_exec_start": 1, "action_exec_stop": 2, "action_exec_timeout": 3, "x": 4} + env = DummyEnv(env_info=dict(preset)) + *_, info = env.step("a") + assert info == preset + + +def test_decorator_preserves_function_metadata(): + assert DummyEnv.step.__name__ == "step" + + +def test_abstract_benchmark_defaults(): + bench = AbstractBenchmark(name="dummy", env_args_list=[1, 2]) + assert bench.name == "dummy" + assert bench.env_args_list == [1, 2] + assert bench.get_version() == "1" + assert bench.prepare_backends() is None + assert bench.dependency_graph_over_tasks() == {} + + +def test_abstract_benchmark_requires_fields(): + with pytest.raises(Exception): + AbstractBenchmark(name="missing env args") diff --git a/tests/benchmarks/test_osworld_axtree_preprocessing.py b/tests/benchmarks/test_osworld_axtree_preprocessing.py new file mode 100644 index 000000000..a59d4ab7a --- /dev/null +++ b/tests/benchmarks/test_osworld_axtree_preprocessing.py @@ -0,0 +1,148 @@ +import io +import xml.etree.ElementTree as ET + +import numpy as np +import pytest +from PIL import Image + +from agentlab.benchmarks.osworld_axtree_preprocessing import ( + draw_bounding_boxes, + filter_nodes, + find_leaf_nodes, + judge_node, + linearize_accessibility_tree, + tag_screenshot, + trim_accessibility_tree, +) + +NS = ( + 'xmlns:st="https://accessibility.ubuntu.example.org/ns/state" ' + 'xmlns:cp="https://accessibility.ubuntu.example.org/ns/component" ' + 'xmlns:attr="https://accessibility.windows.example.org/ns/attributes"' +) +VISIBLE = 'st:showing="true" st:visible="true" st:enabled="true"' + +UBUNTU_TREE = f""" + + OK + + + + say "hi" + + +""" + + +@pytest.fixture +def root(): + return ET.fromstring(UBUNTU_TREE) + + +@pytest.fixture +def noisy_png(): + rng = np.random.default_rng(0) + arr = rng.integers(0, 255, size=(100, 100, 3), dtype=np.uint8) + buf = io.BytesIO() + Image.fromarray(arr).save(buf, format="PNG") + return buf.getvalue() + + +def test_find_leaf_nodes_empty(): + assert find_leaf_nodes("") == [] + assert find_leaf_nodes(None) == [] + + +def test_find_leaf_nodes(): + tags = [n.get("name") for n in find_leaf_nodes(UBUNTU_TREE)] + assert tags == ["OK", "Hidden", "Offscreen", "ZeroSize", "Quote", "NotKeptTag", None] + + +def test_filter_nodes_ubuntu(root): + names = [n.get("name") for n in filter_nodes(root)] + assert names == ["OK", "Quote"] + + +def test_filter_nodes_check_image(root): + nodes = filter_nodes(root, check_image=True) + assert [n.tag for n in nodes] == ["push-button", "entry", "image"] + + +def test_judge_node_invalid_platform(root): + with pytest.raises(ValueError, match="Invalid platform"): + judge_node(root, platform="macos") + + +def test_judge_node_windows_only_requires_visible(): + tree = ( + '' + '