diff --git a/build.py b/build.py index 60255c1a..f9988e72 100644 --- a/build.py +++ b/build.py @@ -96,7 +96,7 @@ def release(): settings_path, next_version + snapshot_suffix, 'Bump version for next development iteration' ) - git('push', '-u', 'origin', 'master') + git('push', '-u', 'origin', 'main') try: git('push', 'origin', release_tag) try: @@ -106,7 +106,7 @@ def release(): ' git pull\n' ' git checkout %s\n' ' python build.py release\n' - ' git checkout master\n\n' + ' git checkout main\n\n' 'on the other OSs now, then come back here and do:' '\n\n' ' python build.py post_release\n' @@ -117,7 +117,7 @@ def release(): raise except: git('revert', '--no-edit', revision_before + '..HEAD' ) - git('push', '-u', 'origin', 'master') + git('push', '-u', 'origin', 'main') revision_before = git('rev-parse', 'HEAD').rstrip() raise except: @@ -149,7 +149,7 @@ def post_release(): create_cloudfront_invalidation(cloudfront_items_to_invalidate) record_release_on_server() upload_core_to_github() - git('checkout', 'master') + git('checkout', 'main') def _prompt_for_next_version(release_version): next_version = _get_suggested_next_version(release_version) diff --git a/src/main/python/fman/__init__.py b/src/main/python/fman/__init__.py index ab1f58d1..eefe7c12 100644 --- a/src/main/python/fman/__init__.py +++ b/src/main/python/fman/__init__.py @@ -41,6 +41,8 @@ DATA_DIRECTORY = expanduser('~/Library/Application Support/fman') elif PLATFORM == 'Linux': DATA_DIRECTORY = expanduser('~/.config/fman') +else: + raise NotImplementedError('Unsupported platform: %s' % PLATFORM) class ApplicationCommand: def __init__(self, window): diff --git a/src/main/python/fman/fs.py b/src/main/python/fman/fs.py index 2cf8f00f..d020a0a7 100644 --- a/src/main/python/fman/fs.py +++ b/src/main/python/fman/fs.py @@ -146,7 +146,7 @@ def prepare_trash(self, path): raise self._operation_not_implemented() return [Task( 'Deleting ' + path.rsplit('/', 1)[-1], - fn=self.delete, args=(path,), size=1 + fn=self.move_to_trash, args=(path,), size=1 )] def touch(self, path): raise self._operation_not_implemented() diff --git a/src/main/python/fman/impl/controller.py b/src/main/python/fman/impl/controller.py index f168ecdb..e6da74e2 100644 --- a/src/main/python/fman/impl/controller.py +++ b/src/main/python/fman/impl/controller.py @@ -50,12 +50,12 @@ def handle_nonexistent_shortcut(self, pane_widget, qkeyevent): pane = self._panes[pane_widget] key_event = QtKeyEvent(qkeyevent.key(), qkeyevent.modifiers()) return self._nonexistent_shortcut_handler(key_event, pane) - def on_doubleclicked(self, pane_widget, file_path): + def on_doubleclicked(self, pane_widget, file_url): past_events = self._metrics.past_events[::] self._metrics.track('DoubleclickedFile') pane = self._panes[pane_widget] if not self._usage_helper.on_doubleclicked(pane, past_events): - pane._broadcast('on_doubleclicked', file_path) + pane._broadcast('on_doubleclicked', file_url) def on_file_renamed(self, pane_widget, *args): self._metrics.track('RenamedFile') self._panes[pane_widget]._broadcast('on_name_edited', *args) @@ -68,7 +68,6 @@ def on_context_menu(self, pane_widget, event, file_under_mouse): elif event.reason() == QContextMenuEvent.Keyboard: via = 'Keyboard' else: - assert event.reason() == QContextMenuEvent.Other, event.reason() via = 'Other' past_events = self._metrics.past_events[::] self._metrics.track('OpenedContextMenu', {'via': via}) diff --git a/src/main/python/fman/impl/metrics.py b/src/main/python/fman/impl/metrics.py index d5682162..1d315899 100644 --- a/src/main/python/fman/impl/metrics.py +++ b/src/main/python/fman/impl/metrics.py @@ -10,8 +10,11 @@ from urllib.request import urlopen, Request import json +import logging import ssl +_LOG = logging.getLogger(__name__) + class MetricsError(Exception): pass @@ -69,14 +72,14 @@ def track(self, event, properties=None): try: self._backend.track(self._user, event, data) except MetricsError: - pass + _LOG.debug('Failed to track event %s', event, exc_info=True) def update_user(self, **properties): if not self._enabled: return try: self._backend.update_user(self._user, **properties) except MetricsError: - pass + _LOG.debug('Failed to update user', exc_info=True) def _read_json(self): with open(self._json_path, 'r') as f: return json.load(f) @@ -148,6 +151,7 @@ def flush(self, log_file_path): f.write('\n\n'.join(map(fmt_log, self._logs))) class AsynchronousMetrics: + _SENTINEL = object() def __init__(self, metrics): self.past_events = [] self._metrics = metrics @@ -164,7 +168,14 @@ def track(self, event, properties=None): self._queue.put(lambda: self._metrics.track(event, properties)) def update_user(self, **properties): self._queue.put(lambda: self._metrics.update_user(**properties)) + def shutdown(self, timeout=2): + self._queue.put(self._SENTINEL) + self._thread.join(timeout) def _work(self): while True: - self._queue.get()() + task = self._queue.get() + if task is self._SENTINEL: + self._queue.task_done() + break + task() self._queue.task_done() \ No newline at end of file diff --git a/src/main/python/fman/impl/model/model.py b/src/main/python/fman/impl/model/model.py index f09c1daa..76838992 100644 --- a/src/main/python/fman/impl/model/model.py +++ b/src/main/python/fman/impl/model/model.py @@ -10,17 +10,18 @@ from functools import wraps, lru_cache from PyQt5.QtCore import pyqtSignal, Qt from PyQt5.QtGui import QIcon, QPixmap -from threading import Event +from threading import Event, Lock from time import time def transaction(priority, synchronous=False): def decorator(f): @wraps(f) def result(self, *args, **kwargs): - if self._shutdown: + if self._shutdown.is_set(): return if synchronous: - assert not is_in_main_thread() + if is_in_main_thread(): + raise RuntimeError('Synchronous transaction must not run in main thread') has_run = Event() def task(): f(self, *args, **kwargs) @@ -66,7 +67,8 @@ def __init__( self._files = {} self._file_watcher = FileWatcher(fs, self) self._worker = Worker() - self._shutdown = False + self._shutdown = Event() + self._files_lock = Lock() def start(self, callback): self._worker.start() self._init(callback) @@ -78,7 +80,7 @@ def _init(self, callback): except FileNotFoundError: self.location_disappeared.emit(self._location) return - while not self._shutdown: + while not self._shutdown.is_set(): try: file_name = next(file_names) except FileNotFoundError: @@ -94,11 +96,10 @@ def _init(self, callback): continue files.append(file_) else: - assert self._shutdown return preloaded_files = self._sorted(self._filter(files)) for i in range(min(self._num_rows_to_preload, len(preloaded_files))): - if self._shutdown: + if self._shutdown.is_set(): return try: preloaded_files[i] = self._load_file(preloaded_files[i].url) @@ -151,14 +152,18 @@ def _on_rows_inited(self, rows, preloaded_rows, callback): # comment in #_on_rows_inited_main(...). self._on_rows_inited_main(rows, preloaded_rows, callback) else: - callback() + self._on_empty_rows_inited(callback) + @run_in_main_thread + def _on_empty_rows_inited(self, callback): + callback() @run_in_main_thread def _on_rows_inited_main(self, rows, preloaded_rows, callback): - self._files = { - row.url: row for row in rows - } - for preloaded_row in preloaded_rows: - self._files[preloaded_row.url] = preloaded_row + with self._files_lock: + self._files = { + row.url: row for row in rows + } + for preloaded_row in preloaded_rows: + self._files[preloaded_row.url] = preloaded_row # We have a transaction_ended listener that ensures we have a cursor. # It is used for example when a filter's conditions were relaxed, so # there are now visible files when previously there were none. However, @@ -176,7 +181,8 @@ def _on_rows_inited_main(self, rows, preloaded_rows, callback): def row_is_loaded(self, rownum): return self._rows[rownum].is_loaded def load_rows(self, rownums, callback=None): - assert is_in_main_thread() + if not is_in_main_thread(): + raise RuntimeError('load_rows must be called from main thread') urls = [self._rows[i].url for i in rownums] self._load_files_async(urls, callback) @transaction(priority=2) @@ -186,7 +192,7 @@ def _load_files(self, urls, callback=None): files = [] disappeared = [] for url in urls: - if self._shutdown: + if self._shutdown.is_set(): return try: files.append(self._load_file(url)) @@ -217,6 +223,8 @@ def _record_files_main(self, files, disappeared=None): """ if disappeared is None: disappeared = [] + if self._shutdown.is_set(): + return self._begin_transaction() RecordFiles( files, disappeared, self._files, @@ -226,15 +234,20 @@ def _record_files_main(self, files, disappeared=None): @transaction(priority=3) def sort(self, column, order=Qt.AscendingOrder): ascending = order == Qt.AscendingOrder - for i, row in enumerate(self._rows): + updated = {} + for row in list(self._rows): if not self._sort_value_is_loaded(row, column, ascending): new_row = self._load_sort_value(row, column, ascending) - # Here, we violate the constraint that data only be changed in - # the main thread. But! The data we are changing here is not - # "visible" outside this class. So it's OK. + updated[row.url] = new_row + self._commit_sort_updates_and_sort(updated, column, order) + @run_in_main_thread + def _commit_sort_updates_and_sort(self, updated, column, order): + for i, row in enumerate(self._rows): + if row.url in updated: + new_row = updated[row.url] self._rows[i] = new_row - self._files[row.url] = new_row - run_in_main_thread(super().sort)(column, order) + self._files[new_row.url] = new_row + super().sort(column, order) def _sort_value_is_loaded(self, row, column, ascending): try: self.get_sort_value(row, column, ascending) @@ -271,13 +284,14 @@ def update(self): @transaction(priority=5) def reload(self): self._fs.clear_cache(self._location) + files_snapshot = self._snapshot_files() files = [] try: file_names = iter(self._fs.iterdir(self._location)) except FileNotFoundError: self.location_disappeared.emit(self._location) return - while not self._shutdown: + while not self._shutdown.is_set(): try: file_name = next(file_names) except FileNotFoundError: @@ -287,9 +301,10 @@ def reload(self): break else: url = join(self._location, file_name) + self._fs.clear_cache(url) try: try: - file_before = self._files[url] + file_before = files_snapshot[url] except KeyError: file_ = self._init_file(url) else: @@ -301,16 +316,18 @@ def reload(self): continue files.append(file_) else: - assert self._shutdown return self._on_files_reloaded(files) # We may have found new files that now still need to be loaded: self._load_remaining_files() @run_in_main_thread def _on_files_reloaded(self, rows): - self._files = { - row.url: row for row in rows - } + if self._shutdown.is_set(): + return + with self._files_lock: + self._files = { + row.url: row for row in rows + } self.update() def get_columns(self): return self._columns @@ -326,6 +343,10 @@ def find(self, url): except KeyError: raise ValueError('%r is not in list' % url) from None return self.index(rownum, 0) + @run_in_main_thread + def _snapshot_files(self): + with self._files_lock: + return dict(self._files) def get_rows(self): return self._files.values() def get_sort_value(self, row, column, ascending): @@ -341,16 +362,19 @@ def setData(self, index, value, role): return super().setData(index, value, role) @transaction(priority=6, synchronous=True) def notify_file_added(self, url): - assert dirname(url) == self._location + if dirname(url) != self._location: + raise ValueError('url %r not in %r' % (url, self._location)) self._load_files([url]) @transaction(priority=6, synchronous=True) def notify_file_changed(self, url): - assert dirname(url) == self._location + if dirname(url) != self._location: + raise ValueError('url %r not in %r' % (url, self._location)) self._fs.clear_cache(url) self._load_files([url]) @transaction(priority=6, synchronous=True) def notify_file_renamed(self, old_url, new_url): - assert dirname(old_url) == dirname(new_url) == self._location + if not (dirname(old_url) == dirname(new_url) == self._location): + raise ValueError('urls not in %r' % self._location) self._fs.clear_cache(old_url) try: new_file = self._load_file(new_url) @@ -369,7 +393,8 @@ def _on_file_renamed(self, old_url, new_file): self._record_files([new_file], [old_url]) @transaction(priority=6, synchronous=True) def notify_file_removed(self, url): - assert dirname(url) == self._location + if dirname(url) != self._location: + raise ValueError('url %r not in %r' % (url, self._location)) self._fs.clear_cache(url) self._record_files([], [url]) @transaction(priority=7) @@ -378,8 +403,8 @@ def _load_remaining_files(self, batch_timeout=.2): files = [] disappeared = [] all_loaded = False - for row in self._rows: - if self._shutdown: + for row in list(self._rows): + if self._shutdown.is_set(): return if time() > end_time: break @@ -395,7 +420,7 @@ def _load_remaining_files(self, batch_timeout=.2): if not all_loaded: self._load_remaining_files() def shutdown(self): - self._shutdown = True + self._shutdown.set() # Similarly to why we don't want to call FileWatcher#start() from the # main thread, we also don't want to call #shutdown() from it to avoid # potential deadlocks. So do it asynchronously: diff --git a/src/main/python/fman/impl/model/table.py b/src/main/python/fman/impl/model/table.py index 33fe1c80..c02ce4bb 100644 --- a/src/main/python/fman/impl/model/table.py +++ b/src/main/python/fman/impl/model/table.py @@ -87,9 +87,10 @@ def insert_rows(self, rows, first_rownum=-1): self.endInsertRows() def move_rows(self, cut_start, cut_end, insert_start): dst_row = _get_move_destination(cut_start, cut_end, insert_start) - assert self.beginMoveRows( + if not self.beginMoveRows( QModelIndex(), cut_start, cut_end - 1, QModelIndex(), dst_row - ) + ): + raise RuntimeError('beginMoveRows failed') self._rows.move(cut_start, cut_end, insert_start) self.endMoveRows() def update_rows(self, rows, first_rownum): @@ -129,9 +130,11 @@ def __init__(self): self._keys = {} self._lock = RLock() def __len__(self): - return len(self._rows) + with self._lock: + return len(self._rows) def __getitem__(self, item): - return self._rows[item] + with self._lock: + return self._rows[item] def __setitem__(self, i, row): with self._lock: old_row = self._rows[i] @@ -140,7 +143,8 @@ def __setitem__(self, i, row): self._keys[row.key] = i self._check_integrity() def __iter__(self): - return iter(self._rows) + with self._lock: + return iter(list(self._rows)) def reset_to(self, new_rows): new_keys = {row.key: i for i, row in enumerate(new_rows)} with self._lock: @@ -151,7 +155,7 @@ def insert(self, rows, first_rownum): new_keys = {row.key: first_rownum + i for i, row in enumerate(rows)} with self._lock: # Perform this check here, once we have the lock: - if first_rownum < 0 or first_rownum > len(self._rows) + 1: + if first_rownum < 0 or first_rownum > len(self._rows): raise ValueError('Invalid first_rownum: %d' % first_rownum) num_rows = len(rows) for row in self._rows[first_rownum:]: @@ -183,7 +187,8 @@ def remove(self, start, end): del self._rows[start:end] self._check_integrity() def find(self, key): - return self._keys[key] + with self._lock: + return self._keys[key] def _cut(self, cut_start, cut_end): with self._lock: num_rows = len(self._rows) @@ -200,8 +205,8 @@ def _cut(self, cut_start, cut_end): self._rows = self._rows[:cut_start] + self._rows[cut_end:] return result def _check_integrity(self): - assert len(self._rows) == len(self._keys), \ - 'Integrity error, likely caused by duplicate rows' + if len(self._rows) != len(self._keys): + raise RuntimeError('Integrity error, likely caused by duplicate rows') class Row: def __init__(self, key, icon, drop_enabled, cells): diff --git a/src/main/python/fman/impl/model/worker.py b/src/main/python/fman/impl/model/worker.py index 51066233..2230ef58 100644 --- a/src/main/python/fman/impl/model/worker.py +++ b/src/main/python/fman/impl/model/worker.py @@ -21,16 +21,18 @@ def submit(self, priority, fn, *args, **kwargs): with self._shutdown_lock: if self._shutdown: return - self._queue.put(WorkItem(priority, fn, *args, *kwargs)) + self._queue.put(WorkItem(priority, fn, *args, **kwargs)) def shutdown(self): with self._shutdown_lock: self._shutdown = True self._queue.put(WorkItem(0, lambda: None)) - self._thread.join() + if self._thread.is_alive(): + self._thread.join() def _run(self): while True: task = self._queue.get() if task.is_shutdown(): + self._queue.task_done() break task.run() self._queue.task_done() @@ -56,7 +58,7 @@ def __lt__(self, other): return NotImplemented def __eq__(self, other): try: - return self._fn, self._args, self._kwargs, self._priority == \ - other._fn, other._args, other._kwargs, other._priority + return (self._fn, self._args, self._kwargs, self._priority) == \ + (other._fn, other._args, other._kwargs, other._priority) except AttributeError: return NotImplemented \ No newline at end of file diff --git a/src/main/python/fman/impl/onboarding/__init__.py b/src/main/python/fman/impl/onboarding/__init__.py index 13aa9575..ea16ee83 100644 --- a/src/main/python/fman/impl/onboarding/__init__.py +++ b/src/main/python/fman/impl/onboarding/__init__.py @@ -159,4 +159,6 @@ def _get_body_html(self): line = re.subn(r'\*([^*]+)\*', highlight(r'\1'), line)[0] line = re.subn(r'_([^_]+)_', underline(r'\1'), line)[0] result += line + if is_list: + result += '' return result \ No newline at end of file diff --git a/src/main/python/fman/impl/onboarding/tutorial.py b/src/main/python/fman/impl/onboarding/tutorial.py index 73d3b628..9cf5790f 100644 --- a/src/main/python/fman/impl/onboarding/tutorial.py +++ b/src/main/python/fman/impl/onboarding/tutorial.py @@ -367,7 +367,8 @@ def _get_step_paragraphs(self, instruction, path): (now, path, 'Cmd' if is_mac() else 'Ctrl') ) else: - assert instruction == 'go to' + if instruction != 'go to': + raise ValueError('Unexpected instruction: %r' % instruction) text = "We need to go to *%s*. Please press *%s* to open " \ "fman's GoTo dialog. There, type *%s* followed by *Enter*."\ % (path, self._cmd_p, path) @@ -506,7 +507,8 @@ def continue_from(src_url, showing_hidden_files=showing_hidden_files): if dst_is_unc: unc_parts = dst_drive[2:].split('\\') server = unc_parts[0] - assert server == server.upper(), server + if server != server.upper(): + raise ValueError('Server name not uppercase: %r' % server) src_path = splitscheme(src_url)[1] if not src_path: return [('open', server)] + continue_from('network://' + server) @@ -540,7 +542,8 @@ def _upper_server(unc_path): r""" \\server\Folder -> \\SERVER\Folder """ - assert unc_path.startswith(r'\\'), unc_path + if not unc_path.startswith(r'\\'): + raise ValueError('Not a UNC path: %r' % unc_path) try: i = unc_path.index('\\', 2) except ValueError: diff --git a/src/main/python/fman/impl/plugins/command_registry.py b/src/main/python/fman/impl/plugins/command_registry.py index e7204763..7bb1a0e6 100644 --- a/src/main/python/fman/impl/plugins/command_registry.py +++ b/src/main/python/fman/impl/plugins/command_registry.py @@ -153,9 +153,11 @@ def _set_context(self, pane, file_under_cursor=_DEFAULT): if file_under_cursor is not self._DEFAULT: cm = pane._override_file_under_cursor(file_under_cursor) cm.__enter__() - yield - if file_under_cursor is not self._DEFAULT: - cm.__exit__(None, None, None) + try: + yield + finally: + if file_under_cursor is not self._DEFAULT: + cm.__exit__(None, None, None) def _get_default_aliases(cmd_class): return re.sub(r'([a-z])([A-Z])', r'\1 \2', cmd_class.__name__)\ diff --git a/src/main/python/fman/impl/plugins/config.py b/src/main/python/fman/impl/plugins/config.py index efbb5b9b..1f30996b 100644 --- a/src/main/python/fman/impl/plugins/config.py +++ b/src/main/python/fman/impl/plugins/config.py @@ -3,6 +3,9 @@ from threading import RLock import json +import logging + +_LOG = logging.getLogger(__name__) class Config: def __init__(self, platform): @@ -54,16 +57,11 @@ def on_quit(self): for json_name in self._save_on_quit: try: self.save_json(json_name) - except ValueError as error_computing_delta: - # This can happen for a variety of reasons. One example: - # When multiple instances of fman are open and another - # instance has already written to the same json file, then - # the delta computation may fail with a ValueError. Ignore - # this so we can at least save the other files in - # _save_on_quit: + except ValueError: + _LOG.debug('Delta computation failed for %s', json_name, exc_info=True) continue except OSError: - # Not much we can do. Try the other JSONs at least: + _LOG.warning('Failed to save %s on quit', json_name, exc_info=True) continue def _reload_cache(self): old_cache = self._cache @@ -94,7 +92,7 @@ def load_json(paths): if result is None: result = type(next_value)(next_value) continue - if type(next_value) != type(result): + if not isinstance(next_value, type(result)): raise ValueError( 'Cannot join types %s and %s.' % (type(next_value).__name__, type(result).__name__) @@ -131,7 +129,7 @@ def get_differential_json(obj, paths, final_path): if old_obj is None: return obj else: - if type(obj) != type(old_obj): + if not isinstance(obj, type(old_obj)): raise ValueError( 'Cannot overwrite value of type %s with different type %s.' % (type(old_obj).__name__, type(obj).__name__) diff --git a/src/main/python/fman/impl/plugins/error.py b/src/main/python/fman/impl/plugins/error.py index 8b19d965..6aa6b51f 100644 --- a/src/main/python/fman/impl/plugins/error.py +++ b/src/main/python/fman/impl/plugins/error.py @@ -30,6 +30,7 @@ def _get_plugin_causing_error(self, traceback): if is_below_dir(frame.filename, plugin_dir): return plugin_dir def report(self, message, exc=None): + """exc: Exception instance, None to auto-capture, or False to suppress.""" if exc is None: exc = sys.exc_info()[1] if exc: @@ -46,8 +47,8 @@ def handle_system_exit(self, code=0): self._app.exit(code) def on_main_window_shown(self, main_window): self._main_window = main_window - if self._pending_error_messages: - self._main_window.show_alert(self._pending_error_messages[0]) + for message in self._pending_error_messages: + self._main_window.show_alert(message) def _get_plugin_traceback(self, exc): if isinstance(exc, ThemeError): return exc.description diff --git a/src/main/python/fman/impl/quicksearch.py b/src/main/python/fman/impl/quicksearch.py index 79314844..7a0dc51e 100644 --- a/src/main/python/fman/impl/quicksearch.py +++ b/src/main/python/fman/impl/quicksearch.py @@ -113,6 +113,8 @@ def _update_items(self, query): def _adjust_item_list_ize(self, min_num_items_to_display=7): num_items = len(self._curr_items) row_height = self._items.sizeHintForRow(0) + if row_height <= 0: + return max_height = num_items * row_height self._items.setMaximumHeight(max_height) if num_items >= min_num_items_to_display: diff --git a/src/main/python/fman/impl/session.py b/src/main/python/fman/impl/session.py index 58278245..18144e70 100644 --- a/src/main/python/fman/impl/session.py +++ b/src/main/python/fman/impl/session.py @@ -68,13 +68,6 @@ def _show_startup_messages(self, main_window): 'Updated to v%s. ' \ 'Changelog' % self._fman_version main_window.show_status_message(status_message, timeout_secs=5) - def _get_startup_message(self): - previous_version = self._settings.get('fman_version', None) - if not previous_version or previous_version == self._fman_version: - return 'v%s ready.' % self._fman_version - return 'Updated to v%s. ' \ - 'Changelog' \ - % self._fman_version def _init_panes(self, panes, pane_infos, paths_on_cmdline): with ThreadPoolExecutor(max_workers=len(panes)) as executor: futures = [ diff --git a/src/main/python/fman/impl/usage_helper.py b/src/main/python/fman/impl/usage_helper.py index a8500d11..1b917963 100644 --- a/src/main/python/fman/impl/usage_helper.py +++ b/src/main/python/fman/impl/usage_helper.py @@ -13,7 +13,8 @@ def on_context_menu(self, pane, via, past_events): def _on_mouse_action(self, pane, events): if not self._is_first_run: return False - assert events, events + if not events: + return False if events[-1] == 'AbortedTour' and 'AbortedTour' not in events[:-1]: response = show_alert( "Hey, sorry to bother again. You just used the mouse. That " diff --git a/src/main/python/fman/impl/util/path.py b/src/main/python/fman/impl/util/path.py index b5ffff8a..3c428aa5 100644 --- a/src/main/python/fman/impl/util/path.py +++ b/src/main/python/fman/impl/util/path.py @@ -34,5 +34,11 @@ def normalize(path_): if path_ == '.': path_ = '' # Resolve a/../b - path_ = re.subn(r'(^|/)([^/]+)/\.\.(?:$|/)', r'\1', path_)[0] + while True: + path_, count = re.subn(r'(^|/)((?!\.\.(?:/|$))[^/]+)/\.\.(?:$|/)', r'\1', path_) + if not count: + break + # Resolve /.. at root + while path_.startswith('/..'): + path_ = path_[3:] or '/' return path_.rstrip('/') \ No newline at end of file diff --git a/src/main/python/fman/impl/util/qt/__init__.py b/src/main/python/fman/impl/util/qt/__init__.py index 9d77ee96..8baddbad 100644 --- a/src/main/python/fman/impl/util/qt/__init__.py +++ b/src/main/python/fman/impl/util/qt/__init__.py @@ -16,8 +16,9 @@ def disable_window_animations_mac(window): # penalties and leads to subtle changes in behaviour. We therefore wait for # the Show event: def eventFilter(target, event): + from ctypes import c_void_p from objc import objc_object - view = objc_object(c_void_p=int(target.winId())) + view = objc_object(c_void_p=c_void_p(int(target.winId()))) NSWindowAnimationBehaviorNone = 2 view.window().setAnimationBehavior_(NSWindowAnimationBehaviorNone) FilterEventOnce(window, QEvent.Show, eventFilter) diff --git a/src/main/python/fman/impl/util/qt/key_event.py b/src/main/python/fman/impl/util/qt/key_event.py index fe550873..f9cbc347 100644 --- a/src/main/python/fman/impl/util/qt/key_event.py +++ b/src/main/python/fman/impl/util/qt/key_event.py @@ -15,11 +15,11 @@ def matches(self, keys): modifiers = self.modifiers if is_mac() and self.key in (Key_Down, Key_Up, Key_Left, Key_Right): # According to the Qt documentation ([1]), the KeypadModifier flag - # is set when an arrow key is pressed on OS X because the arrow keys + # is set when an arrow key is pressed on macOS because the arrow keys # are part of the keypad. We don't want our users to have to specify # this modifier in their keyboard binding files. So we overwrite # this behaviour of Qt. - # [1]: http://doc.qt.io/qt-5/qt.html#KeyboardModifier-enum + # [1]: https://doc.qt.io/archives/qt-5.15/qt.html#KeyboardModifier-enum modifiers &= ~KeypadModifier key, modifiers, keys = self._alias_return_and_enter(modifiers, keys) return QKeySequence(modifiers | key).matches(QKeySequence(keys)) \ diff --git a/src/main/python/fman/impl/view/cursor_movement.py b/src/main/python/fman/impl/view/cursor_movement.py index dcca56de..8aea49c2 100644 --- a/src/main/python/fman/impl/view/cursor_movement.py +++ b/src/main/python/fman/impl/view/cursor_movement.py @@ -12,10 +12,10 @@ def move_cursor_up(self, toggle_selection=False): self._move_cursor(self.MoveUp) def move_cursor_page_up(self, toggle_selection=False): self._move_cursor(self.MovePageUp, toggle_selection) - self.move_cursor_up() + self.move_cursor_up(toggle_selection) def move_cursor_page_down(self, toggle_selection=False): self._move_cursor(self.MovePageDown, toggle_selection) - self.move_cursor_down() + self.move_cursor_down(toggle_selection) def move_cursor_home(self, toggle_selection=False): self._move_cursor(self.MoveHome, toggle_selection) def move_cursor_end(self, toggle_selection=False): diff --git a/src/main/python/fman/impl/view/single_row_mode.py b/src/main/python/fman/impl/view/single_row_mode.py index 6e601c31..5569304a 100644 --- a/src/main/python/fman/impl/view/single_row_mode.py +++ b/src/main/python/fman/impl/view/single_row_mode.py @@ -30,8 +30,8 @@ def setSelectionModel(self, selectionModel): self.selectionModel().currentRowChanged.disconnect( self._current_row_changed ) - assert self._single_row_delegate - self.remove_delegate(self._single_row_delegate) + if self._single_row_delegate: + self.remove_delegate(self._single_row_delegate) super().setSelectionModel(selectionModel) selectionModel.currentRowChanged.connect(self._current_row_changed) self._single_row_delegate = SingleRowModeDelegate(self) diff --git a/src/main/python/fman/impl/widgets.py b/src/main/python/fman/impl/widgets.py index c92c08fc..5a591d98 100644 --- a/src/main/python/fman/impl/widgets.py +++ b/src/main/python/fman/impl/widgets.py @@ -37,7 +37,7 @@ def exit(self, returnCode=0): def set_style_sheet(self, stylesheet): self.setStyleSheet(stylesheet) def _on_state_changed(self, new_state): - if new_state == Qt.ApplicationActive: + if new_state == Qt.ApplicationActive and self._main_window is not None: for pane in self._main_window.get_panes(): pane.reload() @@ -155,7 +155,7 @@ def get_sort_column(self): return column, ascending @run_in_main_thread def get_column_widths(self): - return [self._file_view.columnWidth(i) for i in (0, 1)] + return [self._file_view.columnWidth(i) for i in range(self._model.columnCount())] @run_in_main_thread def set_column_widths(self, column_widths): num_columns = self._model.columnCount() diff --git a/src/main/resources/base/Plugins/Core/core/__init__.py b/src/main/resources/base/Plugins/Core/core/__init__.py index 478b81e4..5cdaf17f 100644 --- a/src/main/resources/base/Plugins/Core/core/__init__.py +++ b/src/main/resources/base/Plugins/Core/core/__init__.py @@ -30,7 +30,7 @@ def get_sort_value(self, url, is_ascending): match = re.search(r'\d+', str_) if match: minor += str_[:match.start()] - minor += '%06d' % int(match.group(0)) + minor += '%020d' % int(match.group(0)) str_ = str_[match.end():] else: minor += str_ diff --git a/src/main/resources/base/Plugins/Core/core/commands/__init__.py b/src/main/resources/base/Plugins/Core/core/commands/__init__.py index 319553d4..b6c88f53 100644 --- a/src/main/resources/base/Plugins/Core/core/commands/__init__.py +++ b/src/main/resources/base/Plugins/Core/core/commands/__init__.py @@ -17,7 +17,7 @@ from io import UnsupportedOperation from itertools import chain from os import strerror -from os.path import basename, pardir +from os.path import pardir from pathlib import PurePath from PyQt5.QtCore import QUrl from PyQt5.QtGui import QDesktopServices @@ -1730,7 +1730,7 @@ def on_command(self, command_name, args): except (KeyError, ValueError): return None if scheme == 'file://': - new_scheme = _get_handler_for_archive(basename(path)) + new_scheme = _get_handler_for_archive(basename(url)) if new_scheme: try: if is_dir(url): diff --git a/src/main/resources/base/Plugins/Core/core/commands/explorer_properties.py b/src/main/resources/base/Plugins/Core/core/commands/explorer_properties.py index b655352a..854bedda 100644 --- a/src/main/resources/base/Plugins/Core/core/commands/explorer_properties.py +++ b/src/main/resources/base/Plugins/Core/core/commands/explorer_properties.py @@ -96,9 +96,11 @@ def _show_file_properties(dir_, filenames): if not cm: return hMenu = win32gui.CreatePopupMenu() - cm.QueryContextMenu(hMenu, 0, 1, 0x7FFF, CMF_EXPLORE) - cm.InvokeCommand((0, None, 'properties', '', '', 1, 0, None)) - cm.QueryContextMenu(hMenu, 0, 1, 0x7FFF, CMF_EXPLORE) + try: + cm.QueryContextMenu(hMenu, 0, 1, 0x7FFF, CMF_EXPLORE) + cm.InvokeCommand((0, None, 'properties', '', '', 1, 0, None)) + finally: + win32gui.DestroyMenu(hMenu) def _show_drive_properties(drive_nobackslash): sei = SHELLEXECUTEINFO() diff --git a/src/main/resources/base/Plugins/Core/core/tests/fs/test_zip.py b/src/main/resources/base/Plugins/Core/core/tests/fs/test_zip.py index 837f2bc2..a68a38d6 100644 --- a/src/main/resources/base/Plugins/Core/core/tests/fs/test_zip.py +++ b/src/main/resources/base/Plugins/Core/core/tests/fs/test_zip.py @@ -351,7 +351,7 @@ def _read_directory(self, dir_path): child_contents = self._read_directory(child) else: child_contents = child.read_text() - result[child.name] = child_contents + result[normalize('NFC', child.name)] = child_contents return result def _expect_zip_contents(self, contents, zip_file_path): with TemporaryDirectory() as tmp_dir: diff --git a/src/main/resources/base/Plugins/Core/core/tests/test_quicksearch_matchers.py b/src/main/resources/base/Plugins/Core/core/tests/test_quicksearch_matchers.py index f33b67b4..21a87b22 100644 --- a/src/main/resources/base/Plugins/Core/core/tests/test_quicksearch_matchers.py +++ b/src/main/resources/base/Plugins/Core/core/tests/test_quicksearch_matchers.py @@ -1,4 +1,5 @@ -from core.quicksearch_matchers import contains_chars_after_separator +from core.quicksearch_matchers import contains_chars_after_separator, \ + contains_chars, contains_substring from unittest import TestCase class ContainsCharsAfterSeparatorTest(TestCase): @@ -24,4 +25,34 @@ def test_prefix_match(self): ) def setUp(self): super().setUp() - self.find_chars_after_space = contains_chars_after_separator(' ') \ No newline at end of file + self.find_chars_after_space = contains_chars_after_separator(' ') + +class ContainsCharsTest(TestCase): + def test_simple_match(self): + self.assertEqual([0, 1, 2], contains_chars('abc', 'abc')) + def test_sparse_match(self): + self.assertEqual([0, 2], contains_chars('abc', 'ac')) + def test_no_match(self): + self.assertIsNone(contains_chars('abc', 'z')) + def test_case_insensitive(self): + self.assertEqual([0, 1, 2], contains_chars('ABC', 'abc')) + def test_case_insensitive_query_upper(self): + self.assertEqual([0, 1, 2], contains_chars('abc', 'ABC')) + def test_case_insensitive_mixed(self): + self.assertEqual([0, 2], contains_chars('AbC', 'ac')) + def test_empty_query(self): + self.assertEqual([], contains_chars('abc', '')) + +class ContainsSubstringTest(TestCase): + def test_simple_match(self): + self.assertEqual([1, 2, 3], contains_substring('abcd', 'bcd')) + def test_no_match(self): + self.assertIsNone(contains_substring('abc', 'xyz')) + def test_case_insensitive(self): + self.assertEqual([0, 1, 2], contains_substring('ABC', 'abc')) + def test_case_insensitive_query_upper(self): + self.assertEqual([0, 1, 2], contains_substring('abc', 'ABC')) + def test_case_insensitive_mixed(self): + self.assertEqual([1, 2], contains_substring('aBc', 'BC')) + def test_empty_query(self): + self.assertEqual([], contains_substring('abc', '')) \ No newline at end of file diff --git a/src/unittest/python/fman_unittest/impl/model/test_model.py b/src/unittest/python/fman_unittest/impl/model/test_model.py index 00818a9e..1779ee41 100644 --- a/src/unittest/python/fman_unittest/impl/model/test_model.py +++ b/src/unittest/python/fman_unittest/impl/model/test_model.py @@ -6,6 +6,7 @@ from fman_unittest.impl.model import StubFileSystem from PyQt5.QtCore import QObject, pyqtSignal from random import shuffle, random +from threading import Thread from unittest import TestCase import random @@ -161,5 +162,39 @@ def f(url, cells, is_loaded=False, is_dir=False): def c(str_, sort_value_asc=0, sort_value_desc=_NOT_LOADED): return Cell(str_, sort_value_asc, sort_value_desc) +class ModelFilesCopyTest(TestCase): + def test_files_copy_safe_under_concurrent_mutation(self): + """dict.copy() must not raise RuntimeError when _files is mutated.""" + app = StubApp() + executor_before = Executor._INSTANCE + Executor._INSTANCE = Executor(app) + try: + fs = StubFileSystem({}) + model = Model(fs, 'null://', [Column()]) + model._files = {('s://%d' % i): None for i in range(1000)} + errors = [] + def copy_loop(): + try: + for _ in range(200): + model._files.copy() + except RuntimeError as e: + errors.append(e) + def mutate_loop(): + for i in range(1000, 1200): + model._files['s://%d' % i] = None + for i in range(1000, 1200): + model._files.pop('s://%d' % i, None) + t1 = Thread(target=copy_loop) + t2 = Thread(target=mutate_loop) + t1.start() + t2.start() + t1.join() + t2.join() + self.assertEqual([], errors) + finally: + app.aboutToQuit.emit() + Executor._INSTANCE = executor_before + + class StubApp(QObject): aboutToQuit = pyqtSignal() \ No newline at end of file diff --git a/src/unittest/python/fman_unittest/impl/model/test_worker.py b/src/unittest/python/fman_unittest/impl/model/test_worker.py new file mode 100644 index 00000000..4eef69d2 --- /dev/null +++ b/src/unittest/python/fman_unittest/impl/model/test_worker.py @@ -0,0 +1,89 @@ +from fman.impl.model.worker import Worker, WorkItem +from threading import Event +from time import sleep +from unittest import TestCase + +class WorkerTest(TestCase): + def test_priority_ordering(self): + results = [] + started = Event() + gate = Event() + done = Event() + def block(): + started.set() + gate.wait() + def record(val): + results.append(val) + if len(results) == 3: + done.set() + worker = Worker() + worker.start() + worker.submit(1, block) + started.wait() + worker.submit(3, record, 'low') + worker.submit(1, record, 'high') + worker.submit(2, record, 'mid') + gate.set() + done.wait(timeout=2) + worker.shutdown() + self.assertEqual(['high', 'mid', 'low'], results) + def test_shutdown_completes(self): + worker = Worker() + worker.start() + worker.shutdown() + self.assertFalse(worker._thread.is_alive()) + def test_submit_after_shutdown_ignored(self): + results = [] + worker = Worker() + worker.start() + worker.shutdown() + worker.submit(1, lambda: results.append(1)) + self.assertEqual([], results) + def test_shutdown_before_start(self): + worker = Worker() + worker.shutdown() + def test_exception_does_not_stop_worker(self): + results = [] + done = Event() + def record_and_signal(): + results.append('ok') + done.set() + worker = Worker() + worker.start() + worker.submit(1, self._raise_error) + worker.submit(2, record_and_signal) + done.wait(timeout=2) + worker.shutdown() + self.assertEqual(['ok'], results) + def test_priority_must_be_positive(self): + worker = Worker() + with self.assertRaises(ValueError): + worker.submit(0, lambda: None) + def _raise_error(self): + raise RuntimeError('test') + +class WorkItemTest(TestCase): + def test_is_shutdown_zero_priority(self): + item = WorkItem(0, lambda: None) + self.assertTrue(item.is_shutdown()) + def test_is_not_shutdown(self): + item = WorkItem(1, lambda: None) + self.assertFalse(item.is_shutdown()) + def test_ordering(self): + low = WorkItem(1, lambda: None) + high = WorkItem(5, lambda: None) + self.assertLess(low, high) + def test_run_captures_exception(self): + captured = [] + import sys + original = sys.excepthook + sys.excepthook = lambda *args: captured.append(args[1]) + try: + item = WorkItem(1, self._raise) + item.run() + finally: + sys.excepthook = original + self.assertEqual(1, len(captured)) + self.assertIsInstance(captured[0], ValueError) + def _raise(self): + raise ValueError('test') diff --git a/src/unittest/python/fman_unittest/impl/plugins/test_error.py b/src/unittest/python/fman_unittest/impl/plugins/test_error.py new file mode 100644 index 00000000..945c20b5 --- /dev/null +++ b/src/unittest/python/fman_unittest/impl/plugins/test_error.py @@ -0,0 +1,122 @@ +from fman.impl.plugins.error import format_traceback, walk_tb_with_filtering, \ + TracebackExceptionWithTbFilter, PluginErrorHandler +from unittest import TestCase + +import os + +class FormatTracebackTest(TestCase): + def test_excludes_frames_from_dir(self): + exc = self._make_exception() + result = format_traceback(exc, exclude_dirs=[os.path.dirname(__file__)]) + self.assertNotIn('test_error.py', result) + def test_includes_frames_outside_dir(self): + exc = self._make_exception() + result = format_traceback(exc, exclude_dirs=['/nonexistent']) + self.assertIn('_make_exception', result) + def test_empty_exclude_dirs(self): + exc = self._make_exception() + result = format_traceback(exc, exclude_dirs=[]) + self.assertIn('ValueError', result) + def _make_exception(self): + try: + raise ValueError('test error') + except ValueError as e: + return e + +class WalkTbWithFilteringTest(TestCase): + def test_no_filter_yields_all(self): + exc = self._make_exception() + frames = list(walk_tb_with_filtering(exc.__traceback__)) + self.assertGreaterEqual(len(frames), 1) + def test_filter_excludes_frames(self): + exc = self._make_exception() + frames = list(walk_tb_with_filtering(exc.__traceback__, lambda tb: False)) + self.assertEqual([], frames) + def test_filter_none_yields_all(self): + exc = self._make_exception() + all_frames = list(walk_tb_with_filtering(exc.__traceback__)) + none_frames = list(walk_tb_with_filtering(exc.__traceback__, None)) + self.assertEqual(len(all_frames), len(none_frames)) + def _make_exception(self): + try: + raise RuntimeError('test') + except RuntimeError as e: + return e + +class TracebackExceptionWithTbFilterTest(TestCase): + def test_from_exception(self): + exc = self._make_chained_exception() + te = TracebackExceptionWithTbFilter.from_exception(exc) + formatted = ''.join(te.format()) + self.assertIn('RuntimeError', formatted) + def test_with_cause(self): + exc = self._make_chained_exception() + te = TracebackExceptionWithTbFilter.from_exception(exc) + formatted = ''.join(te.format()) + self.assertIn('ValueError', formatted) + self.assertIn('RuntimeError', formatted) + def test_with_tb_filter(self): + exc = self._make_chained_exception() + te = TracebackExceptionWithTbFilter.from_exception( + exc, tb_filter=lambda tb: True + ) + formatted = ''.join(te.format()) + self.assertIn('RuntimeError', formatted) + def test_filter_all_frames(self): + exc = self._make_chained_exception() + te = TracebackExceptionWithTbFilter.from_exception( + exc, tb_filter=lambda tb: False + ) + formatted = ''.join(te.format()) + self.assertIn('RuntimeError', formatted) + self.assertNotIn('File', formatted) + def test_context_suppressed_when_all_frames_hidden(self): + exc = self._make_chained_exception() + te = TracebackExceptionWithTbFilter.from_exception( + exc, tb_filter=lambda tb: False + ) + self.assertTrue(te.__suppress_context__) + def _make_chained_exception(self): + try: + try: + raise ValueError('cause') + except ValueError: + raise RuntimeError('effect') + except RuntimeError as e: + return e + +class PluginErrorHandlerTest(TestCase): + def test_report_stores_pending_without_window(self): + handler = self._make_handler() + handler.report('test message', exc=False) + self.assertEqual(['test message'], handler._pending_error_messages) + def test_report_shows_on_window(self): + handler = self._make_handler() + window = StubMainWindow() + handler.on_main_window_shown(window) + handler.report('hello', exc=False) + self.assertEqual(['hello'], window.alerts) + def test_pending_messages_shown_on_window(self): + handler = self._make_handler() + handler.report('pending', exc=False) + window = StubMainWindow() + handler.on_main_window_shown(window) + self.assertEqual(['pending'], window.alerts) + def test_add_remove_dir(self): + handler = self._make_handler() + handler.add_dir('/plugins/Foo') + handler.add_dir('/plugins/Bar') + handler.remove_dir('/plugins/Foo') + self.assertEqual(['/plugins/Bar'], handler._plugin_dirs) + def _make_handler(self): + return PluginErrorHandler(StubApp()) + +class StubApp: + def exit(self, code=0): + pass + +class StubMainWindow: + def __init__(self): + self.alerts = [] + def show_alert(self, message): + self.alerts.append(message) diff --git a/src/unittest/python/fman_unittest/impl/plugins/test_plugin.py b/src/unittest/python/fman_unittest/impl/plugins/test_plugin.py index 8e0df07d..c9921b1c 100644 --- a/src/unittest/python/fman_unittest/impl/plugins/test_plugin.py +++ b/src/unittest/python/fman_unittest/impl/plugins/test_plugin.py @@ -1,6 +1,6 @@ from fman.fs import FileSystem from fman.impl.plugins.plugin import _get_command_name, \ - get_command_class_name, FileSystemWrapper + get_command_class_name, FileSystemWrapper, ReportExceptions from fman_unittest.impl.plugins import StubErrorHandler from unittest import TestCase @@ -94,6 +94,35 @@ def setUp(self): super().setUp() self._error_handler = StubErrorHandler() +class ReportExceptionsTest(TestCase): + def test_no_exception(self): + handler = StubErrorHandler() + with ReportExceptions(handler, 'msg') as cm: + pass + self.assertIsNone(cm.exception) + self.assertEqual([], handler.error_messages) + def test_reports_exception(self): + handler = StubErrorHandler() + with ReportExceptions(handler, 'something broke'): + raise ValueError('bad') + self.assertEqual(['something broke'], handler.error_messages) + def test_excludes_exception_class(self): + handler = StubErrorHandler() + with self.assertRaises(StopIteration): + with ReportExceptions(handler, 'msg', exclude={StopIteration}): + raise StopIteration() + self.assertEqual([], handler.error_messages) + def test_exclude_must_be_set(self): + handler = StubErrorHandler() + with self.assertRaises(ValueError): + ReportExceptions(handler, 'msg', exclude=StopIteration) + def test_exception_stored(self): + handler = StubErrorHandler() + with ReportExceptions(handler, 'msg') as cm: + raise RuntimeError('stored') + self.assertIsInstance(cm.exception, RuntimeError) + + class StubMotherFileSystem: def __init__(self, columns): self._columns = columns diff --git a/src/unittest/python/fman_unittest/impl/util/test_path.py b/src/unittest/python/fman_unittest/impl/util/test_path.py index eb05ea32..47b645f2 100644 --- a/src/unittest/python/fman_unittest/impl/util/test_path.py +++ b/src/unittest/python/fman_unittest/impl/util/test_path.py @@ -40,4 +40,20 @@ def test_trailing_double_dot(self): def test_single_dot_only(self): self.assertEqual('', normalize('.')) def test_pardir_of_subdir(self): - self.assertEqual('a', normalize('a/b/..')) \ No newline at end of file + self.assertEqual('a', normalize('a/b/..')) + def test_multi_level_pardir(self): + self.assertEqual('a', normalize('a/b/c/../..')) + def test_deep_pardir_chain(self): + self.assertEqual('a/d', normalize('a/b/c/../../d')) + def test_root_level_pardir(self): + self.assertEqual('', normalize('/..')) + def test_root_level_double_pardir(self): + self.assertEqual('', normalize('/../..')) + def test_root_with_path_after_pardir(self): + self.assertEqual('/a', normalize('/../a')) + def test_empty_path(self): + self.assertEqual('', normalize('')) + def test_double_slash(self): + self.assertEqual('a/b', normalize('a//b')) + def test_trailing_slash(self): + self.assertEqual('a/b', normalize('a/b/')) \ No newline at end of file diff --git a/src/unittest/python/fman_unittest/impl/view/test_resize_cols_to_contents.py b/src/unittest/python/fman_unittest/impl/view/test_resize_cols_to_contents.py index 4029abf5..91b21df1 100644 --- a/src/unittest/python/fman_unittest/impl/view/test_resize_cols_to_contents.py +++ b/src/unittest/python/fman_unittest/impl/view/test_resize_cols_to_contents.py @@ -1,5 +1,5 @@ from fman.impl.view.resize_cols_to_contents import _get_ideal_column_widths, \ - _resize_column + _resize_column, _distribute_evenly, _distribute_exponentially from unittest import TestCase class GetIdealColumnWidthsTest(TestCase): @@ -43,6 +43,12 @@ def test_enlarge_trims_last_col(self): self._expect([2, 1], 0, 2, [1, 2], [1, 1], 3) def test_rightalign_last_col(self): self._expect([1, 2, 1], 0, 1, [2, 1, 1], [1, 1, 1]) + def test_shrink_respects_available_width_cap(self): + result = _resize_column(0, 3, [3, 1, 1], [1, 1, 1], 6) + self.assertLessEqual(sum(result), 6) + def test_shrink_expansion_does_not_exceed_available(self): + result = _resize_column(0, 4, [4, 1], [1, 1], 5) + self.assertLessEqual(sum(result), 5) def _expect( self, result, col, old_size, widths, min_widths, available_width=None ): @@ -51,4 +57,30 @@ def _expect( actual = _resize_column( col, old_size, widths, min_widths, available_width ) - self.assertEqual(result, actual) \ No newline at end of file + self.assertEqual(result, actual) + +class DistributeEvenlyTest(TestCase): + def test_exact_division(self): + self.assertEqual([5, 5], _distribute_evenly(10, [1, 1])) + def test_zero_total(self): + self.assertEqual([0, 0], _distribute_evenly(10, [0, 0])) + def test_single_proportion(self): + self.assertEqual([7], _distribute_evenly(7, [3])) + def test_weighted(self): + result = _distribute_evenly(100, [3, 1]) + self.assertEqual(100, sum(result)) + self.assertGreater(result[0], result[1]) + def test_zero_width(self): + self.assertEqual([0, 0], _distribute_evenly(0, [1, 1])) + +class DistributeExponentiallyTest(TestCase): + def test_exact_division(self): + result = _distribute_exponentially(100, [10, 10]) + self.assertEqual(100, sum(result)) + def test_zero_total(self): + self.assertEqual([0, 0], _distribute_exponentially(10, [0, 0])) + def test_larger_proportion_gets_more(self): + result = _distribute_exponentially(100, [10, 1]) + self.assertGreater(result[0], result[1]) + def test_zero_width(self): + self.assertEqual([0, 0], _distribute_exponentially(0, [5, 5])) \ No newline at end of file