feat: harden video analysis and runtime resilience
This commit is contained in:
1 parent
5ada543a09
commit
bb16991f2c
55 files changed
+5023
-437
No files matched your search
@@ -0,0 +1,57 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.admin_console.routers import sessions as sessions_router
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_uses_batched_history_metadata(monkeypatch):
|
||||
repo = MagicMock()
|
||||
repo.get_all_sessions.return_value = [
|
||||
{
|
||||
"session_id": "session-1",
|
||||
"status": "completed",
|
||||
"start_time": 1.0,
|
||||
"device_info": '{"profile": "flash"}',
|
||||
},
|
||||
{
|
||||
"session_id": "session-2",
|
||||
"status": "completed",
|
||||
"start_time": 2.0,
|
||||
"device_info": '{"profile": "pro"}',
|
||||
},
|
||||
{
|
||||
"session_id": "session-3",
|
||||
"status": "completed",
|
||||
"start_time": 3.0,
|
||||
"device_info": None,
|
||||
},
|
||||
]
|
||||
repo.get_video_recordings_map.return_value = {}
|
||||
repo.get_latest_video_recordings_map.return_value = {
|
||||
"session-1": {"status": "ready"},
|
||||
"session-2": {"status": "unavailable"},
|
||||
}
|
||||
repo.get_llm_traces_for_profiles_map.return_value = {
|
||||
"session-3": ['{"model": "gemini-3.7-pro"}']
|
||||
}
|
||||
repo.get_agent_trace_names_map.return_value = {}
|
||||
|
||||
monkeypatch.setattr(sessions_router, "session_repo", repo, raising=False)
|
||||
monkeypatch.setattr(
|
||||
sessions_router.media_service, "build_video_index", MagicMock(return_value={})
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sessions_router.media_service, "resolve_video_url", MagicMock(return_value=None)
|
||||
)
|
||||
|
||||
result = await sessions_router.list_sessions()
|
||||
|
||||
assert len(result) == 3
|
||||
repo.get_latest_video_recordings_map.assert_called_once_with()
|
||||
repo.get_llm_traces_for_profiles_map.assert_called_once_with(["session-3"])
|
||||
repo.get_agent_trace_names_map.assert_called_once_with()
|
||||
repo.get_video_recording_for_session.assert_not_called()
|
||||
repo.get_llm_traces_for_profile.assert_not_called()
|
||||
repo.get_agent_trace_names.assert_not_called()
|
||||
@@ -14,8 +14,8 @@
|
||||
|
||||
"""Unit tests for VisualStepSummarizer and Context Compressor in Flash profile."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from unittest.mock import AsyncMock, Mock, call
|
||||
from uuid import uuid4
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
|
||||
@@ -65,27 +65,26 @@ async def test_summarizer_dispatch_and_caching(mock_context):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summarizer_retrigger_stalled_steps(mock_context):
|
||||
"""Verify that earlier stalled steps are re-triggered when subsequent steps arrive."""
|
||||
async def test_summarizer_retries_independently(mock_context):
|
||||
"""A failed summary retries without waiting for another action to arrive."""
|
||||
summarizer = VisualStepSummarizer(mock_context)
|
||||
attempt_count = 0
|
||||
|
||||
async def mock_ainvoke(messages):
|
||||
nonlocal attempt_count
|
||||
attempt_count += 1
|
||||
# Fail the first attempt (Step 1 first call)
|
||||
# Fail the first attempt, then return the requested step summary.
|
||||
if attempt_count == 1:
|
||||
raise asyncio.CancelledError("Simulated timeout")
|
||||
# Check messages for step number
|
||||
raise TimeoutError("Simulated timeout")
|
||||
msg_str = str(messages)
|
||||
if "Step 1" in msg_str:
|
||||
return AIMessage(content="[Step 1 State: Recovered Step 1 summary]")
|
||||
return AIMessage(content="[Step 2 State: Step 2 summary]")
|
||||
raise AssertionError("Unexpected step")
|
||||
|
||||
summarizer._llm = Mock()
|
||||
summarizer._llm.ainvoke = AsyncMock(side_effect=mock_ainvoke)
|
||||
|
||||
# Dispatch step 1 (fails on first attempt)
|
||||
# No Step 2 dispatch is needed to trigger the retry.
|
||||
summarizer.dispatch(
|
||||
step_number=1,
|
||||
action_name="click",
|
||||
@@ -95,22 +94,10 @@ async def test_summarizer_retrigger_stalled_steps(mock_context):
|
||||
exec_outcome="Outcome 1",
|
||||
)
|
||||
await summarizer.flush()
|
||||
assert not summarizer.has_summary(1)
|
||||
|
||||
# Dispatch step 2 (should detect stalled Step 1 and re-trigger)
|
||||
summarizer.dispatch(
|
||||
step_number=2,
|
||||
action_name="swipe",
|
||||
action_args={"action": "up"},
|
||||
pre_img_bytes=b"pre2",
|
||||
post_img_bytes=b"post2",
|
||||
exec_outcome="Outcome 2",
|
||||
)
|
||||
await summarizer.flush()
|
||||
|
||||
assert summarizer.has_summary(1)
|
||||
assert summarizer.has_summary(2)
|
||||
assert "Recovered Step 1" in summarizer.get_summary(1)
|
||||
assert attempt_count == 2
|
||||
|
||||
|
||||
def test_context_compressor_with_ready_summaries(mock_context):
|
||||
@@ -156,7 +143,10 @@ def test_context_compressor_with_ready_summaries(mock_context):
|
||||
# Step 1 (ToolMessage 1): Image replaced with Step 1 summary, old XML pruned
|
||||
assert len(messages[1].content) == 2
|
||||
assert messages[1].content[0]["text"] == "Action 'click' completed."
|
||||
assert messages[1].content[1]["text"] == "[Step 1 State: Tapped Search bar. Keyboard appeared.]"
|
||||
assert messages[1].content[1]["text"] == (
|
||||
"--- Historical Visual Transition ---\n"
|
||||
"[Step 1 State: Tapped Search bar. Keyboard appeared.]"
|
||||
)
|
||||
|
||||
# Step 2 (ToolMessage 2 - Latest live screen): Image and latest live XML MUST be preserved
|
||||
assert len(messages[2].content) == 3
|
||||
@@ -198,6 +188,173 @@ def test_context_compressor_graceful_fallback(mock_context):
|
||||
assert messages[1].content[1]["type"] == "image_url"
|
||||
|
||||
|
||||
def test_context_compressor_backfills_late_summary_idempotently(mock_context):
|
||||
"""A pending image remains until its late summary replaces it."""
|
||||
summarizer = VisualStepSummarizer(mock_context)
|
||||
summarizer._step_inputs["action-1"] = {"step_number": 1}
|
||||
messages = [
|
||||
HumanMessage(
|
||||
content=[
|
||||
{"type": "text", "text": "Task: Search"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG_1"}},
|
||||
]
|
||||
),
|
||||
ToolMessage(
|
||||
tool_call_id="action-1",
|
||||
name="click",
|
||||
content=[
|
||||
{"type": "text", "text": "Tapped Search."},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG_2"}},
|
||||
],
|
||||
),
|
||||
ToolMessage(
|
||||
tool_call_id="action-2",
|
||||
name="input_text",
|
||||
content=[
|
||||
{"type": "text", "text": "Typed query."},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG_3"}},
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
compress_flash_messages(messages, summarizer=summarizer)
|
||||
assert any(block["type"] == "image_url" for block in messages[1].content)
|
||||
assert len(messages[1].content) == 2
|
||||
|
||||
summarizer._summaries["action-1"] = "Search field focused; keyboard appeared."
|
||||
compress_flash_messages(messages, summarizer=summarizer)
|
||||
compress_flash_messages(messages, summarizer=summarizer)
|
||||
|
||||
generated_blocks = [
|
||||
block
|
||||
for block in messages[1].content
|
||||
if block.get("text", "").startswith("--- Historical Visual Transition ---")
|
||||
]
|
||||
assert len(generated_blocks) == 1
|
||||
assert generated_blocks[0]["text"].endswith("Search field focused; keyboard appeared.")
|
||||
assert all(block["type"] != "image_url" for block in messages[1].content)
|
||||
|
||||
|
||||
def test_context_compressor_uses_tool_call_id_over_message_ordinal(mock_context):
|
||||
"""Generic tool messages cannot shift action summaries onto the wrong result."""
|
||||
summarizer = VisualStepSummarizer(mock_context)
|
||||
summarizer._summaries["click-id"] = "Click-specific visual memory."
|
||||
summarizer._summaries["type-id"] = "Typing-specific visual memory."
|
||||
|
||||
messages = [
|
||||
ToolMessage(
|
||||
tool_call_id="click-id",
|
||||
name="click",
|
||||
content=[
|
||||
{"type": "text", "text": "Clicked."},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,A"}},
|
||||
],
|
||||
),
|
||||
ToolMessage(
|
||||
tool_call_id="explorer-id",
|
||||
name="ask_explorer",
|
||||
content=[{"type": "text", "text": "Explorer result."}],
|
||||
),
|
||||
ToolMessage(
|
||||
tool_call_id="type-id",
|
||||
name="input_text",
|
||||
content=[
|
||||
{"type": "text", "text": "Typed."},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,B"}},
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
compress_flash_messages(messages, summarizer=summarizer)
|
||||
|
||||
assert "Click-specific visual memory." in str(messages[0].content)
|
||||
assert "Typing-specific visual memory." not in str(messages[0].content)
|
||||
assert "Visual Transition" not in str(messages[1].content)
|
||||
# The latest live observation remains uncompressed until a newer screen exists.
|
||||
assert messages[2].content[-1]["type"] == "image_url"
|
||||
|
||||
|
||||
def test_context_compressor_preserves_text_before_embedded_ui_list(mock_context):
|
||||
"""Pruning a combined XML block must retain its action result prefix."""
|
||||
messages = [
|
||||
ToolMessage(
|
||||
tool_call_id="old",
|
||||
name="click",
|
||||
content=[
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Tapped Settings.\n--- UI Element List ---\n[huge tree]",
|
||||
},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,A"}},
|
||||
],
|
||||
),
|
||||
ToolMessage(
|
||||
tool_call_id="live",
|
||||
name="click",
|
||||
content=[
|
||||
{"type": "text", "text": "Opened page."},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,B"}},
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
compress_flash_messages(messages, prune_history_xml=True)
|
||||
|
||||
assert messages[0].content == [{"type": "text", "text": "Tapped Settings."}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summarizer_keys_actions_independently_of_turn_number(mock_context):
|
||||
"""Multiple actions from one LLM turn keep independent inputs and summaries."""
|
||||
mock_context.data_engine = Mock()
|
||||
summarizer = VisualStepSummarizer(mock_context)
|
||||
|
||||
async def mock_ainvoke(messages):
|
||||
prompt = str(messages)
|
||||
if "first action" in prompt:
|
||||
return AIMessage(content="First action memory.")
|
||||
return AIMessage(content="Second action memory.")
|
||||
|
||||
summarizer._llm = Mock()
|
||||
summarizer._llm.ainvoke = AsyncMock(side_effect=mock_ainvoke)
|
||||
first_step_id = uuid4()
|
||||
second_step_id = uuid4()
|
||||
|
||||
summarizer.dispatch(
|
||||
step_number=1,
|
||||
action_name="click",
|
||||
action_args={"label": "first action"},
|
||||
pre_img_bytes=None,
|
||||
post_img_bytes=None,
|
||||
exec_outcome="one",
|
||||
action_key="tc-1",
|
||||
data_engine_step_id=first_step_id,
|
||||
)
|
||||
summarizer.dispatch(
|
||||
step_number=2,
|
||||
action_name="click",
|
||||
action_args={"label": "second action"},
|
||||
pre_img_bytes=None,
|
||||
post_img_bytes=None,
|
||||
exec_outcome="two",
|
||||
action_key="tc-2",
|
||||
data_engine_step_id=second_step_id,
|
||||
)
|
||||
await summarizer.flush()
|
||||
|
||||
assert summarizer.get_summary("tc-1") == "First action memory."
|
||||
assert summarizer.get_summary("tc-2") == "Second action memory."
|
||||
assert summarizer._step_inputs["tc-1"]["data_engine_step_id"] == first_step_id
|
||||
assert summarizer._step_inputs["tc-2"]["data_engine_step_id"] == second_step_id
|
||||
mock_context.data_engine.update_step_summary.assert_has_calls(
|
||||
[
|
||||
call(first_step_id, "First action memory."),
|
||||
call(second_step_id, "Second action memory."),
|
||||
],
|
||||
any_order=True,
|
||||
)
|
||||
|
||||
|
||||
def test_flash_config_and_builder():
|
||||
"""Verify Flash profile configuration model and SDK builder fluent API."""
|
||||
cfg = Builders.AgentConfig.with_flash_config(
|
||||
|
||||
@@ -47,6 +47,18 @@ def test_operator_prompt_omits_environment_trust_and_explorer_directives():
|
||||
assert "Visual Explorer Rule" not in prompt
|
||||
|
||||
|
||||
def test_operator_prompt_keeps_large_list_traversal_single_pass_until_boundary():
|
||||
for template in load_operator_prompts().values():
|
||||
prompt = apply_operator_prompt_contract(template)
|
||||
|
||||
assert "do not advance to another milestone or mark it complete" in prompt
|
||||
assert "A single scroll that reveals no new content is not sufficient evidence" in prompt
|
||||
assert "one additional successful swipe in the same direction" in prompt
|
||||
assert "never reverse direction merely to prove completion" in prompt
|
||||
assert "even if older execution history has been pruned" in prompt
|
||||
assert "Terminate exploration when a definitive boundary is reached" not in prompt
|
||||
|
||||
|
||||
def test_compatibility_builder_uses_canonical_operator_template():
|
||||
expected = apply_operator_prompt_contract(load_operator_prompts()["main_template"])
|
||||
|
||||
|
||||
@@ -181,6 +181,9 @@ async def test_exec_analyze_audio_universal(mock_context, tmp_path):
|
||||
assert "Heard notification sound" in result
|
||||
assert len(agent.blackboard_entries) == 1
|
||||
assert agent.blackboard_entries[0]["summary"] == "Heard notification sound."
|
||||
sent_content = mock_bound.ainvoke.await_args.args[0][1].content
|
||||
assert sent_content[1]["type"] == "audio"
|
||||
assert sent_content[1]["base64"] == "YXVkaW9fYnl0ZXM="
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from artemis.agents.video_analyzer.blackboard import (
|
||||
VideoBlackboard,
|
||||
get_video_blackboard,
|
||||
)
|
||||
from artemis.agents.video_analyzer.video_analyzer import VideoAnalyzer, _invoke_with_retry
|
||||
from artemis.context import ArtemisContext
|
||||
|
||||
|
||||
def test_blackboard_persists_segments_observations_and_evidence(tmp_path):
|
||||
db_path = tmp_path / "trace.db"
|
||||
evidence_dir = tmp_path / "video_blackboard" / "evidence"
|
||||
source_image = tmp_path / "temporary.jpg"
|
||||
source_image.write_bytes(b"jpeg evidence")
|
||||
|
||||
board = VideoBlackboard("video:recording-1", db_path=db_path, evidence_dir=evidence_dir)
|
||||
stored = board.add_observation(
|
||||
{
|
||||
"start": 1.0,
|
||||
"end": 1.5,
|
||||
"target": "verify login",
|
||||
"summary": "Login screen became visible.",
|
||||
"confidence_score": 0.95,
|
||||
"screenshot": str(source_image),
|
||||
}
|
||||
)
|
||||
# A duplicate commit is idempotent.
|
||||
board.add_observation(stored)
|
||||
|
||||
claim = board.claim_segment(0.0, 5.0, "verify login")
|
||||
assert claim.state == "claimed"
|
||||
board.complete_segment(
|
||||
0.0,
|
||||
5.0,
|
||||
"verify login",
|
||||
claim.lease_owner,
|
||||
"Login succeeded.",
|
||||
"The authenticated home screen appeared.",
|
||||
)
|
||||
|
||||
reopened = VideoBlackboard("video:recording-1", db_path=db_path, evidence_dir=evidence_dir)
|
||||
observations = reopened.list_observations()
|
||||
assert len(observations) == 1
|
||||
persisted_evidence = observations[0]["screenshot"]
|
||||
assert persisted_evidence != str(source_image)
|
||||
assert (evidence_dir / f"{stored['observation_id']}.jpg").read_bytes() == b"jpeg evidence"
|
||||
|
||||
assert reopened.missing_intervals(0.0, 5.0, "verify login") == []
|
||||
cached = reopened.claim_segment(0.0, 5.0, "verify login")
|
||||
assert cached.state == "cached"
|
||||
assert cached.summary == "Login succeeded."
|
||||
assert any(entry.get("kind") == "segment_result" for entry in reopened.list_ledger_entries())
|
||||
|
||||
|
||||
def test_blackboard_calculates_only_uncovered_intervals(tmp_path):
|
||||
board = VideoBlackboard("video:coverage", db_path=tmp_path / "coverage.db")
|
||||
for start, end in ((0.0, 10.0), (8.0, 20.0), (30.0, 40.0)):
|
||||
claim = board.claim_segment(start, end, "find spinner")
|
||||
board.complete_segment(
|
||||
start, end, "find spinner", claim.lease_owner, f"Analyzed {start}-{end}"
|
||||
)
|
||||
|
||||
assert board.missing_intervals(0.0, 50.0, "find spinner") == [
|
||||
(20.0, 30.0),
|
||||
(40.0, 50.0),
|
||||
]
|
||||
# Different questions can use the observations as context but do not share
|
||||
# query-specific completion coverage.
|
||||
assert board.missing_intervals(0.0, 10.0, "count spinner frames") == [(0.0, 10.0)]
|
||||
|
||||
|
||||
def test_blackboard_lease_suppresses_duplicates_and_is_reclaimable():
|
||||
board = VideoBlackboard("video:lease", lease_seconds=0.02)
|
||||
first = board.claim_segment(0.0, 5.0, "verify tap")
|
||||
assert first.state == "claimed"
|
||||
assert board.claim_segment(0.0, 5.0, "verify tap").state == "in_progress"
|
||||
|
||||
time.sleep(0.03)
|
||||
reclaimed = board.claim_segment(0.0, 5.0, "verify tap")
|
||||
assert reclaimed.state == "claimed"
|
||||
assert reclaimed.lease_owner != first.lease_owner
|
||||
|
||||
board.fail_segment(0.0, 5.0, "verify tap", reclaimed.lease_owner, "temporary provider outage")
|
||||
assert board.claim_segment(0.0, 5.0, "verify tap").state == "claimed"
|
||||
|
||||
|
||||
def test_get_video_blackboard_is_shared_by_context():
|
||||
ctx = MagicMock(spec=ArtemisContext)
|
||||
ctx._video_blackboard = None
|
||||
ctx.data_engine = None
|
||||
ctx.execution_setup = None
|
||||
ctx.device = MagicMock()
|
||||
ctx.device.device_id = "device-1"
|
||||
|
||||
first = get_video_blackboard(ctx)
|
||||
first.add_observation(
|
||||
{
|
||||
"start": 0.0,
|
||||
"end": 1.0,
|
||||
"target": "shared",
|
||||
"summary": "Persisted in context.",
|
||||
}
|
||||
)
|
||||
second = get_video_blackboard(ctx)
|
||||
|
||||
assert first is second
|
||||
assert second.list_observations()[0]["summary"] == "Persisted in context."
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def analyzer_context():
|
||||
ctx = MagicMock(spec=ArtemisContext)
|
||||
ctx._video_blackboard = None
|
||||
ctx._mobile_controller = None
|
||||
ctx.data_engine = None
|
||||
ctx.execution_setup = None
|
||||
ctx.agent_config = None
|
||||
ctx.device = MagicMock()
|
||||
ctx.device.device_id = "device-analyzer"
|
||||
ctx.llm_config = MagicMock()
|
||||
ctx.llm_config.utils.video_analyzer.model = "universal-test-model"
|
||||
ctx.llm_config.utils.video_analyzer.temperature = 0.2
|
||||
return ctx
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spawn_sub_agent_reuses_complete_persistent_coverage(analyzer_context):
|
||||
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
|
||||
analyzer = VideoAnalyzer(analyzer_context)
|
||||
claim = analyzer.blackboard.claim_segment(10.0, 20.0, "verify login")
|
||||
analyzer.blackboard.complete_segment(
|
||||
10.0,
|
||||
20.0,
|
||||
"verify login",
|
||||
claim.lease_owner,
|
||||
"Login succeeded.",
|
||||
"Home screen appeared.",
|
||||
)
|
||||
|
||||
with patch.object(analyzer, "_exec_single_chunk", new_callable=AsyncMock) as child:
|
||||
result = await analyzer.exec_spawn_sub_agent(10.0, 20.0, "verify login")
|
||||
|
||||
child.assert_not_awaited()
|
||||
assert result.startswith("CACHED VIDEO ANALYSIS:")
|
||||
assert "Login succeeded" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spawn_sub_agent_returns_partial_and_keeps_prior_success(analyzer_context):
|
||||
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
|
||||
analyzer = VideoAnalyzer(analyzer_context)
|
||||
claim = analyzer.blackboard.claim_segment(0.0, 60.0, "summarize flow")
|
||||
analyzer.blackboard.complete_segment(
|
||||
0.0,
|
||||
60.0,
|
||||
"summarize flow",
|
||||
claim.lease_owner,
|
||||
"First minute completed.",
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
analyzer,
|
||||
"_exec_single_chunk",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("provider unavailable"),
|
||||
) as child:
|
||||
result = await analyzer.exec_spawn_sub_agent(0.0, 120.0, "summarize flow")
|
||||
|
||||
child.assert_awaited_once_with(60.0, 120.0, "summarize flow")
|
||||
assert result.startswith("PARTIAL VIDEO ANALYSIS")
|
||||
assert "First minute completed" in result
|
||||
assert analyzer.blackboard.missing_intervals(0.0, 120.0, "summarize flow") == [(60.0, 120.0)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_video_coordinator_retries_transient_failures():
|
||||
operation = AsyncMock(
|
||||
side_effect=[TimeoutError("timeout one"), ConnectionError("reset"), "completed"]
|
||||
)
|
||||
with patch(
|
||||
"artemis.agents.video_analyzer.video_analyzer.asyncio.sleep", new_callable=AsyncMock
|
||||
) as sleep:
|
||||
result = await _invoke_with_retry(operation, "test coordinator")
|
||||
|
||||
assert result == "completed"
|
||||
assert operation.await_count == 3
|
||||
assert sleep.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audio_analysis_reuses_exact_completed_coverage(analyzer_context):
|
||||
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
|
||||
analyzer = VideoAnalyzer(analyzer_context)
|
||||
claim = analyzer.blackboard.claim_segment(
|
||||
2.0, 6.0, "hear notification", modality="audio"
|
||||
)
|
||||
analyzer.blackboard.complete_segment(
|
||||
2.0,
|
||||
6.0,
|
||||
"hear notification",
|
||||
claim.lease_owner,
|
||||
"A notification sounded.",
|
||||
"One short bell was audible.",
|
||||
modality="audio",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"artemis.agents.video_analyzer.video_analyzer.get_controller"
|
||||
) as controller:
|
||||
result = await analyzer.exec_analyze_audio_only(
|
||||
2.0, 6.0, "hear notification"
|
||||
)
|
||||
|
||||
controller.assert_not_called()
|
||||
assert result.startswith("CACHED AUDIO ANALYSIS:")
|
||||
assert "notification sounded" in result
|
||||
|
||||
|
||||
def test_blackboard_metrics_include_structured_failures(tmp_path):
|
||||
board = VideoBlackboard("video:metrics", db_path=tmp_path / "metrics.db")
|
||||
claim = board.claim_segment(
|
||||
0.0,
|
||||
5.0,
|
||||
"inspect",
|
||||
model_name="vlm-primary",
|
||||
source_generation=2,
|
||||
)
|
||||
board.fail_segment(
|
||||
0.0,
|
||||
5.0,
|
||||
"inspect",
|
||||
claim.lease_owner,
|
||||
"timeout",
|
||||
error_category="timeout",
|
||||
)
|
||||
|
||||
assert board.metrics()["segments"] == {"retryable_failed": 1}
|
||||
assert board.metrics()["failure_categories"] == {"timeout": 1}
|
||||
|
||||
|
||||
def test_session_deletion_removes_video_blackboard_rows(tmp_path):
|
||||
from uuid import uuid4
|
||||
|
||||
from artemis.data_engine.engine import DataEngine
|
||||
|
||||
ctx = MagicMock(spec=ArtemisContext)
|
||||
ctx.execution_setup = MagicMock(traces_path=str(tmp_path / "traces"))
|
||||
ctx.device = None
|
||||
engine = DataEngine(ctx)
|
||||
session_id = engine.start_session(goal="blackboard cleanup")
|
||||
video_id = uuid4()
|
||||
recording = tmp_path / "recording.mp4"
|
||||
recording.write_bytes(b"video")
|
||||
engine.record_video_start(video_id, "device", recording)
|
||||
|
||||
board = VideoBlackboard(f"video:{video_id}", db_path=engine.storage.db_path)
|
||||
board.add_observation(
|
||||
{"start": 0.0, "end": 1.0, "target": "cleanup", "summary": "evidence"}
|
||||
)
|
||||
assert board.list_observations()
|
||||
|
||||
engine.storage.delete_session(session_id)
|
||||
|
||||
reopened = VideoBlackboard(f"video:{video_id}", db_path=engine.storage.db_path)
|
||||
assert reopened.list_observations() == []
|
||||
@@ -0,0 +1,193 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
import pytest
|
||||
|
||||
from artemis.agents.video_analyzer.reliability import (
|
||||
VideoCircuitBreaker,
|
||||
VideoFailureCategory,
|
||||
classify_video_failure,
|
||||
)
|
||||
from artemis.agents.video_analyzer.video_analyzer import VideoAnalyzer
|
||||
from artemis.context import ArtemisContext
|
||||
|
||||
|
||||
def _context() -> MagicMock:
|
||||
ctx = MagicMock(spec=ArtemisContext)
|
||||
ctx._video_blackboard = None
|
||||
ctx._video_circuit_breaker = None
|
||||
ctx._mobile_controller = None
|
||||
ctx.execution_setup = None
|
||||
ctx.data_engine = None
|
||||
ctx.agent_config = SimpleNamespace(
|
||||
video_analyzer=SimpleNamespace(
|
||||
enable_ledger=True,
|
||||
chunk_size_seconds=60.0,
|
||||
min_chunk_seconds=4.0,
|
||||
max_split_depth=4,
|
||||
circuit_breaker_threshold=2,
|
||||
circuit_breaker_cooldown_seconds=30.0,
|
||||
action_window_seconds=2.0,
|
||||
dense_action_fps=4.0,
|
||||
max_dense_action_frames=24,
|
||||
)
|
||||
)
|
||||
ctx.device = SimpleNamespace(device_id="reliability-device")
|
||||
ctx.llm_config = MagicMock()
|
||||
ctx.llm_config.utils.video_analyzer.model = "primary-video-model"
|
||||
ctx.llm_config.utils.video_analyzer.temperature = 0.2
|
||||
return ctx
|
||||
|
||||
|
||||
def test_failure_classifier_chooses_safe_recovery():
|
||||
outage = classify_video_failure(RuntimeError("503 service unavailable"))
|
||||
assert outage.category == VideoFailureCategory.PROVIDER_UNAVAILABLE
|
||||
assert outage.retryable and outage.should_fallback
|
||||
assert not outage.should_split
|
||||
|
||||
timeout = classify_video_failure(TimeoutError("clip timed out"))
|
||||
assert timeout.category == VideoFailureCategory.TIMEOUT
|
||||
assert timeout.should_split and timeout.should_fallback
|
||||
|
||||
invalid = SimpleNamespace(code=400)
|
||||
invalid_error = RuntimeError("bad request")
|
||||
invalid_error.code = invalid.code
|
||||
classified = classify_video_failure(invalid_error)
|
||||
assert classified.category == VideoFailureCategory.BAD_REQUEST
|
||||
assert not classified.retryable
|
||||
|
||||
|
||||
def test_circuit_breaker_opens_and_half_opens():
|
||||
breaker = VideoCircuitBreaker(threshold=2, cooldown_seconds=10.0)
|
||||
failure = classify_video_failure(TimeoutError("timeout"))
|
||||
breaker.record_failure("model", failure, now=10.0)
|
||||
assert breaker.allow("model", now=10.1)
|
||||
breaker.record_failure("model", failure, now=11.0)
|
||||
assert not breaker.allow("model", now=15.0)
|
||||
assert breaker.allow("model", now=21.1)
|
||||
assert not breaker.allow("model", now=21.2)
|
||||
breaker.record_success("model")
|
||||
assert breaker.snapshot() == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_chunk_is_bisected_and_successful_leaves_survive():
|
||||
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
|
||||
analyzer = VideoAnalyzer(_context())
|
||||
|
||||
async def analyze(start: float, end: float, query: str) -> str:
|
||||
if end - start > 4.0:
|
||||
raise TimeoutError("large clip timed out")
|
||||
return f"[from {start:.1f}s to {end:.1f}s] Summary: recovered {query}"
|
||||
|
||||
with patch.object(analyzer, "_exec_single_chunk", side_effect=analyze) as child:
|
||||
result = await analyzer.exec_spawn_sub_agent(0.0, 16.0, "find transition")
|
||||
|
||||
assert child.await_count == 7
|
||||
assert result.count("recovered find transition") == 4
|
||||
assert "PARTIAL" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_universal_primary_failure_uses_configured_fallback():
|
||||
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
|
||||
analyzer = VideoAnalyzer(_context())
|
||||
|
||||
primary = MagicMock()
|
||||
primary_bound = MagicMock()
|
||||
primary_bound.ainvoke = AsyncMock(side_effect=TimeoutError("primary timeout"))
|
||||
primary.bind_tools.return_value = primary_bound
|
||||
fallback = MagicMock()
|
||||
fallback_bound = MagicMock()
|
||||
fallback_bound.ainvoke = AsyncMock(return_value=AIMessage(content="fallback answer"))
|
||||
fallback.bind_tools.return_value = fallback_bound
|
||||
|
||||
with patch(
|
||||
"artemis.agents.video_analyzer.video_analyzer.get_llm",
|
||||
side_effect=[primary, fallback],
|
||||
) as get_model:
|
||||
response = await analyzer._invoke_universal_model(
|
||||
[HumanMessage(content="inspect")],
|
||||
[],
|
||||
timeout=1.0,
|
||||
label="test",
|
||||
)
|
||||
|
||||
assert response.content == "fallback answer"
|
||||
assert get_model.call_args_list[0].kwargs["is_utils"] is True
|
||||
assert get_model.call_args_list[1].kwargs["use_fallback"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_chunk_exhaustion_commits_universal_fallback(tmp_path):
|
||||
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
|
||||
analyzer = VideoAnalyzer(_context())
|
||||
analyzer.use_native_gemini = True
|
||||
raw_video = tmp_path / "segment.mp4"
|
||||
raw_video.write_bytes(b"video")
|
||||
extracted = SimpleNamespace(
|
||||
success=True,
|
||||
video_path=raw_video,
|
||||
actual_start_relative_time=0.0,
|
||||
duration_seconds=5.0,
|
||||
warning=None,
|
||||
)
|
||||
controller = SimpleNamespace(
|
||||
extract_segment_metadata=AsyncMock(return_value=extracted)
|
||||
)
|
||||
fallback_text = "[from 0.0s to 5.0s] Summary: fallback recovered Analysis: grounded"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"artemis.agents.video_analyzer.video_analyzer.get_controller",
|
||||
return_value=controller,
|
||||
),
|
||||
patch(
|
||||
"artemis.agents.video_analyzer.video_analyzer.compress_video_for_api",
|
||||
new=AsyncMock(return_value=raw_video),
|
||||
),
|
||||
patch.object(
|
||||
analyzer,
|
||||
"upload_and_poll_file",
|
||||
new=AsyncMock(side_effect=RuntimeError("503 provider unavailable")),
|
||||
) as upload,
|
||||
patch.object(
|
||||
analyzer,
|
||||
"_exec_single_chunk_universal",
|
||||
new=AsyncMock(return_value=fallback_text),
|
||||
) as fallback,
|
||||
patch(
|
||||
"artemis.agents.video_analyzer.video_analyzer.asyncio.sleep",
|
||||
new=AsyncMock(),
|
||||
),
|
||||
):
|
||||
result = await analyzer._exec_single_chunk(0.0, 5.0, "verify transition")
|
||||
|
||||
assert result == fallback_text
|
||||
assert upload.await_count == 2
|
||||
fallback.assert_awaited_once()
|
||||
assert analyzer.blackboard.missing_intervals(0.0, 5.0, "verify transition") == []
|
||||
|
||||
|
||||
def test_action_timestamps_drive_dense_sampling():
|
||||
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
|
||||
analyzer = VideoAnalyzer(_context())
|
||||
step = SimpleNamespace(timestamp=104.0, action_taken={"name": "tap"})
|
||||
storage = SimpleNamespace(get_steps=lambda _session: [step])
|
||||
analyzer.ctx.data_engine = SimpleNamespace(
|
||||
current_session_id="session-1",
|
||||
session_start_time=100.0,
|
||||
storage=storage,
|
||||
)
|
||||
|
||||
offsets = analyzer._dense_action_offsets(0.0, 10.0, actual_start=0.0)
|
||||
|
||||
assert offsets[0] == 2.0
|
||||
assert 4.0 in offsets
|
||||
assert offsets[-1] == 6.0
|
||||
@@ -14,7 +14,8 @@
|
||||
|
||||
"""Unit tests for Artemis System Diagnostics & Readiness Engine."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from artemis.core.diagnostics.engine import ReadinessEngine
|
||||
@@ -139,6 +140,128 @@ def test_adb_probe_parses_device_lock_state(policy_output, trust_output, expecte
|
||||
assert AdbDeviceProbe._parse_device_lock_state(policy_output, trust_output) is expected
|
||||
|
||||
|
||||
def test_modern_unlock_state_is_not_overridden_by_legacy_fields():
|
||||
policy = (
|
||||
"KeyguardServiceDelegate\n"
|
||||
" showing=false\n"
|
||||
"mShowingLockscreen=true mKeyguardOccluded=false"
|
||||
)
|
||||
trust = 'User "Owner" (current): deviceLocked=0'
|
||||
|
||||
assert AdbDeviceProbe._parse_device_lock_state(policy, trust) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_positive_lock_state_requires_confirmation(monkeypatch):
|
||||
probe = AdbDeviceProbe()
|
||||
raw_probe = AsyncMock(side_effect=[True, None])
|
||||
monkeypatch.setattr(probe, "_get_device_lock_state", raw_probe)
|
||||
|
||||
result = await probe._get_confirmed_device_lock_state("adb", "device-1")
|
||||
|
||||
assert result is None
|
||||
assert raw_probe.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_reuses_recent_confirmed_state_on_one_timeout(monkeypatch):
|
||||
probe = AdbDeviceProbe()
|
||||
confirmed_probe = AsyncMock(side_effect=[False, None])
|
||||
monkeypatch.setattr(
|
||||
probe, "_get_confirmed_device_lock_state", confirmed_probe
|
||||
)
|
||||
|
||||
assert await probe._get_dashboard_lock_state("adb", "device-1") is False
|
||||
assert await probe._get_dashboard_lock_state("adb", "device-1") is False
|
||||
assert probe._lock_state_sources["device-1"] == "recent_confirmed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readiness_engine_coalesces_concurrent_full_scans():
|
||||
engine = ReadinessEngine()
|
||||
result = ProbeResult(
|
||||
id="test_probe",
|
||||
category=ProbeCategory.RUNTIME,
|
||||
title="Test",
|
||||
status=ProbeStatus.PASS,
|
||||
is_blocker=True,
|
||||
summary="Ready",
|
||||
description="Ready",
|
||||
)
|
||||
|
||||
async def slow_probe():
|
||||
await asyncio.sleep(0.01)
|
||||
return result
|
||||
|
||||
probe = Mock()
|
||||
probe.probe = AsyncMock(side_effect=slow_probe)
|
||||
engine._probes = {"test_probe": probe}
|
||||
|
||||
reports = await asyncio.gather(*(engine.run_all() for _ in range(8)))
|
||||
|
||||
assert probe.probe.await_count == 1
|
||||
assert all(report.overall_ready for report in reports)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readiness_engine_reuses_cache_until_forced():
|
||||
engine = ReadinessEngine()
|
||||
result = ProbeResult(
|
||||
id="test_probe",
|
||||
category=ProbeCategory.RUNTIME,
|
||||
title="Test",
|
||||
status=ProbeStatus.PASS,
|
||||
is_blocker=True,
|
||||
summary="Ready",
|
||||
description="Ready",
|
||||
)
|
||||
probe = Mock()
|
||||
probe.probe = AsyncMock(return_value=result)
|
||||
engine._probes = {"test_probe": probe}
|
||||
|
||||
first = await engine.run_all()
|
||||
cached = await engine.run_all()
|
||||
refreshed = await engine.run_all(force_refresh=True)
|
||||
|
||||
assert probe.probe.await_count == 2
|
||||
assert cached.timestamp == first.timestamp
|
||||
assert refreshed.timestamp >= first.timestamp
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalidation_prevents_in_flight_report_from_becoming_shared_cache():
|
||||
engine = ReadinessEngine()
|
||||
result = ProbeResult(
|
||||
id="test_probe",
|
||||
category=ProbeCategory.RUNTIME,
|
||||
title="Test",
|
||||
status=ProbeStatus.PASS,
|
||||
is_blocker=True,
|
||||
summary="Ready",
|
||||
description="Ready",
|
||||
)
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def controlled_probe():
|
||||
started.set()
|
||||
await release.wait()
|
||||
return result
|
||||
|
||||
probe = Mock()
|
||||
probe.probe = AsyncMock(side_effect=controlled_probe)
|
||||
engine._probes = {"test_probe": probe}
|
||||
|
||||
old_scan = asyncio.create_task(engine.run_all())
|
||||
await started.wait()
|
||||
engine.invalidate_cache()
|
||||
release.set()
|
||||
await old_scan
|
||||
await engine.run_all()
|
||||
|
||||
assert probe.probe.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submission_probe_skips_full_device_enrichment(monkeypatch):
|
||||
probe = AdbDeviceProbe(target_serial="device-2")
|
||||
|
||||
@@ -101,6 +101,29 @@ def test_ipc_does_not_reconnect_after_engine_shutdown(tmp_path):
|
||||
connected_socket.close.assert_called_once()
|
||||
|
||||
|
||||
def test_ipc_connection_failure_is_backed_off(tmp_path):
|
||||
"""A stale desktop port must not block every emitted trace event."""
|
||||
mock_ctx = MagicMock(spec=ArtemisContext)
|
||||
mock_execution_setup = MagicMock()
|
||||
mock_execution_setup.traces_path = str(tmp_path)
|
||||
mock_ctx.execution_setup = mock_execution_setup
|
||||
mock_ctx.device = None
|
||||
|
||||
with (
|
||||
patch("artemis.data_engine.engine.read_ipc_port", return_value=49152),
|
||||
patch("artemis.data_engine.engine.get_ipc_port_file", return_value=tmp_path / "none"),
|
||||
patch(
|
||||
"artemis.data_engine.engine.socket.create_connection",
|
||||
side_effect=TimeoutError("stale port"),
|
||||
) as create_connection,
|
||||
):
|
||||
engine = DataEngine(mock_ctx)
|
||||
for index in range(20):
|
||||
engine._publish("llm_stream", {"chunk": str(index)})
|
||||
|
||||
assert create_connection.call_count == 1
|
||||
|
||||
|
||||
def test_get_or_create_image_updates_missing_data(tmp_path):
|
||||
# Setup mock context
|
||||
mock_ctx = MagicMock(spec=ArtemisContext)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp_server.background.task_runner import _initialize_agent
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_agent_initialization_has_hard_timeout():
|
||||
async def slow_init(**_):
|
||||
await asyncio.sleep(60)
|
||||
|
||||
agent = MagicMock()
|
||||
agent.init = AsyncMock(side_effect=slow_init)
|
||||
|
||||
with pytest.raises(TimeoutError, match="initialization exceeded 0.0s"):
|
||||
await _initialize_agent(
|
||||
agent,
|
||||
retry_count=1,
|
||||
retry_wait_seconds=1,
|
||||
timeout_seconds=0.01,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_agent_initialization_forwards_health_settings():
|
||||
agent = AsyncMock()
|
||||
|
||||
await _initialize_agent(
|
||||
agent,
|
||||
retry_count=3,
|
||||
retry_wait_seconds=4,
|
||||
timeout_seconds=1.0,
|
||||
)
|
||||
|
||||
agent.init.assert_awaited_once_with(retry_count=3, retry_wait_seconds=4)
|
||||
@@ -0,0 +1,34 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from artemis.context import ArtemisContext
|
||||
from artemis.controllers.controller_factory import get_controller
|
||||
from artemis.controllers.unified_controller import UnifiedMobileController
|
||||
|
||||
|
||||
def test_get_controller_reuses_context_scoped_controller():
|
||||
ctx = MagicMock(spec=ArtemisContext)
|
||||
ctx._mobile_controller = None
|
||||
controller = object.__new__(UnifiedMobileController)
|
||||
|
||||
with patch(
|
||||
"artemis.controllers.controller_factory.create_device_controller",
|
||||
return_value=controller,
|
||||
) as create:
|
||||
assert get_controller(ctx) is controller
|
||||
assert get_controller(ctx) is controller
|
||||
|
||||
create.assert_called_once_with(ctx)
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import base64
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -166,3 +166,35 @@ async def test_android_driver_parses_xml_hierarchy_fallback():
|
||||
|
||||
assert screen_data.ui_hierarchy_xml is not None
|
||||
assert any(element.get("text") == "Settings" for element in screen_data.ui_elements)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_element_prefers_fresh_bounds_over_stale_center():
|
||||
"""Layout transitions must not leave dynamic element taps at old coordinates."""
|
||||
driver = MockDeviceDriver(device_id="test-mock", width=1080, height=2400)
|
||||
driver.get_screen_data = AsyncMock(
|
||||
return_value=ScreenData(
|
||||
screenshot_bytes=base64.b64decode(_ONE_PIXEL_PNG),
|
||||
screenshot_base64=_ONE_PIXEL_PNG,
|
||||
ui_elements=[
|
||||
{
|
||||
"resource_id": "app:id/digit_4",
|
||||
"bounds": "[10,1700][270,1940]",
|
||||
"parsed_bounds": {
|
||||
"left": 10,
|
||||
"top": 1700,
|
||||
"right": 270,
|
||||
"bottom": 1940,
|
||||
},
|
||||
"center": [140, 1600],
|
||||
}
|
||||
],
|
||||
width=1080,
|
||||
height=2400,
|
||||
)
|
||||
)
|
||||
|
||||
_, center, error = await driver.find_element(resource_id="app:id/digit_4")
|
||||
|
||||
assert error is None
|
||||
assert center == [140, 1820]
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -28,6 +29,8 @@ from artemis.drivers.mock.mock_driver import MockDeviceDriver
|
||||
from artemis.utils.video import (
|
||||
RecordingSession,
|
||||
build_scrcpy_record_command,
|
||||
extract_audio_from_video,
|
||||
extract_frames_at_timestamps,
|
||||
get_ffmpeg_path,
|
||||
get_active_session,
|
||||
normalize_recording_to_mp4,
|
||||
@@ -37,6 +40,33 @@ from artemis.utils.video import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audio_extraction_rejects_silent_video_without_running_ffmpeg(tmp_path):
|
||||
silent_video = tmp_path / "silent.mp4"
|
||||
silent_video.write_bytes(b"video")
|
||||
probe = MagicMock(returncode=0)
|
||||
probe.communicate = AsyncMock(return_value=(b"", b""))
|
||||
|
||||
with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=probe)) as spawn:
|
||||
with pytest.raises(ValueError, match="no audio stream"):
|
||||
await extract_audio_from_video(silent_video)
|
||||
|
||||
assert spawn.await_count == 1
|
||||
|
||||
|
||||
def test_targeted_frame_extraction_uses_requested_timestamps():
|
||||
fixture = Path(__file__).parents[1] / "tools" / "inputs" / "recording.mp4"
|
||||
frames = extract_frames_at_timestamps(
|
||||
fixture,
|
||||
[0.0, 0.2, 0.4, 0.2, -1.0],
|
||||
max_frames=3,
|
||||
max_dimension=320,
|
||||
)
|
||||
|
||||
assert [timestamp for timestamp, _ in frames] == [0.0, 0.2, 0.4]
|
||||
assert all(data.startswith(b"\xff\xd8") for _, data in frames)
|
||||
|
||||
|
||||
def test_scrcpy_recording_locks_each_segment_orientation(tmp_path):
|
||||
output_path = tmp_path / "recording.mkv"
|
||||
|
||||
@@ -292,6 +322,38 @@ async def test_unified_controller_extract_segment_metadata(mock_ctx, tmp_path):
|
||||
remove_active_session("emulator-5554")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_segment_cache_is_scoped_to_recording_generation(mock_ctx, tmp_path):
|
||||
controller = UnifiedMobileController(mock_ctx)
|
||||
remove_active_session("emulator-5554")
|
||||
recording = tmp_path / "recording.mkv"
|
||||
recording.write_bytes(b"recording")
|
||||
session = RecordingSession(
|
||||
video_id=uuid4(),
|
||||
device_id="emulator-5554",
|
||||
start_time=time.time() - 20.0,
|
||||
data_engine_start_time=time.time() - 20.0,
|
||||
local_video_path=recording,
|
||||
process=MagicMock(returncode=None),
|
||||
)
|
||||
set_active_session("emulator-5554", session)
|
||||
|
||||
render = AsyncMock(
|
||||
side_effect=lambda source, start, end, output: output.write_bytes(b"clip") or True
|
||||
)
|
||||
with patch("artemis.controllers.unified_controller.render_timeline_clip", render):
|
||||
first = await controller.extract_segment_metadata(2.0, 8.0)
|
||||
cached = await controller.extract_segment_metadata(2.0, 8.0)
|
||||
session.generation = 1
|
||||
regenerated = await controller.extract_segment_metadata(2.0, 8.0)
|
||||
|
||||
assert first is cached
|
||||
assert regenerated is not cached
|
||||
assert regenerated.generation == 1
|
||||
assert render.await_count == 2
|
||||
remove_active_session("emulator-5554")
|
||||
|
||||
|
||||
def test_data_engine_video_lifecycle(tmp_path):
|
||||
from artemis.data_engine.engine import DataEngine
|
||||
from artemis.context import ArtemisContext
|
||||
|
||||
Reference in new issue
Block a user