From ee441c319aa05f70b7465a416ac0e86784838b5b Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Thu, 6 Aug 2026 13:49:53 -0400 Subject: [PATCH] Improved progress bar detections. --- .../onScreenUpdate/65000-progress_detector.py | 74 +++++++++++------ src/fenrirscreenreader/fenrirVersion.py | 2 +- tests/unit/test_progress_detector.py | 79 +++++++++++++++++++ 3 files changed, 129 insertions(+), 26 deletions(-) diff --git a/src/fenrirscreenreader/commands/onScreenUpdate/65000-progress_detector.py b/src/fenrirscreenreader/commands/onScreenUpdate/65000-progress_detector.py index bf23e908..bbb78801 100644 --- a/src/fenrirscreenreader/commands/onScreenUpdate/65000-progress_detector.py +++ b/src/fenrirscreenreader/commands/onScreenUpdate/65000-progress_detector.py @@ -113,6 +113,40 @@ class command: return True + def get_curl_classic_percentage(self, text): + """Return progress from a structurally valid curl classic row.""" + import re + + size_field = r"\d+(?:\.\d+)?(?:[kKmMgGtT](?:i?[bB])?)?" + time_field = r"(?:(?:\d+:)?\d{2}:\d{2}|--:--:--)" + curl_classic_match = re.match( + rf"^\s*(\d+)\s+{size_field}\s+" + rf"(\d+)\s+{size_field}\s+" + rf"(\d+)\s+{size_field}\s+" + rf"{size_field}\s+{size_field}\s+" + rf"{time_field}\s+{time_field}\s+{time_field}\s+" + rf"{size_field}(?=\s|$)", + text, + ) + if not curl_classic_match: + return None + + total_percentage = int(curl_classic_match.group(1)) + received_percentage = int(curl_classic_match.group(2)) + uploaded_percentage = int(curl_classic_match.group(3)) + if not all( + 0 <= percentage <= 100 + for percentage in ( + total_percentage, + received_percentage, + uploaded_percentage, + ) + ): + return None + if total_percentage != received_percentage: + return None + return float(total_percentage) + def is_explicit_progress_delta(self, text): """Allow long single-line deltas that still look like progress output.""" import re @@ -120,6 +154,9 @@ class command: if "\n" in text or self.contains_url(text): return False + if self.get_curl_classic_percentage(text) is not None: + return True + has_percentage = re.search(r"(^|\s)\d+(?:\.\d+)?\s*%", text) if not has_percentage: return self.has_progress_status_fields(text) @@ -229,34 +266,21 @@ class command: return # Pattern 1a2: Curl classic progress format (percentage without % symbol) - # Extract percentage from curl's classic format - curl_classic_match = re.search( - r"^\s*(\d+)\s+\d+[kMGT]?\s+(\d+)\s+\d+[kMGT]?\s+\d+\s+\d+\s+\d+[kMGT]?\s+\d+\s+\d+:\d+:\d+\s+\d+:\d+:\d+\s+\d+:\d+:\d+\s+\d+[kMGT]?\s*$", text - ) - if curl_classic_match: - # Use the first percentage (total progress) - percentage = float(curl_classic_match.group(1)) - if 0 <= percentage <= 100: + percentage = self.get_curl_classic_percentage(text) + if percentage is not None: + self.env["runtime"]["DebugManager"].write_debug_out( + "found curl classic percentage: " + str(percentage), + debug.DebugLevel.INFO, + ) + if percentage != self.env["commandBuffer"]["lastProgressValue"]: self.env["runtime"]["DebugManager"].write_debug_out( - "found curl classic percentage: " + str(percentage), + "Playing tone for curl: " + str(percentage), debug.DebugLevel.INFO, ) - if ( - percentage - != self.env["commandBuffer"]["lastProgressValue"] - ): - self.env["runtime"]["DebugManager"].write_debug_out( - "Playing tone for curl: " + str(percentage), - debug.DebugLevel.INFO, - ) - self.play_progress_tone(percentage) - self.env["commandBuffer"][ - "lastProgressValue" - ] = percentage - self.env["commandBuffer"][ - "lastProgressTime" - ] = current_time - return + self.play_progress_tone(percentage) + self.env["commandBuffer"]["lastProgressValue"] = percentage + self.env["commandBuffer"]["lastProgressTime"] = current_time + return # Pattern 1b: Time/token activity (not percentage-based, so use single # beep) diff --git a/src/fenrirscreenreader/fenrirVersion.py b/src/fenrirscreenreader/fenrirVersion.py index 5ee82f8b..0db3d02e 100644 --- a/src/fenrirscreenreader/fenrirVersion.py +++ b/src/fenrirscreenreader/fenrirVersion.py @@ -4,5 +4,5 @@ # Fenrir TTY screen reader # By Chrys, Storm Dragon, and contributors. -version = "2026.07.30" +version = "2026.08.06" code_name = "testing" diff --git a/tests/unit/test_progress_detector.py b/tests/unit/test_progress_detector.py index caebad6f..67691183 100644 --- a/tests/unit/test_progress_detector.py +++ b/tests/unit/test_progress_detector.py @@ -290,3 +290,82 @@ def test_explicit_progress_delta_ignores_percentage_in_bracketed_prose(): assert not command.is_explicit_progress_delta( "Release notes [issue #749] are 41% complete" ) + + +@pytest.mark.unit +def test_progress_detector_beeps_for_curl_classic_repaint_with_trailing_text(): + progress_module = _load_progress_module() + command = progress_module.command() + sample = ( + "53 537.8M 53 285.8M 0 0 9.48M 0 00:56 00:30 00:26 9.94M " + "Executing cabextract -q -d /home/Username/.local/wine64/dosdevices/" + "c:/windows/syswow64 -F dx8vb.dll /home/Username/.cache/winetricks/" + "dx8vb/DX81NTger.exe" + ) + command.env = { + "commandBuffer": { + "progress_monitoring": True, + "lastProgressValue": -1, + "lastProgressTime": 0, + }, + "runtime": { + "DebugManager": Mock(write_debug_out=Mock()), + "ScreenManager": Mock(is_screen_change=Mock(return_value=False)), + "CursorManager": Mock(is_cursor_vertical_move=Mock(return_value=False)), + }, + "screen": { + "new_delta": sample, + "new_delta_is_typing": False, + "new_content_text": sample, + "old_cursor": {"x": 0, "y": 0}, + "new_cursor": {"x": 0, "y": 0}, + }, + } + command.play_progress_tone = Mock() + + assert len(sample) > 200 + + command.run() + + command.play_progress_tone.assert_called_once_with(53.0) + assert command.env["commandBuffer"]["lastProgressValue"] == 53.0 + + +@pytest.mark.unit +def test_progress_detector_ignores_curl_like_fields_with_mismatched_percentages(): + progress_module = _load_progress_module() + command = progress_module.command() + sample = ( + "53 537.8M 52 285.8M 0 0 9.48M 0 00:56 00:30 00:26 9.94M " + "ordinary numeric report with enough trailing text to cross the long " + "delta filter without representing a coherent download progress row " + "or providing trustworthy percentage information" + ) + command.env = { + "commandBuffer": { + "progress_monitoring": True, + "lastProgressValue": -1, + "lastProgressTime": 0, + }, + "runtime": { + "DebugManager": Mock(write_debug_out=Mock()), + "ScreenManager": Mock(is_screen_change=Mock(return_value=False)), + "CursorManager": Mock(is_cursor_vertical_move=Mock(return_value=False)), + }, + "screen": { + "new_delta": sample, + "new_delta_is_typing": False, + "new_content_text": sample, + "old_cursor": {"x": 0, "y": 0}, + "new_cursor": {"x": 0, "y": 0}, + }, + } + command.play_activity_beep = Mock() + command.play_progress_tone = Mock() + + assert len(sample) > 200 + + command.run() + + command.play_activity_beep.assert_not_called() + command.play_progress_tone.assert_not_called()