# 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. """Unit tests for UnifiedMobileController video recording and playback features.""" import asyncio import subprocess import time from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 import pytest import cv2 from artemis.context import ArtemisContext from artemis.controllers.unified_controller import UnifiedMobileController 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, plan_timeline_pieces, render_timeline_clip, remove_active_session, set_active_session, ) @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" command = build_scrcpy_record_command("scrcpy", "device-1", output_path) assert "--capture-orientation=@" in command assert command[command.index("--record") + 1] == str(output_path) @pytest.mark.asyncio async def test_normalize_recording_handles_resolution_change(tmp_path): """A portrait/landscape H.264 track must become one fixed-size MP4.""" ffmpeg = get_ffmpeg_path() portrait = tmp_path / "portrait.mkv" landscape = tmp_path / "landscape.mkv" dynamic = tmp_path / "dynamic.mkv" output = tmp_path / "recording.mp4" for size, path in (("108x242", portrait), ("242x108", landscape)): subprocess.run( [ ffmpeg, "-y", "-f", "lavfi", "-i", f"testsrc=size={size}:rate=10:duration=0.5", "-c:v", "libx264", "-pix_fmt", "yuv420p", str(path), ], check=True, capture_output=True, ) concat_file = tmp_path / "segments.txt" concat_file.write_text( f"file '{portrait.as_posix()}'\nfile '{landscape.as_posix()}'\n", encoding="utf-8", ) subprocess.run( [ ffmpeg, "-y", "-f", "concat", "-safe", "0", "-i", str(concat_file), "-c", "copy", str(dynamic), ], check=True, capture_output=True, ) assert await normalize_recording_to_mp4(dynamic, output, 108, 242) capture = cv2.VideoCapture(str(output)) dimensions = set() frame_count = 0 while True: ok, frame = capture.read() if not ok: break frame_count += 1 dimensions.add((frame.shape[1], frame.shape[0])) capture.release() assert frame_count >= 8 assert dimensions == {(108, 242)} def _make_solid_clip(ffmpeg: str, color: str, size: str, duration: float, path) -> None: """Encode a single-colour test clip (``size`` is ``WxH``) at 15 fps.""" subprocess.run( [ ffmpeg, "-y", "-f", "lavfi", "-i", f"color={color}:size={size}:rate=15:duration={duration}", "-c:v", "libx264", "-pix_fmt", "yuv420p", str(path), ], check=True, capture_output=True, ) def _read_frames(path) -> list: """Decode every frame of ``path`` with OpenCV, in order.""" capture = cv2.VideoCapture(str(path)) frames = [] while True: ok, frame = capture.read() if not ok: break frames.append(frame) capture.release() return frames @pytest.mark.asyncio async def test_analyzer_clip_stays_continuous_across_orientation_segments(tmp_path): """The analyzer must still receive one decodable MP4 across a rotation.""" ffmpeg = get_ffmpeg_path() portrait = tmp_path / "recording.mkv" landscape = tmp_path / "recording_001.mkv" output = tmp_path / "agent_clip.mp4" _make_solid_clip(ffmpeg, "red", "108x242", 1.0, portrait) _make_solid_clip(ffmpeg, "blue", "242x108", 1.0, landscape) assert await render_timeline_clip( [ {"path": portrait, "start": 0.0, "end": 1.0}, {"path": landscape, "start": 1.0, "end": 2.0}, ], 0.25, 1.75, output, canvas_width=180, canvas_height=320, ) frames = _read_frames(output) assert {(frame.shape[1], frame.shape[0]) for frame in frames} == {(180, 320)} assert len(frames) >= 20 @pytest.mark.asyncio async def test_analyzer_clip_pins_requested_fps_across_recording_gap(tmp_path): ffmpeg = get_ffmpeg_path() first = tmp_path / "first.mkv" second = tmp_path / "second.mkv" output = tmp_path / "clip.mp4" _make_solid_clip(ffmpeg, "red", "108x242", 1.0, first) _make_solid_clip(ffmpeg, "blue", "242x108", 1.0, second) assert await render_timeline_clip( [{"path": first, "start": 0.0, "end": 1.0}, {"path": second, "start": 1.5, "end": 2.5}], 0.0, 2.5, output, canvas_width=180, canvas_height=320, fps=7, ) capture = cv2.VideoCapture(str(output)) try: assert capture.get(cv2.CAP_PROP_FPS) == pytest.approx(7.0, abs=0.1) finally: capture.release() def test_timeline_pieces_fill_restart_gaps(tmp_path): """Holes between segments (and a window starting in one) become black gaps.""" first = tmp_path / "recording.mkv" second = tmp_path / "recording_001.mkv" first.write_bytes(b"x") second.write_bytes(b"x") segments = [ {"path": first, "start": 0.0, "end": 10.0}, {"path": second, "start": 11.5, "end": 20.0}, # scrcpy restart took 1.5s ] pieces = plan_timeline_pieces(segments, 8.0, 14.0) assert pieces == [ ("file", first, 8.0, 10.0), ("gap", pytest.approx(1.5)), ("file", second, 0.0, pytest.approx(2.5)), ] # Window opening inside the hole: the clip's t=0 is still the requested start. pieces = plan_timeline_pieces(segments, 10.5, 13.0) assert pieces == [("gap", pytest.approx(1.0)), ("file", second, 0.0, pytest.approx(1.5))] # Sub-epsilon seams are rounding noise, not gaps. seam = [ {"path": first, "start": 0.0, "end": 10.0}, {"path": second, "start": 10.02, "end": 20.0}, ] assert [p[0] for p in plan_timeline_pieces(seam, 9.0, 11.0)] == ["file", "file"] assert plan_timeline_pieces(segments, 10.1, 11.4) == [] @pytest.mark.asyncio async def test_analyzer_clip_keeps_timeline_time_across_restart_gap(tmp_path): """After a rotation the clip must not run ahead of the recording timeline. Two 1s segments with a 1.5s restart hole between them; the requested 3s window must render as 3s of video with black frames where the hole was, so ``start_time + frame_offset`` still names the true recording time. """ ffmpeg = get_ffmpeg_path() portrait = tmp_path / "recording.mkv" landscape = tmp_path / "recording_001.mkv" _make_solid_clip(ffmpeg, "red", "108x242", 1.0, portrait) _make_solid_clip(ffmpeg, "blue", "242x108", 1.0, landscape) segments = [ {"path": portrait, "start": 0.0, "end": 1.0}, {"path": landscape, "start": 2.5, "end": 3.5}, ] output = tmp_path / "agent_clip.mp4" assert await render_timeline_clip( segments, 0.25, 3.25, output, canvas_width=180, canvas_height=320 ) frames = _read_frames(output) # 3.0s at 15 fps; allow one frame of encoder slack on either side. assert 43 <= len(frames) <= 47 def is_black(frame) -> bool: return frame.max() < 40 def is_blue(frame) -> bool: b, g, r = frame[160, 90] return b > 150 and r < 80 and g < 80 # t=0.5s -> red segment, t=1.5s -> restart hole, t=3.0s -> blue segment. assert not is_black(frames[7]) and not is_blue(frames[7]) assert is_black(frames[22]) assert is_blue(frames[-1]) # A window opening inside the hole leads with black, then blue at the # expected timeline offset (2.5s - 1.5s = 1.0s into the clip). output2 = tmp_path / "agent_clip2.mp4" assert await render_timeline_clip( segments, 1.5, 3.25, output2, canvas_width=180, canvas_height=320 ) frames2 = _read_frames(output2) assert 24 <= len(frames2) <= 28 assert is_black(frames2[3]) assert is_blue(frames2[20]) @pytest.fixture def mock_ctx(tmp_path): ctx = MagicMock(spec=ArtemisContext) ctx.device = MagicMock() ctx.device.device_id = "emulator-5554" ctx.device.mobile_platform = "android" # DataEngine mock ctx.data_engine = MagicMock() ctx.data_engine.current_session_id = uuid4() ctx.data_engine.session_start_time = time.time() ctx.data_engine.storage = MagicMock() # Mock driver mock_driver = MockDeviceDriver(device_id="emulator-5554") ctx._active_driver = mock_driver return ctx @pytest.mark.asyncio async def test_unified_controller_start_recording(mock_ctx, tmp_path): controller = UnifiedMobileController(mock_ctx) remove_active_session("emulator-5554") # Mock scrcpy subprocess mock_proc = MagicMock() mock_proc.returncode = None with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=mock_proc)): with patch( "artemis.controllers.unified_controller.get_android_display_state", AsyncMock(return_value=(0, 1080, 2424)), ): with patch("asyncio.sleep", AsyncMock()): res = await controller.start_video_recording(output_dir=tmp_path) assert res.success is True assert get_active_session("emulator-5554") is not None assert mock_ctx.data_engine.record_video_start.called # Calling start again should report already in progress res2 = await controller.start_video_recording(output_dir=tmp_path) assert res2.success is False assert "already in progress" in res2.message remove_active_session("emulator-5554") @pytest.mark.asyncio async def test_unified_controller_stop_recording(mock_ctx, tmp_path): controller = UnifiedMobileController(mock_ctx) remove_active_session("emulator-5554") # Create dummy recording.mkv mkv_path = tmp_path / "recording.mkv" mkv_path.write_bytes(b"dummy mkv content") mock_proc = MagicMock() mock_proc.returncode = None mock_proc.terminate = MagicMock() mock_proc.wait = AsyncMock() session = RecordingSession( video_id=uuid4(), device_id="emulator-5554", start_time=time.time() - 10.0, data_engine_start_time=time.time() - 10.0, local_video_path=mkv_path, process=mock_proc, ) set_active_session("emulator-5554", session) with ( patch( "artemis.controllers.unified_controller.remux_recording_to_mp4", AsyncMock(side_effect=lambda src, dst: (dst.write_bytes(b"mp4 content"), True)[1]), ), patch( "artemis.controllers.unified_controller.write_recording_manifest", AsyncMock(return_value=tmp_path / "recording.json"), ), ): res = await controller.stop_video_recording() assert res.success is True assert res.video_path is not None assert str(res.video_path).endswith("recording.mp4") assert get_active_session("emulator-5554") is None assert mock_ctx.data_engine.record_video_stop.called @pytest.mark.asyncio async def test_unified_controller_extract_segment_metadata(mock_ctx, tmp_path): controller = UnifiedMobileController(mock_ctx) remove_active_session("emulator-5554") mkv_path = tmp_path / "recording.mkv" mkv_path.write_bytes(b"dummy mkv content") mock_proc = MagicMock() mock_proc.returncode = None 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=mkv_path, process=mock_proc, ) set_active_session("emulator-5554", session) with patch( "artemis.controllers.unified_controller.render_timeline_clip", AsyncMock(side_effect=lambda src, s, e, dst: dst.write_bytes(b"segment mp4") or True), ): res = await controller.extract_segment_metadata(start_time=2.0, end_time=8.0) assert res.success is True assert res.video_path is not None assert res.video_path.exists() # Ensure session is still active (not stopped by extraction!) assert get_active_session("emulator-5554") is not None 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 mock_c = MagicMock(spec=ArtemisContext) mock_c.execution_setup = MagicMock(traces_path=str(tmp_path / "traces")) mock_c.device = None engine = DataEngine(mock_c) session_id = engine.start_session(goal="Test Video Engine") vid = uuid4() vpath = tmp_path / "recording.mp4" vpath.write_bytes(b"video bytes") # Start video recording engine.record_video_start(vid, "device-1", vpath) rec = engine.storage.get_video_recording(vid) assert rec is not None assert rec.device_id == "device-1" # Stop video recording engine.record_video_stop(vid, "device-1", vpath, time.time() - 10.0, time.time()) sess = engine.storage.get_session(session_id) assert sess.video_filepath == str(vpath) # Move video path on finalize new_vpath = tmp_path / "archived" / "recording.mp4" new_vpath.parent.mkdir(parents=True, exist_ok=True) new_vpath.write_bytes(b"video bytes") engine.update_video_path(new_vpath) sess_updated = engine.storage.get_session(session_id) assert sess_updated.video_filepath == str(new_vpath) @pytest.mark.asyncio async def test_unified_controller_crash_recovery_and_multi_segment(mock_ctx, tmp_path): """Verify that if recording is interrupted, segments are auto-recovered and concatenated seamlessly.""" controller = UnifiedMobileController(mock_ctx) remove_active_session("emulator-5554") seg1 = tmp_path / "recording_0.mkv" seg1.write_bytes(b"segment 0") seg2 = tmp_path / "recording_1.mkv" seg2.write_bytes(b"segment 1") mock_proc = MagicMock() mock_proc.returncode = 1 # Simulated crash session = RecordingSession( video_id=uuid4(), device_id="emulator-5554", start_time=time.time() - 30.0, data_engine_start_time=time.time() - 30.0, local_video_path=seg2, android_video_segments=[seg1], android_segment_records=[ { "path": seg1, "output_path": tmp_path / "recording.mp4", "start": 0.0, "end": 15.0, } ], android_segment_index=1, process=mock_proc, ) set_active_session("emulator-5554", session) with ( patch( "artemis.controllers.unified_controller.remux_recording_to_mp4", AsyncMock(side_effect=lambda src, dst: (dst.write_bytes(b"final mp4"), True)[1]), ), patch( "artemis.controllers.unified_controller.write_recording_manifest", AsyncMock(return_value=tmp_path / "recording.json"), ), ): res = await controller.stop_video_recording() assert res.success is True assert res.video_path is not None assert res.video_path.exists() assert str(res.video_path).endswith("recording.mp4") assert get_active_session("emulator-5554") is None @pytest.mark.asyncio async def test_unified_controller_timeline_alignment(mock_ctx, tmp_path): """Verify that relative time offset between DataEngine T0 and video start is precisely calculated.""" controller = UnifiedMobileController(mock_ctx) remove_active_session("emulator-5554") mkv_path = tmp_path / "recording.mkv" mkv_path.write_bytes(b"dummy mkv content") now = time.time() # Assume session started 15s ago, and video started 12s ago (offset = 3.0s) session_start_t0 = now - 15.0 video_start_t = now - 12.0 session = RecordingSession( video_id=uuid4(), device_id="emulator-5554", start_time=video_start_t, data_engine_start_time=session_start_t0, local_video_path=mkv_path, process=MagicMock(returncode=None), ) set_active_session("emulator-5554", session) trimmed_ranges = [] async def mock_trim(src, s_time, e_time, dst): trimmed_ranges.append((s_time, e_time)) dst.write_bytes(b"trimmed") return True with patch( "artemis.controllers.unified_controller.render_timeline_clip", AsyncMock(side_effect=mock_trim), ): # Agent asks for system time range [5.0s, 10.0s] res = await controller.extract_segment_metadata(start_time=5.0, end_time=10.0) assert res.success is True # Since offset = 3.0s, video_start_relative_time should be 5.0 - 3.0 = 2.0s # and video_end_relative_time should be 10.0 - 3.0 = 7.0s assert len(trimmed_ranges) == 1 s_time, e_time = trimmed_ranges[0] assert abs(s_time - 2.0) < 0.1 assert abs(e_time - 7.0) < 0.1 # actual_start_relative_time reported back to agent must align with system T0 (5.0s) assert abs(res.actual_start_relative_time - 5.0) < 0.1 remove_active_session("emulator-5554") class _FakeScrcpyProcess: """Minimal stand-in for an asyncio subprocess with a real stdout reader.""" def __init__(self, lines: list[bytes], *, close: bool = False): self.stdout = asyncio.StreamReader() for line in lines: self.stdout.feed_data(line) if close: self.stdout.feed_eof() self.stderr = MagicMock() self.stderr.read = AsyncMock(return_value=b"") self.returncode = None self.waited = False async def wait(self): self.waited = True self.returncode = 1 return self.returncode @pytest.mark.asyncio async def test_await_scrcpy_first_frame_uses_recording_started_marker(): from artemis.utils.video import await_scrcpy_first_frame proc = _FakeScrcpyProcess( [ b"scrcpy 4.1 \n", b"INFO: ADB device found:\n", b"INFO: Recording started to matroska file: C:\\rec\\recording.mkv\n", ] ) spawned_at = time.time() - 2.0 before = time.time() estimate = await await_scrcpy_first_frame(proc, spawned_at) after = time.time() assert before <= estimate <= after assert after - before < 0.5 @pytest.mark.asyncio async def test_await_scrcpy_first_frame_never_precedes_spawn(): from artemis.utils.video import await_scrcpy_first_frame proc = _FakeScrcpyProcess([b"INFO: Recording started to matroska file: x.mkv\n"]) spawned_at = time.time() estimate = await await_scrcpy_first_frame(proc, spawned_at) assert estimate >= spawned_at @pytest.mark.asyncio async def test_await_scrcpy_first_frame_falls_back_when_stdout_closes(): from artemis.utils.video import SCRCPY_STARTUP_FALLBACK_SECONDS, await_scrcpy_first_frame proc = _FakeScrcpyProcess([b"scrcpy 4.1\n"], close=True) spawned_at = time.time() estimate = await await_scrcpy_first_frame(proc, spawned_at) assert estimate == pytest.approx(spawned_at + SCRCPY_STARTUP_FALLBACK_SECONDS) assert proc.waited is True assert proc.returncode == 1 @pytest.mark.asyncio async def test_await_scrcpy_first_frame_falls_back_on_timeout(): from artemis.utils.video import SCRCPY_STARTUP_FALLBACK_SECONDS, await_scrcpy_first_frame proc = _FakeScrcpyProcess([b"scrcpy 4.1\n"]) # marker never arrives, stdout stays open spawned_at = time.time() estimate = await await_scrcpy_first_frame(proc, spawned_at, timeout=0.1) assert estimate == pytest.approx(spawned_at + SCRCPY_STARTUP_FALLBACK_SECONDS) @pytest.mark.asyncio async def test_start_recording_anchors_timeline_to_first_frame(mock_ctx, tmp_path): controller = UnifiedMobileController(mock_ctx) remove_active_session("emulator-5554") proc = _FakeScrcpyProcess([b"INFO: Recording started to matroska file: x.mkv\n"]) before = time.time() with ( patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)), patch( "artemis.controllers.unified_controller.get_android_display_state", AsyncMock(return_value=(0, 1080, 2424)), ), ): res = await controller.start_video_recording(output_dir=tmp_path) try: assert res.success is True session = get_active_session("emulator-5554") assert session is not None assert session.start_time >= before assert session.android_segment_started_at == session.start_time kwargs = mock_ctx.data_engine.record_video_start.call_args.kwargs assert kwargs["start_time"] == session.start_time finally: session = get_active_session("emulator-5554") if session and session.watchdog_task: session.watchdog_task.cancel() remove_active_session("emulator-5554") @pytest.mark.asyncio async def test_unconfirmed_recording_start_fails_and_closes_own_process(mock_ctx, tmp_path): controller = UnifiedMobileController(mock_ctx) remove_active_session("emulator-5554") proc = _FakeScrcpyProcess([]) with ( patch.object(controller, "_spawn_scrcpy", AsyncMock(return_value=proc)), patch.object(controller, "_stop_scrcpy", AsyncMock()) as stop, patch( "artemis.controllers.unified_controller.get_android_display_state", AsyncMock(return_value=None), ), patch( "artemis.controllers.unified_controller.await_scrcpy_first_frame", AsyncMock(side_effect=TimeoutError("no first frame")), ) as first, ): result = await controller.start_video_recording(output_dir=tmp_path) assert not result.success assert "no first frame" in result.message assert first.call_args.kwargs["require_marker"] is True stop.assert_awaited() assert get_active_session("emulator-5554") is None mock_ctx.data_engine.record_video_failure.assert_called_once() @pytest.mark.asyncio async def test_next_segment_anchors_at_first_frame(mock_ctx, tmp_path): controller = UnifiedMobileController(mock_ctx) session = RecordingSession( video_id=uuid4(), device_id="emulator-5554", start_time=time.time() - 30.0, data_engine_start_time=time.time() - 30.0, local_video_path=tmp_path / "recording.mkv", ) proc = _FakeScrcpyProcess([b"INFO: Recording started to matroska file: y.mkv\n"]) before = time.time() with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)): ok = await controller._start_next_recording_segment(session, (1, 2424, 1080)) assert ok is True assert session.android_segment_started_at is not None assert session.android_segment_started_at >= before assert session.android_rotation == 1 assert session.local_video_path == tmp_path / "recording_001.mkv" @pytest.mark.asyncio async def test_stop_recording_passes_session_offsets_to_manifest(mock_ctx, tmp_path): """The playlist manifest must carry each segment's real session-relative start.""" controller = UnifiedMobileController(mock_ctx) remove_active_session("emulator-5554") seg0 = tmp_path / "recording.mkv" seg0.write_bytes(b"segment 0") seg1 = tmp_path / "recording_001.mkv" seg1.write_bytes(b"segment 1") now = time.time() session_t0 = now - 40.0 # First frame landed 1.2s after the session started; rotation restarted # scrcpy so segment 1 begins 11.5s after that first frame (10s of video + 1.5s gap). recording_t0 = session_t0 + 1.2 session = RecordingSession( video_id=uuid4(), device_id="emulator-5554", start_time=recording_t0, data_engine_start_time=session_t0, local_video_path=seg1, android_video_segments=[seg0], android_segment_records=[ { "path": seg0, "output_path": tmp_path / "recording.mp4", "start": 0.0, "end": 10.0, } ], android_segment_index=1, android_segment_started_at=recording_t0 + 11.5, process=MagicMock(returncode=None, terminate=MagicMock(), wait=AsyncMock()), ) set_active_session("emulator-5554", session) manifest = AsyncMock(return_value=tmp_path / "recording.json") with ( patch( "artemis.controllers.unified_controller.remux_recording_to_mp4", AsyncMock(side_effect=lambda src, dst: (dst.write_bytes(b"mp4"), True)[1]), ), patch("artemis.controllers.unified_controller.write_recording_manifest", manifest), ): res = await controller.stop_video_recording() assert res.success is True manifest.assert_awaited_once() output_dir, mp4_paths, offsets = manifest.await_args.args assert output_dir == tmp_path assert mp4_paths == [tmp_path / "recording.mp4", tmp_path / "recording_001.mp4"] assert abs(offsets[tmp_path / "recording.mp4"] - 1.2) < 0.01 assert abs(offsets[tmp_path / "recording_001.mp4"] - 12.7) < 0.01 def test_segment_session_offsets_without_data_engine_anchor(tmp_path): session = RecordingSession( video_id=uuid4(), device_id="emulator-5554", start_time=1000.0, data_engine_start_time=None, local_video_path=tmp_path / "recording.mkv", android_segment_records=[ {"path": tmp_path / "a.mkv", "output_path": tmp_path / "recording.mp4", "start": 0.0}, { "path": tmp_path / "b.mkv", "output_path": tmp_path / "recording_001.mp4", "start": 7.5, }, ], ) fallback = tmp_path / "recording_converted.mp4" offsets = UnifiedMobileController._segment_session_offsets( session, [tmp_path / "recording.mp4", tmp_path / "recording_001.mp4", fallback] ) assert offsets[tmp_path / "recording.mp4"] == 0.0 assert offsets[tmp_path / "recording_001.mp4"] == 7.5 # An emergency fallback remux has no record; it starts at the recording anchor. assert offsets[fallback] == 0.0