diff --git a/CHANGELOG.md b/CHANGELOG.md index 5eb5eb4..117b707 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,17 @@ notes, so you can check for a newer version — and read what changed — withou ## [Unreleased] +### Fixed + +- `aua record stop` no longer fails a recording whose screen never changed at all. A wholly + static window makes `screenrecord` emit a single frame, so its media length is exactly 0.0s, + and the aggregate "captured nothing at all" guard fired underneath the per-segment rule that + already excuses stillness: an 8.02s idle recording exited 3 with `recording_coverage_failed` + while holding a valid 37 KB MP4. A partly idle window passed and a wholly idle one did not. + Zero media now fails only where no `static_screen_no_frames` gap accounts for it. No segments, + a missing supervisor completion, an unreadable or unfinished segment, a non-zero recorder exit, + and stretches where the encoder was not running all still fail. + ## [0.27.5] - 2026-09-16 ### Fixed diff --git a/docs/recording.md b/docs/recording.md index 4747553..57b254f 100644 --- a/docs/recording.md +++ b/docs/recording.md @@ -33,9 +33,13 @@ positive check that the directory is absent. Unverified ownership retains the pe Inspect `segments`, `gaps`, `finish`, and `duration_check`. Lifecycle timestamps use target boot uptime, so changing the device wall clock cannot shorten a measured pause. The native movie duration is compared with the encoder process window and the requested recording -window, with a two-second tolerance. A duration shortfall or missing supervisor completion -returns `ok=false` after evidence collection. After encoder failure or incomplete supervisor -lifecycle, stop exports all finalized segments in order into the requested playable MP4. +window, with a two-second tolerance. Missing supervisor completion, an unreadable or unfinished +segment, and stretches where the encoder was not running return `ok=false` after evidence +collection. A shortfall while the encoder was running is reported as a `static_screen_no_frames` +gap and `coverage_shortfall_s` instead: `screenrecord` emits a frame when the screen changes, so +a window whose screen never changed is 0.0 seconds of media rather than lost footage. After +encoder failure or incomplete supervisor lifecycle, stop exports all finalized segments in +order into the requested playable MP4. Every lifecycle segment has a `status` (`finalized`, `missing`, or `corrupt`) and `exported` flag. Corrupt/partial files are copied unchanged into the segment directory. Missing footage is explicitly unexported; absence requires a successful inspection with a known missing-file diff --git a/src/android_ui_analyser/platforms/android_recording.py b/src/android_ui_analyser/platforms/android_recording.py index 5395f93..a8d53f0 100644 --- a/src/android_ui_analyser/platforms/android_recording.py +++ b/src/android_ui_analyser/platforms/android_recording.py @@ -544,13 +544,22 @@ def event_time(raw: str) -> float: if stop_uptime_s - previous > 0: gaps.append({"start_uptime_s": previous, "end_uptime_s": stop_uptime_s, "reason": "recording_ended_before_stop"}) requested = max(0.0, stop_uptime_s - first) - # Two coverage results a static screen cannot explain, and only these still fail: capturing - # nothing at all, and stretches where the encoder was not running. Short media while the + # Only stretches where the encoder was not running still fail here. Short media while the # encoder WAS running is the static-screen case and is reported instead. not_running = {"encoder_startup", "segment_rotation", "recording_ended_before_stop"} dark = [gap for gap in gaps if gap["reason"] in not_running and (gap["end_uptime_s"] or 0) - (gap["start_uptime_s"] or 0) > _COVERAGE_TOLERANCE_S] - failed = failed or bool(dark) or (requested > _COVERAGE_TOLERANCE_S and media_total <= 0) + # "Captured nothing at all" is not a separate result from stillness. A window whose screen + # never changed once encodes a single frame, so its media length is exactly 0.0: a 2026-09-17 + # idle run wrote a valid 37,320-byte MP4 with nb_frames=1 over 8.02s and was failed by this + # guard -- the very recording the per-segment rule above was written to keep. A partly idle + # window was tolerated and a wholly idle one was not. So zero media only fails where no + # static_screen_no_frames gap already accounts for the stillness. A recorder that really + # captured nothing is caught before this line: no segments, no finish event, an encoder + # failure, a missing or unreadable segment file, or a non-zero recorder exit. + stillness = any(gap["reason"] == "static_screen_no_frames" for gap in gaps) + captured_nothing = requested > _COVERAGE_TOLERANCE_S and media_total <= 0 and not stillness + failed = failed or bool(dark) or captured_nothing return { "mode": "native_segments", "state": "finalized", "segments": list(segments.values()), "requested_duration_s": requested, "media_duration_s": media_total, diff --git a/tests/test_recording_coverage_is_judged_in_one_direction.py b/tests/test_recording_coverage_is_judged_in_one_direction.py index b68e3c5..3e99d81 100644 --- a/tests/test_recording_coverage_is_judged_in_one_direction.py +++ b/tests/test_recording_coverage_is_judged_in_one_direction.py @@ -77,6 +77,21 @@ def test_a_shortfall_is_never_negative() -> None: # idle at home, so the check was guaranteed to fail exactly the runs it was meant to protect. # # Only a recording that captured nothing can still fail on coverage. +# +# --- 2026-09-17, third correction -------------------------------------------------------- +# +# "Captured nothing" and "nothing happened" turned out to be the same number. A screen that +# never changes at all makes `screenrecord` emit ONE frame, and one frame is 0.0 seconds of +# media. An 8.02s recording of an idle screen wrote a valid 37,320-byte MP4 (`nb_frames=1`) +# and still exited 3 with `recording_coverage_failed`, because the aggregate "media_total <= 0" +# guard fired underneath the per-segment rule that had just excused the same stillness. +# +# So a partly idle window passed and a wholly idle one failed -- exactly inverted. Zero media +# now fails only where no `static_screen_no_frames` gap accounts for it. Everything a dead +# recorder actually produces (no segments, no finish event, an unreadable file, a non-zero +# exit, an encoder that was not running) is judged before that point and still fails. + +IDLE_RUN = {"wall_s": 8.33, "media_s": 0.0} A1_RUN = {"wall_s": 168.17, "media_s": 130.001, "frames": 126} @@ -103,10 +118,66 @@ def test_the_shortfall_is_still_reported_rather_than_hidden(monkeypatch): assert "static_screen_no_frames" in reasons -def test_capturing_nothing_at_all_still_fails(monkeypatch): - # The one coverage result a screen that did not change cannot explain. - report = _timeline(media_s=0.0, monkeypatch=monkeypatch) +def test_a_wholly_static_window_is_zero_media_and_still_passes(monkeypatch): + """The reported bug: one frame over the whole window is 0.0s of media, not lost evidence.""" + report = _timeline(media_s=IDLE_RUN["media_s"], wall_s=IDLE_RUN["wall_s"], monkeypatch=monkeypatch) + + assert report["media_duration_s"] == 0.0 + assert report["duration_check"] == "passed" + + +def test_a_wholly_static_window_still_reports_its_shortfall(monkeypatch): + """Passing is not hiding: the whole window is still named as stillness, not as coverage.""" + report = _timeline(media_s=IDLE_RUN["media_s"], wall_s=IDLE_RUN["wall_s"], monkeypatch=monkeypatch) + + assert report["coverage_shortfall_s"] == pytest.approx(IDLE_RUN["wall_s"], abs=0.05) + assert [gap["reason"] for gap in report["gaps"]] == ["static_screen_no_frames"] + assert report["encoder_idle_gaps"] == [] + + +def test_a_static_window_that_rotated_segments_also_passes(monkeypatch): + """Rotation is why the excuse is keyed to stillness found, not to stillness alone. + + A window longer than the native segment limit rotates, and rotation adds its own gap. If a + zero-media recording were only excused when *every* gap is stillness, the same idle screen + would pass at 8 seconds and fail at 400. + """ + from android_ui_analyser.platforms import android_recording as rec + + monkeypatch.setattr(rec, "media_duration", lambda path: 0.0) + events = ("begin 0 100\nend 0 280 0\nbegin 1 280.4\nend 1 460.4 0\n" + "begin 2 460.9\nend 2 500.0 0\nfinish 500.0 stopped\n") + report = rec.timeline(events, [Path(f"segment-{i}.mp4") for i in range(3)], + stop_uptime_s=500.0, start_uptime_s=100.0) + + assert report["media_duration_s"] == 0.0 + assert "segment_rotation" in {gap["reason"] for gap in report["gaps"]} + assert report["duration_check"] == "passed" + + +def test_capturing_nothing_that_stillness_cannot_explain_still_fails(monkeypatch): + """Zero media across segments too short to raise a stillness gap keeps failing.""" + from android_ui_analyser.platforms import android_recording as rec + + monkeypatch.setattr(rec, "media_duration", lambda path: 0.0) + events = "begin 0 100\nend 0 101.2 0\nbegin 1 101.4\nend 1 102.6 0\nfinish 102.6 stopped\n" + report = rec.timeline(events, [Path("segment-0.mp4"), Path("segment-1.mp4")], + stop_uptime_s=102.6, start_uptime_s=100.0) + + assert not any(gap["reason"] == "static_screen_no_frames" for gap in report["gaps"]) + assert report["duration_check"] == "failed" + + +def test_a_recorder_that_produced_no_segment_at_all_still_fails(): + """The dead recorder: the window was requested, nothing ever began, nothing can excuse it.""" + from android_ui_analyser.platforms import android_recording as rec + + report = rec.timeline("", [], stop_uptime_s=1000.0 + A1_RUN["wall_s"], start_uptime_s=1000.0) + + assert report["segments"] == [] + assert report["media_duration_s"] == 0.0 assert report["duration_check"] == "failed" + assert report["encoder_idle_gaps"][0]["reason"] == "encoder_startup" def test_a_recorder_that_died_still_fails(monkeypatch):