Files

421 lines
16 KiB
Python

"""Unit tests for desktop_bridge.py utility functions.
Tests pure functions that don't require agent/session infrastructure.
Run: pytest frontends/tests/test_bridge_utils.py -v
"""
import ast
import json
import os
import sys
import re
from pathlib import Path
from types import SimpleNamespace
# Add project root so we can import bridge helpers
ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(ROOT / "frontends"))
from data_backup import materialize_import_source, merge_data_files
# Import the functions under test (module-level helpers)
import importlib.util
spec = importlib.util.spec_from_file_location("desktop_bridge", ROOT / "frontends" / "desktop_bridge.py")
# We can't import the whole module (it starts aiohttp etc.) so we extract functions manually
_bridge_source = (ROOT / "frontends" / "desktop_bridge.py").read_text(encoding="utf-8")
def _load_named_helpers(names, namespace):
tree = ast.parse(_bridge_source)
nodes = [
node
for node in tree.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in names
]
exec(compile(ast.Module(body=nodes, type_ignores=[]), "desktop_bridge.py", "exec"), namespace)
return namespace
class TestGaRootBoundary:
def test_external_core_override_needs_only_agentmain(self, tmp_path, monkeypatch):
external = tmp_path / "external core"
external.mkdir()
(external / "agentmain.py").write_text("# core\n", encoding="utf-8")
monkeypatch.setenv("GA_ROOT", str(external))
monkeypatch.setattr(sys, "argv", ["desktop_bridge.py"])
helpers = _load_named_helpers(
{"_ga_root_override"},
{"os": os, "sys": sys, "Path": Path, "Optional": __import__("typing").Optional},
)
assert helpers["_ga_root_override"]() == external.resolve()
def test_invalid_override_falls_back_instead_of_becoming_app_dir(self, tmp_path, monkeypatch):
missing = tmp_path / "deleted-core"
monkeypatch.setenv("GA_ROOT", str(missing))
monkeypatch.setattr(sys, "argv", ["desktop_bridge.py"])
helpers = _load_named_helpers(
{"_ga_root_override"},
{"os": os, "sys": sys, "Path": Path, "Optional": __import__("typing").Optional},
)
assert helpers["_ga_root_override"]() is None
class TestMemoryImport:
def test_source_memory_wins_while_current_responses_remain_add_only(self, tmp_path):
source = tmp_path / "source"
target = tmp_path / "target"
(source / "memory").mkdir(parents=True)
(source / "memory" / "same.md").write_text("new", encoding="utf-8")
(source / "memory" / "added.md").write_text("added", encoding="utf-8")
(source / "temp" / "model_responses").mkdir(parents=True)
(source / "temp" / "model_responses" / "same.json").write_text("new", encoding="utf-8")
(source / "temp" / "model_responses" / "added.json").write_text("added", encoding="utf-8")
(target / "memory").mkdir(parents=True)
(target / "memory" / "same.md").write_text("old", encoding="utf-8")
(target / "temp" / "model_responses").mkdir(parents=True)
(target / "temp" / "model_responses" / "same.json").write_text("old", encoding="utf-8")
result = merge_data_files(str(source), str(target))
assert result["memoryCopied"] == 2
assert result["memorySkipped"] == 0
assert result["responsesCopied"] == 1
assert result["responsesSkipped"] == 1
assert (target / "memory" / "same.md").read_text(encoding="utf-8") == "new"
assert (target / "memory" / "added.md").read_text(encoding="utf-8") == "added"
assert (target / "temp" / "model_responses" / "same.json").read_text(encoding="utf-8") == "old"
assert (Path(result["backupDir"]) / "memory" / "same.md").read_text(encoding="utf-8") == "old"
def test_bridge_adopts_only_sessions_committed_by_transaction(self, tmp_path):
source = tmp_path / "source"
target = tmp_path / "target"
(source / "memory").mkdir(parents=True)
(source / "memory" / "imported.md").write_text("new", encoding="utf-8")
source_sessions = source / "temp" / "desktop_sessions"
source_sessions.mkdir(parents=True)
source_sessions.joinpath("sess-imported.json").write_text(
json.dumps({"id": "sess-imported", "title": "Imported", "messages": []}),
encoding="utf-8",
)
target.mkdir()
class Lock:
def __enter__(self):
return self
def __exit__(self, *_args):
return False
class Manager:
ga_root = str(target)
lock = Lock()
sessions = {}
@staticmethod
def begin_maintenance(kind, running_extras_fn):
assert kind == "import"
assert running_extras_fn() == []
return "test-token"
@staticmethod
def end_maintenance(token):
assert token == "test-token"
@staticmethod
def _session_from_item(item):
return SimpleNamespace(id=item["id"], title=item.get("title", ""))
helpers = _load_named_helpers(
{"_import_data_source"},
{
"manager": Manager(),
"services": SimpleNamespace(running_managed_ids=lambda: []),
"materialize_import_source": materialize_import_source,
"merge_data_files": merge_data_files,
},
)
result = helpers["_import_data_source"](str(source))
manager = helpers["manager"]
assert result["sessionsAdded"] == 1
assert "_preparedSessions" not in result
assert manager.sessions["sess-imported"].title == "Imported"
persisted = target / "temp" / "desktop_sessions" / "sess-imported.json"
assert json.loads(persisted.read_text(encoding="utf-8"))["id"] == "sess-imported"
def _load_empty_turn_helpers():
tree = ast.parse(_bridge_source)
wanted = {"_EMPTY_TURN_MICROCOPY", "_get_ui_lang", "empty_turn_fallback"}
nodes = []
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in wanted:
nodes.append(node)
elif isinstance(node, ast.Assign):
names = {target.id for target in node.targets if isinstance(target, ast.Name)}
if names & wanted:
nodes.append(node)
namespace = {"Path": Path, "json": json}
exec(compile(ast.Module(body=nodes, type_ignores=[]), "desktop_bridge.py", "exec"), namespace)
return namespace
class TestEmptyTurnFallback:
def test_uses_english_microcopy(self, tmp_path, monkeypatch):
(tmp_path / ".ga_desktop_settings.json").write_text(
'{"ui":{"lang":"en"},"lang":"zh"}', encoding="utf-8"
)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
helpers = _load_empty_turn_helpers()
assert helpers["empty_turn_fallback"]() == (
'⚠️ This turn ended without a visible response. You can send "continue" to retry.'
)
def test_supports_legacy_top_level_language(self, tmp_path, monkeypatch):
(tmp_path / ".ga_desktop_settings.json").write_text('{"lang":"en"}', encoding="utf-8")
monkeypatch.setattr(Path, "home", lambda: tmp_path)
helpers = _load_empty_turn_helpers()
assert helpers["empty_turn_fallback"]() == (
'⚠️ This turn ended without a visible response. You can send "continue" to retry.'
)
def test_defaults_to_chinese_for_missing_or_invalid_settings(self, tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
helpers = _load_empty_turn_helpers()
expected = '⚠️ 这一轮结束了,但没有产出可见回复。你可以发送"继续"重试。'
assert helpers["empty_turn_fallback"]() == expected
(tmp_path / ".ga_desktop_settings.json").write_text("not-json", encoding="utf-8")
assert helpers["empty_turn_fallback"]() == expected
def test_defaults_to_chinese_for_non_string_language(self, tmp_path, monkeypatch):
(tmp_path / ".ga_desktop_settings.json").write_text(
'{"ui":{"lang":[]},"lang":[]}', encoding="utf-8"
)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
helpers = _load_empty_turn_helpers()
assert helpers["empty_turn_fallback"]() == (
'⚠️ 这一轮结束了,但没有产出可见回复。你可以发送"继续"重试。'
)
# === strip_final_info_marker ===
_FINAL_INFO_RE = re.compile(r'\n*`{5}\n*\[Info\] Final response to user\.\n*`{5}\s*$')
def strip_final_info_marker(text):
return _FINAL_INFO_RE.sub('', str(text or ''))
class TestStripFinalInfoMarker:
def test_removes_marker_at_end(self):
text = "Hello world\n`````\n[Info] Final response to user.\n`````"
assert strip_final_info_marker(text) == "Hello world"
def test_no_marker_unchanged(self):
text = "Just normal text"
assert strip_final_info_marker(text) == "Just normal text"
def test_empty_string(self):
assert strip_final_info_marker("") == ""
def test_none_becomes_empty(self):
# str(None or '') → '' since `or` short-circuits
assert strip_final_info_marker(None) == ""
def test_marker_only_in_middle_not_removed(self):
text = "Before\n`````\n[Info] Final response to user.\n`````\nAfter"
assert strip_final_info_marker(text) == text
# === normalize_final_turn_segs ===
def normalize_final_turn_segs(full, outputs):
if not outputs or not isinstance(outputs, (list, tuple)):
return None
segs = [strip_final_info_marker(s) for s in outputs]
full_text = strip_final_info_marker(full)
if not segs:
return None
joined = "".join(segs)
if full_text.strip() == joined.strip():
return segs
if joined and full_text.startswith(joined):
suffix = full_text[len(joined):]
if suffix.strip():
segs[-1] = segs[-1] + suffix
return segs
return None
class TestNormalizeFinalTurnSegs:
def test_exact_match(self):
segs = normalize_final_turn_segs("AB", ["A", "B"])
assert segs == ["A", "B"]
def test_suffix_appended_to_last_seg(self):
segs = normalize_final_turn_segs("ABC_extra", ["A", "BC"])
assert segs is not None
assert segs[-1] == "BC_extra"
def test_no_match_returns_none(self):
segs = normalize_final_turn_segs("XYZ", ["A", "B"])
assert segs is None
def test_none_outputs_returns_none(self):
assert normalize_final_turn_segs("text", None) is None
def test_empty_outputs_returns_none(self):
assert normalize_final_turn_segs("text", []) is None
def test_string_outputs_returns_none(self):
assert normalize_final_turn_segs("text", "not a list") is None
def test_whitespace_match(self):
segs = normalize_final_turn_segs("A B ", ["A B "])
assert segs == ["A B "]
# === _extract_first_timestamp ===
def _extract_first_timestamp(content):
m = re.search(r'^=== Prompt === (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})', content, re.MULTILINE)
if m:
try:
from datetime import datetime
return datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S").timestamp()
except Exception:
pass
return 0.0
class TestExtractFirstTimestamp:
def test_extracts_valid_timestamp(self):
content = "=== Prompt === 2024-06-15 14:30:00\nSome prompt text"
ts = _extract_first_timestamp(content)
assert ts > 0
from datetime import datetime
expected = datetime(2024, 6, 15, 14, 30, 0).timestamp()
assert ts == expected
def test_no_match_returns_zero(self):
assert _extract_first_timestamp("No timestamp here") == 0.0
def test_empty_string(self):
assert _extract_first_timestamp("") == 0.0
def test_multiline_finds_first(self):
content = "Some header\n=== Prompt === 2024-01-01 00:00:00\n=== Response === 2024-01-01 00:01:00"
ts = _extract_first_timestamp(content)
from datetime import datetime
assert ts == datetime(2024, 1, 1, 0, 0, 0).timestamp()
# === _next_native_var ===
def _next_native_var(text, protocol):
proto = str(protocol or "").strip().lower()
if proto == "claude":
prefix = "native_claude_config"
elif proto in ("oai", "openai"):
prefix = "native_oai_config"
else:
raise ValueError("protocol is required: choose 'oai' or 'claude'")
nums = [0]
if re.search(rf"^{prefix}\s*=", text, re.M):
nums.append(0)
nums.extend(int(m.group(1)) for m in re.finditer(rf"^{prefix}(\d+)\s*=", text, re.M))
n = max(nums) + 1
return prefix if n == 1 and not re.search(rf"^{prefix}\s*=", text, re.M) else f"{prefix}{n}"
class TestNextNativeVar:
def test_first_oai_config(self):
assert _next_native_var("", "oai") == "native_oai_config"
def test_first_claude_config(self):
assert _next_native_var("", "claude") == "native_claude_config"
def test_increments_when_one_exists(self):
text = "native_oai_config = {'key': 'xxx'}"
result = _next_native_var(text, "oai")
# When base var exists: nums=[0,0], max=0, n=1, but base already exists → prefix1
assert result == "native_oai_config1"
def test_increments_past_existing_numbered(self):
text = "native_claude_config = {}\nnative_claude_config2 = {}\nnative_claude_config3 = {}"
result = _next_native_var(text, "claude")
assert result == "native_claude_config4"
def test_raises_on_invalid_protocol(self):
import pytest
with pytest.raises(ValueError, match="protocol is required"):
_next_native_var("", "gemini")
def test_openai_alias(self):
assert _next_native_var("", "openai") == "native_oai_config"
# === _format_py_dict ===
def _format_py_dict(d):
lines = [f" '{k}': {json.dumps(v, ensure_ascii=False)}," if isinstance(v, str) else f" '{k}': {v}," for k, v in d.items()]
return "{\n" + "\n".join(lines) + "\n}"
class TestFormatPyDict:
def test_simple_dict(self):
result = _format_py_dict({"key": "sk-xxx", "model": "gpt-4"})
assert "'key': \"sk-xxx\"" in result
assert "'model': \"gpt-4\"" in result
assert result.startswith("{")
assert result.endswith("}")
def test_non_string_values(self):
result = _format_py_dict({"timeout": 30, "stream": True})
assert "'timeout': 30," in result
assert "'stream': True," in result
def test_empty_dict(self):
result = _format_py_dict({})
assert result == "{\n\n}"
def test_chinese_values_preserved(self):
result = _format_py_dict({"name": "模型一"})
assert "模型一" in result
# === _load_plan_baseline ===
def _load_plan_baseline(item, msgs):
base = int(item.get("plan_scan_baseline", 0) or 0)
if base >= len(msgs):
return 0
return max(0, base)
class TestLoadPlanBaseline:
def test_valid_baseline(self):
assert _load_plan_baseline({"plan_scan_baseline": 5}, list(range(10))) == 5
def test_baseline_exceeds_messages_returns_zero(self):
assert _load_plan_baseline({"plan_scan_baseline": 20}, list(range(5))) == 0
def test_missing_key_returns_zero(self):
assert _load_plan_baseline({}, list(range(10))) == 0
def test_none_value_returns_zero(self):
assert _load_plan_baseline({"plan_scan_baseline": None}, list(range(10))) == 0
def test_negative_clamped_to_zero(self):
assert _load_plan_baseline({"plan_scan_baseline": -5}, list(range(10))) == 0