From b58ffb6aed0254c565788e912a7cbc13db826275 Mon Sep 17 00:00:00 2001 From: Ted Lawson Date: Fri, 11 Aug 2023 04:22:10 -0700 Subject: [PATCH] Setting for number format in archive tab. By @bigtedde (#1719) --- src/vorta/store/settings.py | 12 ++++ src/vorta/utils.py | 12 +--- src/vorta/views/archive_tab.py | 11 +-- src/vorta/views/main_window.py | 1 + src/vorta/views/misc_tab.py | 6 +- tests/unit/test_misc.py | 67 ++++++++++++------ tests/unit/test_utils.py | 126 ++++++++++++++++----------------- 7 files changed, 133 insertions(+), 102 deletions(-) diff --git a/src/vorta/store/settings.py b/src/vorta/store/settings.py index f0559582b..40324e9b7 100644 --- a/src/vorta/store/settings.py +++ b/src/vorta/store/settings.py @@ -59,6 +59,18 @@ def get_misc_settings() -> List[Dict[str, str]]: 'label': trans_late('settings', 'Get statistics of file/folder when added'), 'tooltip': trans_late('settings', 'When adding a new source, calculate its size and the number of files.'), }, + { + 'key': 'enable_fixed_units', + 'value': False, + 'type': 'checkbox', + 'group': information, + 'label': trans_late('settings', 'Use the same unit of measurement for archive sizes'), + 'tooltip': trans_late( + 'settings', + 'When enabled, all archive sizes will use the same unit of measurement, ' + 'such as KB or MB. This can make archive sizes easier to compare.', + ), + }, { 'key': 'use_system_keyring', 'value': True, diff --git a/src/vorta/utils.py b/src/vorta/utils.py index 1793a5e88..033b486a5 100644 --- a/src/vorta/utils.py +++ b/src/vorta/utils.py @@ -24,6 +24,8 @@ # Used to store whether a user wanted to override the # default directory for the --development flag DEFAULT_DIR_FLAG = object() +METRIC_UNITS = ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'] +NONMETRIC_UNITS = ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi'] borg_compat = BorgCompatibility() _network_status_monitor = None @@ -140,14 +142,10 @@ def get_network_status_monitor(): def get_path_datasize(path, exclude_patterns): file_info = QFileInfo(path) - data_size = 0 if file_info.isDir(): data_size, files_count = get_directory_size(file_info.absoluteFilePath(), exclude_patterns) - # logger.info("path (folder) %s %u elements size now=%u (%s)", - # file_info.absoluteFilePath(), files_count, data_size, pretty_bytes(data_size)) else: - # logger.info("path (file) %s size=%u", file_info.path(), file_info.size()) data_size = file_info.size() files_count = 1 @@ -279,11 +277,7 @@ def pretty_bytes( if not isinstance(size, int): return '' prefix = '+' if sign and size > 0 else '' - power, units = ( - (10**3, ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']) - if metric - else (2**10, ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi']) - ) + power, units = (10**3, METRIC_UNITS) if metric else (2**10, NONMETRIC_UNITS) if fixed_unit is None: n = find_best_unit_for_size(size, metric=metric, precision=precision) else: diff --git a/src/vorta/views/archive_tab.py b/src/vorta/views/archive_tab.py index 54ae7aa13..c29218c97 100644 --- a/src/vorta/views/archive_tab.py +++ b/src/vorta/views/archive_tab.py @@ -33,7 +33,7 @@ from vorta.borg.rename import BorgRenameJob from vorta.borg.umount import BorgUmountJob from vorta.i18n import translate -from vorta.store.models import ArchiveModel, BackupProfileMixin +from vorta.store.models import ArchiveModel, BackupProfileMixin, SettingsModel from vorta.utils import ( borg_compat, choose_file_dialog, @@ -291,9 +291,12 @@ def populate_from_profile(self): formatted_time = archive.time.strftime('%Y-%m-%d %H:%M') self.archiveTable.setItem(row, 0, QTableWidgetItem(formatted_time)) - self.archiveTable.setItem( - row, 1, SizeItem(pretty_bytes(archive.size, fixed_unit=best_unit, precision=SIZE_DECIMAL_DIGITS)) - ) + + # format units based on user settings for 'dynamic' or 'fixed' units + fixed_unit = best_unit if SettingsModel.get(key='enable_fixed_units').value else None + size = pretty_bytes(archive.size, fixed_unit=fixed_unit, precision=SIZE_DECIMAL_DIGITS) + self.archiveTable.setItem(row, 1, SizeItem(size)) + if archive.duration is not None: formatted_duration = str(timedelta(seconds=round(archive.duration))) else: diff --git a/src/vorta/views/main_window.py b/src/vorta/views/main_window.py index 3da134f46..1f0c42e73 100644 --- a/src/vorta/views/main_window.py +++ b/src/vorta/views/main_window.py @@ -78,6 +78,7 @@ def __init__(self, parent=None): self.repoTab.repo_changed.connect(self.archiveTab.populate_from_profile) self.repoTab.repo_changed.connect(self.scheduleTab.populate_from_profile) self.repoTab.repo_added.connect(self.archiveTab.refresh_archive_list) + self.miscTab.refresh_archive.connect(self.archiveTab.populate_from_profile) self.createStartBtn.clicked.connect(self.app.create_backup_action) self.cancelButton.clicked.connect(self.app.backup_cancelled_event.emit) diff --git a/src/vorta/views/misc_tab.py b/src/vorta/views/misc_tab.py index 2d52880f0..83c47be64 100644 --- a/src/vorta/views/misc_tab.py +++ b/src/vorta/views/misc_tab.py @@ -1,6 +1,6 @@ import logging -from PyQt6 import uic +from PyQt6 import QtCore, uic from PyQt6.QtCore import Qt from PyQt6.QtWidgets import ( QApplication, @@ -28,6 +28,8 @@ class MiscTab(MiscTabBase, MiscTabUI, BackupProfileMixin): + refresh_archive = QtCore.pyqtSignal() + def __init__(self, parent=None): """Init.""" super().__init__(parent) @@ -101,6 +103,8 @@ def populate(self): cb.setCheckState(Qt.CheckState(setting.value)) cb.setTristate(False) cb.stateChanged.connect(lambda v, key=setting.key: self.save_setting(key, v)) + if setting.key == 'enable_fixed_units': + cb.stateChanged.connect(self.refresh_archive.emit) tb = ToolTipButton() tb.setToolTip(setting.tooltip) diff --git a/tests/unit/test_misc.py b/tests/unit/test_misc.py index 680ac9d88..eb1a5ac1f 100644 --- a/tests/unit/test_misc.py +++ b/tests/unit/test_misc.py @@ -11,7 +11,6 @@ def test_autostart(qapp, qtbot): """Check if file exists only on Linux, otherwise just check it doesn't crash""" - setting = "Automatically start Vorta at login" _click_toggle_setting(setting, qapp, qtbot) @@ -34,10 +33,41 @@ def test_autostart(qapp, qtbot): assert not os.path.exists(autostart_path) +def test_enable_fixed_units(qapp, qtbot, mocker): + """Tests the 'enable fixed units' setting to ensure the archive tab sizes are displayed correctly.""" + tab = qapp.main_window.archiveTab + setting = "Use the same unit of measurement for archive sizes" + + # set mocks + mock_setting = mocker.patch.object(vorta.views.archive_tab.SettingsModel, "get", return_value=Mock(value=True)) + mock_pretty_bytes = mocker.patch.object(vorta.views.archive_tab, "pretty_bytes") + + # with setting enabled, fixed units should be determined and passed to pretty_bytes as an 'int' + tab.populate_from_profile() + mock_pretty_bytes.assert_called() + kwargs_list = mock_pretty_bytes.call_args_list[0].kwargs + assert 'fixed_unit' in kwargs_list + assert isinstance(kwargs_list['fixed_unit'], int) + + # disable setting and reset mock + mock_setting.return_value = Mock(value=False) + mock_pretty_bytes.reset_mock() + + # with setting disabled, pretty_bytes should be called with fixed units set to 'None' + tab.populate_from_profile() + mock_pretty_bytes.assert_called() + kwargs_list = mock_pretty_bytes.call_args_list[0].kwargs + assert 'fixed_unit' in kwargs_list + assert kwargs_list['fixed_unit'] is None + + # use the qt bot to click the setting and see that the refresh_archive emit works as intended. + with qtbot.waitSignal(qapp.main_window.miscTab.refresh_archive, timeout=5000): + _click_toggle_setting(setting, qapp, qtbot) + + @pytest.mark.skipif(sys.platform != 'darwin', reason="Full Disk Access check only on Darwin") def test_check_full_disk_access(qapp, qtbot, mocker): """Enables/disables 'Check for Full Disk Access on startup' setting and ensures functionality""" - setting = "Check for Full Disk Access on startup" # Set mocks for setting enabled @@ -64,23 +94,16 @@ def test_check_full_disk_access(qapp, qtbot, mocker): def _click_toggle_setting(setting, qapp, qtbot): - """Click toggle setting in the misc tab""" - - main = qapp.main_window - main.tabWidget.setCurrentIndex(4) - tab = main.miscTab - - for x in range(0, tab.checkboxLayout.count()): - item = tab.checkboxLayout.itemAt(x, QFormLayout.ItemRole.FieldRole) - if not item: - continue - checkbox = item.itemAt(0).widget() - checkbox.__class__ = QCheckBox - - if checkbox.text() == setting: - # Have to use pos to click checkbox correctly - # https://stackoverflow.com/questions/19418125/pysides-qtest-not-checking-box/24070484#24070484 - qtbot.mouseClick( - checkbox, QtCore.Qt.MouseButton.LeftButton, pos=QtCore.QPoint(2, int(checkbox.height() / 2)) - ) - break + """Toggle setting checkbox in the misc tab""" + miscTab = qapp.main_window.miscTab + + for x in range(miscTab.checkboxLayout.count()): + item = miscTab.checkboxLayout.itemAt(x, QFormLayout.ItemRole.FieldRole) + if item is not None: + checkbox = item.itemAt(0).widget() + if checkbox.text() == setting and isinstance(checkbox, QCheckBox): + # Have to use pos to click checkbox correctly + # https://stackoverflow.com/questions/19418125/pysides-qtest-not-checking-box/24070484#24070484 + pos = QtCore.QPoint(2, int(checkbox.height() / 2)) + qtbot.mouseClick(checkbox, QtCore.Qt.MouseButton.LeftButton, pos=pos) + break diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 4d9c3d6f3..db575216a 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -1,7 +1,11 @@ import uuid +import pytest from vorta.keyring.abc import VortaKeyring -from vorta.utils import find_best_unit_for_sizes, pretty_bytes +from vorta.utils import ( + find_best_unit_for_sizes, + pretty_bytes, +) def test_keyring(): @@ -13,70 +17,60 @@ def test_keyring(): assert keyring.get_password("vorta-repo", REPO) == UNICODE_PW -def test_best_size_unit_precision0(): +@pytest.mark.parametrize( + "precision, expected_unit", + [ + (0, 1), # return units as "1" (represents KB), min=100KB + (1, 2), # return units as "2" (represents MB), min=0.1MB + (2, 2), # still returns KB, since 0.1MB < min=0.001 GB to allow for GB to be best_unit + ], +) +def test_best_unit_for_sizes_precision(precision, expected_unit): MB = 1000000 sizes = [int(0.1 * MB), 100 * MB, 2000 * MB] - unit = find_best_unit_for_sizes(sizes, metric=True, precision=0) - assert unit == 1 # KB, min=100KB - - -def test_best_size_unit_precision1(): - MB = 1000000 - sizes = [int(0.1 * MB), 100 * MB, 2000 * MB] - unit = find_best_unit_for_sizes(sizes, metric=True, precision=1) - assert unit == 2 # MB, min=0.1MB - - -def test_best_size_unit_empty(): - sizes = [] - unit = find_best_unit_for_sizes(sizes, metric=True, precision=1) - assert unit == 0 # bytes - - -def test_best_size_unit_precision3(): - MB = 1000000 - sizes = [1 * MB, 100 * MB, 2000 * MB] - unit = find_best_unit_for_sizes(sizes, metric=True, precision=3) - assert unit == 3 # GB, min=0.001 GB - - -def test_best_size_unit_nonmetric1(): - sizes = [102] - unit = find_best_unit_for_sizes(sizes, metric=False, precision=1) - assert unit == 0 # 102 < 0.1KB - - -def test_best_size_unit_nonmetric2(): - sizes = [103] - unit = find_best_unit_for_sizes(sizes, metric=False, precision=1) - assert unit == 1 # 103bytes == 0.1KB - - -def test_pretty_bytes_metric_fixed1(): - s = pretty_bytes(1000000, metric=True, precision=0, fixed_unit=2) - assert s == "1 MB" - - -def test_pretty_bytes_metric_fixed2(): - s = pretty_bytes(1000000, metric=True, precision=1, fixed_unit=2) - assert s == "1.0 MB" - - -def test_pretty_bytes_metric_fixed3(): - s = pretty_bytes(100000, metric=True, precision=1, fixed_unit=2) - assert s == "0.1 MB" - - -def test_pretty_bytes_nonmetric_fixed1(): - s = pretty_bytes(1024 * 1024, metric=False, precision=1, fixed_unit=2) - assert s == "1.0 MiB" - - -def test_pretty_bytes_metric_nonfixed2(): - s = pretty_bytes(1000000, metric=True, precision=1) - assert s == "1.0 MB" - - -def test_pretty_bytes_metric_large(): - s = pretty_bytes(10**30, metric=True, precision=1) - assert s == "1000000.0 YB" + best_unit = find_best_unit_for_sizes(sizes, metric=True, precision=precision) + assert best_unit == expected_unit + + +@pytest.mark.parametrize( + "sizes, expected_unit", + [ + ([], 0), # no sizes given but should still return "0" (represents bytes) as best representation + ([102], 0), # non-metric size 102 < 0.1KB (102 < 0.1 * 1024), so it will return 0 instead of 1 + ([103], 1), # non-metric size 103 > 0.1KB (103 < 0.1 * 1024), so it will return 1 + ], +) +def test_best_unit_for_sizes_nonmetric(sizes, expected_unit): + best_unit = find_best_unit_for_sizes(sizes, metric=False, precision=1) + assert best_unit == expected_unit + + +@pytest.mark.parametrize( + "size, metric, precision, fixed_unit, expected_output", + [ + (10**5, True, 1, 2, "0.1 MB"), # 100KB, metric, precision 1, fixed unit "2" (MB) + (10**6, True, 0, 2, "1 MB"), # 1MB, metric, precision 0, fixed unit "2" (MB) + (10**6, True, 1, 2, "1.0 MB"), # 1MB, metric, precision 1, fixed unit "2" (MB) + (1024 * 1024, False, 1, 2, "1.0 MiB"), # 1MiB, nonmetric, precision 1, fixed unit "2" (MiB) + ], +) +def test_pretty_bytes_fixed_units(size, metric, precision, fixed_unit, expected_output): + # test pretty bytes when specifying a fixed unit of measurement + output = pretty_bytes(size, metric=metric, precision=precision, fixed_unit=fixed_unit) + assert output == expected_output + + +@pytest.mark.parametrize( + "size, metric, expected_output", + [ + (10**6, True, "1.0 MB"), # 1MB, metric + (10**24, True, "1.0 YB"), # 1YB, metric + (10**30, True, "1000000.0 YB"), # test huge number, metric + (1024 * 1024, False, "1.0 MiB"), # 1MiB, nonmetric + (2**40 * 2**40, False, "1.0 YiB"), # 1YiB, nonmetric + ], +) +def test_pretty_bytes_nonfixed_units(size, metric, expected_output): + # test pretty bytes when NOT specifying a fixed unit of measurement + output = pretty_bytes(size, metric=metric, precision=1) + assert output == expected_output