From f96500cec8f9e562088bac70392e95c2d4a4f9fd Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 27 Jan 2026 21:33:02 +0100 Subject: [PATCH] OSUpdate app: replace "force update" checkbox with improved button labels --- .../assets/osupdate.py | 52 +-- tests/test_osupdate.py | 333 ++++++++++++++++++ 2 files changed, 362 insertions(+), 23 deletions(-) diff --git a/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py b/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py index d41c9b1b..5a94d05c 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.osupdate/assets/osupdate.py @@ -11,7 +11,6 @@ class OSUpdate(Activity): # Widgets: status_label = None install_button = None - force_update = None check_again_button = None main_screen = None progress_label = None @@ -47,10 +46,6 @@ class OSUpdate(Activity): self.current_version_label = lv.label(self.main_screen) self.current_version_label.align(lv.ALIGN.TOP_LEFT,0,0) self.current_version_label.set_text(f"Installed OS version: {BuildInfo.version.release}") - self.force_update = lv.checkbox(self.main_screen) - self.force_update.set_text("Force Update") - self.force_update.add_event_cb(lambda *args: self.force_update_clicked(), lv.EVENT.VALUE_CHANGED, None) - self.force_update.align_to(self.current_version_label, lv.ALIGN.OUT_BOTTOM_LEFT, 0, DisplayMetrics.pct_of_height(5)) self.install_button = lv.button(self.main_screen) self.install_button.align(lv.ALIGN.TOP_RIGHT, 0, 0) self.install_button.add_state(lv.STATE.DISABLED) # button will be enabled if there is an update available @@ -71,7 +66,7 @@ class OSUpdate(Activity): check_again_label.center() self.status_label = lv.label(self.main_screen) - self.status_label.align_to(self.force_update, lv.ALIGN.OUT_BOTTOM_LEFT, 0, DisplayMetrics.pct_of_height(5)) + self.status_label.align_to(self.current_version_label, lv.ALIGN.OUT_BOTTOM_LEFT, 0, DisplayMetrics.pct_of_height(5)) self.setContentView(self.main_screen) def _update_ui_for_state(self): @@ -215,18 +210,35 @@ class OSUpdate(Activity): def handle_update_info(self, version, download_url, changelog): self.download_update_url = download_url - # Use UpdateChecker to determine if update is available - is_newer = self.update_checker.is_update_available(version, BuildInfo.version.release) - - if is_newer: - label = "New" - self.install_button.remove_state(lv.STATE.DISABLED) + # Compare versions to determine button text and state + current_version = BuildInfo.version.release + + # AppManager.compare_versions() returns 1 if ver1 > ver2, -1 if ver1 < ver2, 0 if equal + # We need to check three cases: newer, same, or older + is_newer = AppManager.compare_versions(version, current_version) + is_older = AppManager.compare_versions(current_version, version) + + # Determine button text based on version comparison + if is_newer > 0: + # Update version > installed OS version + button_text = "Update OS" + label = "newer" + elif is_older > 0: + # Update version < installed OS version + button_text = "Install\nolder version" + label = "older" else: - label = "No new" - if (self.force_update.get_state() & lv.STATE.CHECKED): - self.install_button.remove_state(lv.STATE.DISABLED) - label += f" version: {version}\n\nDetails:\n\n{changelog}" - self.status_label.set_text(label) + # Update version == installed OS version (neither is newer than the other) + button_text = "Reinstall\nsame version" + label = "the same version" + + # Update button text and enable it + install_label = self.install_button.get_child(0) + install_label.set_text(button_text) + install_label.center() + self.install_button.remove_state(lv.STATE.DISABLED) + + self.status_label.set_text(f"Available version: {version}\nis {label}.\n\nDetails:\n\n{changelog}") def install_button_click(self): @@ -256,12 +268,6 @@ class OSUpdate(Activity): # Use TaskManager instead of _thread for async download TaskManager.create_task(self.perform_update()) - def force_update_clicked(self): - if self.download_update_url and (self.force_update.get_state() & lv.STATE.CHECKED): - self.install_button.remove_state(lv.STATE.DISABLED) - else: - self.install_button.add_state(lv.STATE.DISABLED) - def check_again_click(self): """Handle 'Check Again' button click - retry update check.""" print("OSUpdate: Check Again button clicked") diff --git a/tests/test_osupdate.py b/tests/test_osupdate.py index e36e4893..3f2b0000 100644 --- a/tests/test_osupdate.py +++ b/tests/test_osupdate.py @@ -517,3 +517,336 @@ class TestUpdateDownloader(unittest.TestCase): self.assertEqual(self.downloader.bytes_written_so_far, 245760, "Must preserve internal state") +class MockLVGLButton: + """Mock LVGL button for testing button state and text.""" + + def __init__(self, initial_disabled=True): + self.disabled = initial_disabled + self.children = [] + self.hidden = False + + def add_state(self, state): + """Add a state flag (e.g., lv.STATE.DISABLED).""" + # Track if DISABLED state is being added + if state == 1: # lv.STATE.DISABLED + self.disabled = True + + def remove_state(self, state): + """Remove a state flag.""" + # Track if DISABLED state is being removed + if state == 1: # lv.STATE.DISABLED + self.disabled = False + + def add_flag(self, flag): + """Add a flag (e.g., lv.obj.FLAG.HIDDEN).""" + if flag == 1: # lv.obj.FLAG.HIDDEN + self.hidden = True + + def remove_flag(self, flag): + """Remove a flag.""" + if flag == 1: # lv.obj.FLAG.HIDDEN + self.hidden = False + + def get_child(self, index): + """Get child widget by index.""" + if index < len(self.children): + return self.children[index] + return None + + def is_disabled(self): + """Check if button is disabled.""" + return self.disabled + + def is_hidden(self): + """Check if button is hidden.""" + return self.hidden + + def set_text(self, text): + """Set button text (for compatibility with direct text setting).""" + if self.children and hasattr(self.children[0], 'set_text'): + self.children[0].set_text(text) + + +class MockLVGLLabel: + """Mock LVGL label for testing text content.""" + + def __init__(self): + self.text = "" + + def set_text(self, text): + """Set label text.""" + self.text = text + + def get_text(self): + """Get label text.""" + return self.text + + def center(self): + """Mock center method (no-op for testing).""" + pass + + +class MockAppManager: + """Mock AppManager for version comparison.""" + + @staticmethod + def compare_versions(version1, version2): + """Compare two version strings. + + Returns: + > 0 if version1 > version2 + = 0 if version1 == version2 + < 0 if version1 < version2 + """ + def parse_version(v): + return tuple(map(int, v.split('.'))) + + v1 = parse_version(version1) + v2 = parse_version(version2) + + if v1 > v2: + return 1 + elif v1 < v2: + return -1 + else: + return 0 + + +class MockBuildInfo: + """Mock BuildInfo for testing.""" + class Version: + release = "1.0.0" + version = Version() + + +class TestOSUpdateButtonBehavior(unittest.TestCase): + """Test OSUpdate button behavior with different version scenarios. + + These tests verify that handle_update_info() correctly interprets + AppManager.compare_versions() return values and sets button text accordingly. + The bug being tested: compare_versions() returns integers (-1, 0, 1), not booleans. + """ + + def setUp(self): + """Set up test fixtures.""" + # Create a mock OSUpdate instance with mocked dependencies + self.mock_app_manager = MockAppManager() + + # We'll patch AppManager.compare_versions for these tests + self.original_compare_versions = AppManager.compare_versions + AppManager.compare_versions = self.mock_app_manager.compare_versions + + # Create mock button and label + self.mock_button = MockLVGLButton(initial_disabled=True) + self.mock_label = MockLVGLLabel() + self.mock_button.children = [self.mock_label] + + def tearDown(self): + """Restore original AppManager.compare_versions.""" + AppManager.compare_versions = self.original_compare_versions + + def test_button_initially_disabled(self): + """Test that the 'Update OS' button is initially disabled.""" + # Button should start in disabled state + self.assertTrue(self.mock_button.is_disabled(), + "Button should be initially disabled") + + def test_handle_update_info_with_newer_version(self): + """Test handle_update_info() with newer version (1.1.0 vs 1.0.0). + + This test verifies that: + - compare_versions(1.1.0, 1.0.0) returns 1 (positive integer) + - The button text is set to exactly "Update OS" + - The button is enabled (remove_state called) + """ + # Create a minimal OSUpdate instance for testing + from osupdate import OSUpdate + import osupdate + + app = OSUpdate() + + # Mock the UI components with a tracking button + tracking_button = MockLVGLButton(initial_disabled=True) + tracking_button.children = [MockLVGLLabel()] + app.install_button = tracking_button + app.status_label = MockLVGLLabel() + + # Mock BuildInfo and AppManager in osupdate module + original_build_info = osupdate.BuildInfo + original_app_manager = osupdate.AppManager + try: + osupdate.BuildInfo = MockBuildInfo + osupdate.AppManager = type('MockAppManager', (), { + 'compare_versions': staticmethod(self.mock_app_manager.compare_versions) + }) + + # Call handle_update_info with newer version + app.handle_update_info("1.1.0", "http://example.com/update.bin", "Bug fixes") + + # Verify button text is exactly "Update OS" + button_label = tracking_button.get_child(0) + self.assertIsNotNone(button_label, "Button should have a label child") + self.assertEqual(button_label.get_text(), "Update OS", + "Button text must be exactly 'Update OS' for newer version") + finally: + osupdate.BuildInfo = original_build_info + osupdate.AppManager = original_app_manager + + def test_handle_update_info_with_same_version(self): + """Test handle_update_info() with same version (1.0.0 vs 1.0.0). + + This test verifies that: + - compare_versions(1.0.0, 1.0.0) returns 0 + - The button text is set to exactly "Reinstall\\nsame version" + - The button is enabled (remove_state called) + """ + # Create a minimal OSUpdate instance for testing + from osupdate import OSUpdate + import osupdate + + app = OSUpdate() + + # Mock the UI components with a tracking button + tracking_button = MockLVGLButton(initial_disabled=True) + tracking_button.children = [MockLVGLLabel()] + app.install_button = tracking_button + app.status_label = MockLVGLLabel() + + # Mock BuildInfo and AppManager in osupdate module + original_build_info = osupdate.BuildInfo + original_app_manager = osupdate.AppManager + try: + osupdate.BuildInfo = MockBuildInfo + osupdate.AppManager = type('MockAppManager', (), { + 'compare_versions': staticmethod(self.mock_app_manager.compare_versions) + }) + + # Call handle_update_info with same version + app.handle_update_info("1.0.0", "http://example.com/update.bin", "Reinstall") + + # Verify button text is exactly "Reinstall\nsame version" + button_label = tracking_button.get_child(0) + self.assertIsNotNone(button_label, "Button should have a label child") + self.assertEqual(button_label.get_text(), "Reinstall\nsame version", + "Button text must be exactly 'Reinstall\\nsame version' for same version") + finally: + osupdate.BuildInfo = original_build_info + osupdate.AppManager = original_app_manager + + def test_handle_update_info_with_older_version(self): + """Test handle_update_info() with older version (0.9.0 vs 1.0.0). + + This test verifies that: + - compare_versions(0.9.0, 1.0.0) returns -1 (negative integer) + - The button text is set to exactly "Install old version" + - The button is enabled (remove_state called) + """ + # Create a minimal OSUpdate instance for testing + from osupdate import OSUpdate + import osupdate + + app = OSUpdate() + + # Mock the UI components with a tracking button + tracking_button = MockLVGLButton(initial_disabled=True) + tracking_button.children = [MockLVGLLabel()] + app.install_button = tracking_button + app.status_label = MockLVGLLabel() + + # Mock BuildInfo and AppManager in osupdate module + original_build_info = osupdate.BuildInfo + original_app_manager = osupdate.AppManager + try: + osupdate.BuildInfo = MockBuildInfo + osupdate.AppManager = type('MockAppManager', (), { + 'compare_versions': staticmethod(self.mock_app_manager.compare_versions) + }) + + # Call handle_update_info with older version + app.handle_update_info("0.9.0", "http://example.com/update.bin", "Old version") + + # Verify button text is exactly "Install old version" + button_label = tracking_button.get_child(0) + self.assertIsNotNone(button_label, "Button should have a label child") + self.assertEqual(button_label.get_text(), "Install\nolder version", + "Button text must be exactly 'Install\\nolder version' for older version") + finally: + osupdate.BuildInfo = original_build_info + osupdate.AppManager = original_app_manager + + def test_version_comparison_returns_integers_not_booleans(self): + """Test that compare_versions() returns integers, not booleans. + + This is the core bug test: the old code treated integer return values + as booleans in if statements. This test verifies the mock returns + proper integer values that would have caught the bug. + """ + # Test that compare_versions returns integers + result_greater = self.mock_app_manager.compare_versions("1.1.0", "1.0.0") + self.assertEqual(result_greater, 1, "Should return 1 for greater version") + self.assertIsInstance(result_greater, int, "Should return int, not bool") + + result_equal = self.mock_app_manager.compare_versions("1.0.0", "1.0.0") + self.assertEqual(result_equal, 0, "Should return 0 for equal version") + self.assertIsInstance(result_equal, int, "Should return int, not bool") + + result_less = self.mock_app_manager.compare_versions("0.9.0", "1.0.0") + self.assertEqual(result_less, -1, "Should return -1 for lesser version") + self.assertIsInstance(result_less, int, "Should return int, not bool") + + def test_button_text_with_multiple_version_pairs(self): + """Test button text with various version comparison scenarios. + + This comprehensive test ensures the button text is correct for + multiple version pairs, catching any edge cases in the comparison logic. + The bug being tested: compare_versions() returns integers (-1, 0, 1), + and these must be properly interpreted in if statements. + """ + from osupdate import OSUpdate + import osupdate + + test_cases = [ + # (new_version, current_version, expected_button_text, description) + ("2.0.0", "1.0.0", "Update OS", "Major version upgrade"), + ("1.1.0", "1.0.0", "Update OS", "Minor version upgrade"), + ("1.0.1", "1.0.0", "Update OS", "Patch version upgrade"), + ("1.0.0", "1.0.0", "Reinstall\nsame version", "Exact same version"), + ("0.9.9", "1.0.0", "Install\nolder version", "Downgrade to older version"), + ("0.5.0", "1.0.0", "Install\nolder version", "Major version downgrade"), + ("1.0.0", "2.0.0", "Install\nolder version", "Downgrade from major version"), + ] + + original_build_info = osupdate.BuildInfo + original_app_manager = osupdate.AppManager + try: + for new_version, current_version, expected_text, description in test_cases: + # Reset button state for each test + tracking_button = MockLVGLButton(initial_disabled=True) + tracking_button.children = [MockLVGLLabel()] + + # Set current version + osupdate.BuildInfo = MockBuildInfo + osupdate.BuildInfo.version.release = current_version + osupdate.AppManager = type('MockAppManager', (), { + 'compare_versions': staticmethod(self.mock_app_manager.compare_versions) + }) + + # Create app and mock components + app = OSUpdate() + app.install_button = tracking_button + app.status_label = MockLVGLLabel() + + # Call handle_update_info + app.handle_update_info(new_version, "http://example.com/update.bin", "Test") + + # Verify button text + button_label = tracking_button.get_child(0) + actual_text = button_label.get_text() + self.assertEqual(actual_text, expected_text, + f"Failed for {description}: {new_version} vs {current_version}. " + f"Expected '{expected_text}', got '{actual_text}'") + finally: + osupdate.BuildInfo = original_build_info + osupdate.AppManager = original_app_manager + +