diff --git a/DESIGN.md b/DESIGN.md index d11ef34..8cbf104 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -323,6 +323,7 @@ The `testium_assist` editor extension is a thin LSP client that spawns `testium Both Flatpak and AppImage export `TESTIUM_VERSION` from a launcher (Flatpak: launcher script in `org.testium.Testium.yaml`; AppImage: `runtime.env` in `AppImageBuilder.yml`). `get_testium_version()` checks `/.flatpak-info` / `APPIMAGE` and reads `TESTIUM_VERSION` rather than relying on package metadata or repo introspection. ## Recent fixes / notable changes +- Open-log-at-line (GUI): double-click on a tree item opened the log via a hardcoded `code -g {file}:{line}` (broke in Flatpak where `code` is absent). Now driven by a configurable `editor_cmd` preference (placeholders `{file}`/`{line}`, default `code -g {file}:{line}`); the argv is built by `shlex.split` then per-token `.format` (paths with spaces stay one token), wrapped by `bins.host_console_command()` for Flatpak host-spawn, with a `host_open_path`/`openUrl` fallback (no line) when empty or failing. Settings/pref refactor alongside: `SettingsItem` carries its default (single source of truth), trivial getters/setters collapse to `_pref(item)` bindings, the pref window's `elements` dict becomes a `Field(key, type, widget)` table with a per-type `_FIELD` read/write bridge, and the four file-picker slots fold into `_pick_dir`/`_pick_file`. (Also fixed a latent default mismatch: `report_path` defaulted to `$(home)` in the property but `$(test_directory)` in the pref window; unified to `$(test_directory)`.) - Show Results (GUI): the toolbar action stays enabled during a run (the log grows live, so it is useful mid-test), not just after. In Flatpak `QDesktopServices.openUrl` routes through the OpenURI portal and often opens no editor for a `.log`; `bins.host_open_path()` now spawns `xdg-open` on the host via `flatpak-spawn --host` (returns False outside Flatpak so the caller falls back to `openUrl`). - Test-tree search (GUI): a Ctrl+F find bar highlights + navigates matching items, with Name/Type/Doc field checkboxes. Search modifications run under `blockSignals` (else `setBackground`→`itemChanged`→`on_testChecked` storms the controller), and the search/run highlights share one flag-driven `_refresh_highlight()` (run > search > default) so overlapping layers never leave a stale colour. See "## Test-tree search (GUI)". - `pytest` item: pytest analogue of `unittest`, but runs on the **host interpreter in a subprocess** (`bins.python_bin()`, like `py_func`) so it works across every packaging channel. A stdlib-only pytest plugin streams collected node-ids + per-test results back over stdout via sentinels; each test becomes a child item with its own PASS/FAIL/SKIP, duration and failure message. Params: `test_file`, `test_method`. Validation item: `test/validation/items/pytest/` (the validation venv now pip-installs `pytest`). See "### `pytest` item". diff --git a/release_note.txt b/release_note.txt index 42160f4..6767d97 100644 --- a/release_note.txt +++ b/release_note.txt @@ -8,6 +8,11 @@ version 0.3.2 even when testium is in the GUI. - The captured output of a ``run`` step can be saved with ``store_result`` and inspected afterwards (for example with ``expected_result`` or a ``py_func``). +- "Show Results" now opens the log on Flatpak (it used to do nothing) and can + be used while a test is running, not only after it finishes. +- Double-clicking a test item to open the log now uses an editor of your choice: + a new preference holds the command (default ``code -g {file}:{line}``); set it + to your editor (for example ``kate -l {line} {file}``). Works on Flatpak too. version 0.3.1 ============== diff --git a/src/testium/interpreter/utils/settings.py b/src/testium/interpreter/utils/settings.py index b5c87e6..65d9f1b 100644 --- a/src/testium/interpreter/utils/settings.py +++ b/src/testium/interpreter/utils/settings.py @@ -13,30 +13,59 @@ def init(): settings = TestiumSettings() +_UNSET = object() + + class SettingsItem(): - def __init__(self, name: str, item_type: type) -> None: + def __init__(self, name: str, item_type: type, default=None) -> None: self.name = name self.t = item_type + self.default = default + + +def _pref(item): + """Build a get/set property reading/writing *item* (default carried by the item).""" + return property(lambda self: self.value(item), + lambda self, value: self.set_value(item, value)) class TestiumSettings(): - SettingsRecentFiles = SettingsItem('recentFileList', list) - SettingsLastLogFile = SettingsItem('lastLogFile', str) - SettingsLogFileSaved = SettingsItem('logFileSaved', bool) - SettingsHideDocPane = SettingsItem('docPaneHidden', bool) - SettingsHideLogPane = SettingsItem('logPaneHidden', bool) - SettingsShowCheckboxes = SettingsItem('checkBoxesShow', bool) - SettingsLogPath = SettingsItem('defaultLogPath', str) - SettingsReportPath = SettingsItem('defaultReportPath', str) - SettingsShowTimeColumn = SettingsItem('showTimeColumn', bool) - SettingsColumnsSize = SettingsItem('columnsSize', dict) - SettingsDblClickEnabled = SettingsItem('dblClickEnabled', bool) - SettingsIconsTheme = SettingsItem('iconsTheme', int) - SettingsLogFont = SettingsItem('logFont', str) - SettingsLogFontSize = SettingsItem('logFontSize', int) - SettingsGitSupported = SettingsItem('logGitSupported', bool) - SettingsPythonPath = SettingsItem('pythonPath', str) - SettingsLuaPath = SettingsItem('luaPath', str) + SettingsRecentFiles = SettingsItem('recentFileList', list, []) + SettingsLastLogFile = SettingsItem('lastLogFile', str, '') + SettingsLogFileSaved = SettingsItem('logFileSaved', bool, False) + SettingsHideDocPane = SettingsItem('docPaneHidden', bool, False) + SettingsHideLogPane = SettingsItem('logPaneHidden', bool, False) + SettingsShowCheckboxes = SettingsItem('checkBoxesShow', bool, False) + SettingsLogPath = SettingsItem('defaultLogPath', str, '$(test_directory)') + SettingsReportPath = SettingsItem('defaultReportPath', str, '$(test_directory)') + SettingsShowTimeColumn = SettingsItem('showTimeColumn', bool, False) + SettingsColumnsSize = SettingsItem('columnsSize', dict, {}) + SettingsDblClickEnabled = SettingsItem('dblClickEnabled', bool, False) + SettingsEditorCmd = SettingsItem('editorCmd', str, 'code -g {file}:{line}') + SettingsIconsTheme = SettingsItem('iconsTheme', int, 0) + SettingsLogFont = SettingsItem('logFont', str, 'Monospace') + SettingsLogFontSize = SettingsItem('logFontSize', int, 8) + SettingsGitSupported = SettingsItem('logGitSupported', bool, True) + SettingsPythonPath = SettingsItem('pythonPath', str, '') + SettingsLuaPath = SettingsItem('luaPath', str, '') + + recent_files = _pref(SettingsRecentFiles) + log_file = _pref(SettingsLastLogFile) + log_file_saved = _pref(SettingsLogFileSaved) + hide_doc_pane = _pref(SettingsHideDocPane) + hide_log_pane = _pref(SettingsHideLogPane) + show_checkboxes = _pref(SettingsShowCheckboxes) + log_path = _pref(SettingsLogPath) + report_path = _pref(SettingsReportPath) + show_time_column = _pref(SettingsShowTimeColumn) + columns_size = _pref(SettingsColumnsSize) + dbl_click_enabled = _pref(SettingsDblClickEnabled) + editor_cmd = _pref(SettingsEditorCmd) + icons_theme = _pref(SettingsIconsTheme) + log_font = _pref(SettingsLogFont) + git_supported = _pref(SettingsGitSupported) + python_bin = _pref(SettingsPythonPath) + lua_bin = _pref(SettingsLuaPath) def __init__(self): if 'windows' in platform.system().lower(): @@ -71,9 +100,11 @@ class TestiumSettings(): self.conf['Default'] = {} self.sync() - def value(self, key: SettingsItem, default=''): + def value(self, key: SettingsItem, default=_UNSET): if not isinstance(key, SettingsItem): raise ETUMRuntimeError('Not a proper Settings item.') + if default is _UNSET: + default = key.default if type(default) != key.t: raise ETUMRuntimeError( 'Types mismatch in config file. You could try to erase "{}" to solve the issue'.format(self.settings_fname)) @@ -120,161 +151,14 @@ class TestiumSettings(): if configfile.writable(): self.conf.write(configfile) -# SettingsRecentFiles = 'recentFileList' - @property - def recent_files(self): - return self.value(self.SettingsRecentFiles, []) - - @recent_files.setter - def recent_files(self, value): - self.set_value(self.SettingsRecentFiles, value) - -# SettingsLastLogFile = 'lastLogFile' - @property - def log_file(self): - return self.value(self.SettingsLastLogFile) - - @log_file.setter - def log_file(self, value): - self.set_value(self.SettingsLastLogFile, value) - -# SettingsLogFileSaved = 'logFileSaved' - @property - def log_file_saved(self): - return self.value(self.SettingsLogFileSaved, False) - - @log_file_saved.setter - def log_file_saved(self, value): - self.set_value(self.SettingsLogFileSaved, value) - -# SettingsHideDocPane = 'docPaneHidden' - @property - def hide_doc_pane(self): - return self.value(self.SettingsHideDocPane, False) - - @hide_doc_pane.setter - def hide_doc_pane(self, value): - self.set_value(self.SettingsHideDocPane, value) - -# SettingsHideLogPane = 'logPaneHidden' - @property - def hide_log_pane(self): - return self.value(self.SettingsHideLogPane, False) - - @hide_log_pane.setter - def hide_log_pane(self, value): - self.set_value(self.SettingsHideLogPane, value) - -# SettingsShowCheckboxes = 'checkBoxesShow' - @property - def show_checkboxes(self): - return self.value(self.SettingsShowCheckboxes, False) - - @show_checkboxes.setter - def show_checkboxes(self, value): - self.set_value(self.SettingsShowCheckboxes, value) - -# SettingsLogPath = 'defaultLogPath' - @property - def log_path(self): - return self.value(self.SettingsLogPath, '$(test_directory)') - - @log_path.setter - def log_path(self, value): - self.set_value(self.SettingsLogPath, value) - -# SettingsReportPath = 'defaultReportPath' - @property - def report_path(self): - return self.value(self.SettingsReportPath, '$(home)') - - @report_path.setter - def report_path(self, value): - self.set_value(self.SettingsReportPath, value) - -# SettingsShowTimeColumn = 'showTimeColumn' - @property - def show_time_column(self): - return self.value(self.SettingsShowTimeColumn, False) - - @show_time_column.setter - def show_time_column(self, value): - self.set_value(self.SettingsShowTimeColumn, value) - -# SettingsColumnsSize = 'columnsSize' - @property - def columns_size(self): - return self.value(self.SettingsColumnsSize, {}) - - @columns_size.setter - def columns_size(self, value): - self.set_value(self.SettingsColumnsSize, value) - -# SettingsDblClickEnabled = 'dblClickEnabled' - @property - def dbl_click_enabled(self): - return self.value(self.SettingsDblClickEnabled, False) - - @dbl_click_enabled.setter - def dbl_click_enabled(self, value): - self.set_value(self.SettingsDblClickEnabled, value) - -# SettingsIconsTheme = 'iconsTheme' - @property - def icons_theme(self): - return self.value(self.SettingsIconsTheme, 0) - - @icons_theme.setter - def icons_theme(self, value): - self.set_value(self.SettingsIconsTheme, value) - -# SettingsLogFont = 'logFont' - @property - def log_font(self): - return self.value(self.SettingsLogFont, 'Monospace') - - @log_font.setter - def log_font(self, value): - self.set_value(self.SettingsLogFont, value) - -# SettingsLogFontSize = 'logFontSize' + # log_font_size keeps a custom getter: clamp non-positive sizes to 8. @property def log_font_size(self): - v = self.value(self.SettingsLogFontSize, 8) + v = self.value(self.SettingsLogFontSize) if v <= 0: v = 8 return v @log_font_size.setter def log_font_size(self, value): - self.set_value(self.SettingsLogFontSize, value) - -# SettingsGitSupported = 'gitSupported' - @property - def git_supported(self): - r = self.value(self.SettingsGitSupported, True) - return r - - @git_supported.setter - def git_supported(self, value): - self.set_value(self.SettingsGitSupported, value) - -# SettingsPythonPath = 'python_bin' - @property - def python_bin(self): - r = self.value(self.SettingsPythonPath, "") - return r - - @python_bin.setter - def python_bin(self, value): - self.set_value(self.SettingsPythonPath, value) - -# SettingsLuaPath = 'luaPath' - @property - def lua_bin(self): - r = self.value(self.SettingsLuaPath, "") - return r - - @lua_bin.setter - def lua_bin(self, value): - self.set_value(self.SettingsLuaPath, value) \ No newline at end of file + self.set_value(self.SettingsLogFontSize, value) \ No newline at end of file diff --git a/src/testium/main_win/preference_win/preference_win.py b/src/testium/main_win/preference_win/preference_win.py index d204697..3a05f07 100644 --- a/src/testium/main_win/preference_win/preference_win.py +++ b/src/testium/main_win/preference_win/preference_win.py @@ -1,5 +1,7 @@ -from PySide6.QtCore import Slot, Qt -from PySide6.QtWidgets import QDialog, QFileDialog +from collections import namedtuple + +from PySide6.QtCore import Slot +from PySide6.QtWidgets import QDialog, QFileDialog, QLabel, QLineEdit from PySide6.QtGui import QFont from main_win.preference_win.preference_core_win import Ui_preferenceWindow @@ -8,6 +10,24 @@ from main_win import file_dialog import interpreter.utils.settings as prefs +def _set_font(w, v): + f = QFont() + f.fromString(v) + w.setCurrentFont(f) + + +# Per-type widget <-> value bridge: (read from widget, write to widget). +_FIELD = { + "bool": (lambda w: w.isChecked(), lambda w, v: w.setChecked(v)), + "text": (lambda w: w.text(), lambda w, v: w.setText(v)), + "int": (lambda w: int(w.value()), lambda w, v: w.setValue(v)), + "combo": (lambda w: int(w.currentIndex()), lambda w, v: w.setCurrentIndex(v)), + "font": (lambda w: w.currentFont().toString(), _set_font), +} + +Field = namedtuple("Field", "key type widget") + + class PrefWindow(QDialog): def __init__(self, parent=None): super().__init__(parent) @@ -17,162 +37,57 @@ class PrefWindow(QDialog): self.ui.buttonBox.accepted.connect(self.on_buttOKPressed) self.ui.buttonBox.rejected.connect(self.on_buttCancelPressed) self.finished.connect(self.on_finishedPressed) - self.ui.butLogPath.triggered.connect(self.on_butLogPath_pressed) - self.ui.butReportPath.triggered.connect(self.on_butReportPath_pressed) - self.ui.butPythonPath.triggered.connect(self.on_butPythonPath_pressed) - self.ui.butLuaPath.triggered.connect(self.on_butLuaPath_pressed) - self.elements = { - prefs.settings.SettingsHideDocPane: { - "type": "bool", - "widget": self.ui.checkDocPane, - "value": prefs.settings.hide_doc_pane, - "default": False, - "changed": False, - }, - prefs.settings.SettingsHideLogPane: { - "type": "bool", - "widget": self.ui.checkLogPane, - "value": prefs.settings.hide_log_pane, - "default": False, - "changed": False, - }, - prefs.settings.SettingsShowCheckboxes: { - "type": "bool", - "widget": self.ui.checkBoxTest, - "value": prefs.settings.show_checkboxes, - "default": False, - "changed": False, - }, - prefs.settings.SettingsShowTimeColumn: { - "type": "bool", - "widget": self.ui.checkShowTime, - "value": prefs.settings.show_time_column, - "default": False, - "changed": False, - }, - prefs.settings.SettingsLogPath: { - "type": "text", - "widget": self.ui.editDefaultLogPath, - "value": prefs.settings.log_path, - "default": "$(test_directory)", - "changed": False, - }, - prefs.settings.SettingsReportPath: { - "type": "text", - "widget": self.ui.editDefaultReportPath, - "value": prefs.settings.report_path, - "default": "$(test_directory)", - "changed": False, - }, - prefs.settings.SettingsDblClickEnabled: { - "type": "bool", - "widget": self.ui.checkDblClick, - "value": prefs.settings.dbl_click_enabled, - "default": False, - "changed": False, - }, - prefs.settings.SettingsIconsTheme: { - "type": "combo", - "widget": self.ui.choiceIconsTheme, - "value": prefs.settings.icons_theme, - "default": 0, - "changed": False, - }, - prefs.settings.SettingsLogFont: { - "type": "font", - "widget": self.ui.font_choice, - "value": prefs.settings.log_font, - "default": "Monospace", - "changed": False, - }, - prefs.settings.SettingsLogFontSize: { - "type": "int", - "widget": self.ui.font_size, - "value": prefs.settings.log_font_size, - "default": 8, - "changed": False, - }, - prefs.settings.SettingsGitSupported: { - "type": "bool", - "widget": self.ui.checkGitSupported, - "value": prefs.settings.git_supported, - "default": True, - "changed": False, - }, - prefs.settings.SettingsPythonPath: { - "type": "text", - "widget": self.ui.editPythonPath, - "value": prefs.settings.python_bin, - "default": "", - "changed": False, - }, - prefs.settings.SettingsLuaPath: { - "type": "text", - "widget": self.ui.editLuaPath, - "value": prefs.settings.lua_bin, - "default": "", - "changed": False, - }, - } + self.ui.butLogPath.triggered.connect( + lambda: self._pick_dir(self.ui.editDefaultLogPath, "Select the default log directory")) + self.ui.butReportPath.triggered.connect( + lambda: self._pick_dir(self.ui.editDefaultReportPath, "Select the default report directory")) + self.ui.butPythonPath.triggered.connect( + lambda: self._pick_file(self.ui.editPythonPath, "Select the python interpreter")) + self.ui.butLuaPath.triggered.connect( + lambda: self._pick_file(self.ui.editLuaPath, "Select the lua interpreter")) + + # Editor command field, added in code (mirrors the F1 filter approach) so the + # generated UI stays untouched. Sits with the double-click toggle it feeds. + self.editEditorCmd = QLineEdit(self.ui.scrollAreaWidgetContents) + self.editEditorCmd.setPlaceholderText("ex: code -g {file}:{line}") + self.ui.formLayout.addRow(QLabel("Open log line in editor"), self.editEditorCmd) + + s = prefs.settings + self.fields = [ + Field(s.SettingsHideDocPane, "bool", self.ui.checkDocPane), + Field(s.SettingsHideLogPane, "bool", self.ui.checkLogPane), + Field(s.SettingsShowCheckboxes, "bool", self.ui.checkBoxTest), + Field(s.SettingsShowTimeColumn, "bool", self.ui.checkShowTime), + Field(s.SettingsLogPath, "text", self.ui.editDefaultLogPath), + Field(s.SettingsReportPath, "text", self.ui.editDefaultReportPath), + Field(s.SettingsDblClickEnabled, "bool", self.ui.checkDblClick), + Field(s.SettingsEditorCmd, "text", self.editEditorCmd), + Field(s.SettingsIconsTheme, "combo", self.ui.choiceIconsTheme), + Field(s.SettingsLogFont, "font", self.ui.font_choice), + Field(s.SettingsLogFontSize, "int", self.ui.font_size), + Field(s.SettingsGitSupported, "bool", self.ui.checkGitSupported), + Field(s.SettingsPythonPath, "text", self.ui.editPythonPath), + Field(s.SettingsLuaPath, "text", self.ui.editLuaPath), + ] + self._changed = set() self.restore_prefs() def store_prefs(self): - for k, v in self.elements.items(): - self.elements[k]["changed"] = False - if v["type"] == "bool": - val = v["widget"].isChecked() - if self.elements[k]["value"] != val: - self.elements[k]["value"] = val - self.elements[k]["changed"] = True - - if v["type"] == "text": - val = v["widget"].text() - if self.elements[k]["value"] != val: - self.elements[k]["value"] = val - self.elements[k]["changed"] = True - - if v["type"] == "font": - val = v["widget"].currentFont().toString() - if self.elements[k]["value"] != val: - self.elements[k]["value"] = val - self.elements[k]["changed"] = True - - if v["type"] == "int": - val = int(v["widget"].value()) - if self.elements[k]["value"] != val: - self.elements[k]["value"] = val - self.elements[k]["changed"] = True - - if v["type"] == "combo": - val = int(v["widget"].currentIndex()) - if self.elements[k]["value"] != val: - self.elements[k]["value"] = val - self.elements[k]["changed"] = True - - if self.elements[k]["changed"]: - prefs.settings.set_value(k, v["value"]) - + self._changed = set() + for f in self.fields: + val = _FIELD[f.type][0](f.widget) + if val != prefs.settings.value(f.key): + prefs.settings.set_value(f.key, val) + self._changed.add(f.key.name) prefs.settings.sync() def restore_prefs(self): - for k, v in self.elements.items(): - v["value"] = prefs.settings.value(k, v["default"]) - if v["type"] == "bool": - v["widget"].setChecked(v["value"]) - elif v["type"] == "text": - v["widget"].setText(self.elements[k]["value"]) - elif v["type"] == "font": - f = QFont() - f.fromString(self.elements[k]["value"]) - v["widget"].setCurrentFont(f) - elif v["type"] == "int": - v["widget"].setValue(self.elements[k]["value"]) - elif v["type"] == "combo": - v["widget"].setCurrentIndex(self.elements[k]["value"]) + for f in self.fields: + _FIELD[f.type][1](f.widget, prefs.settings.value(f.key)) def isChanged(self, setting): - return self.elements[setting]["changed"] + return setting.name in self._changed @Slot() def on_buttOKPressed(self): @@ -188,46 +103,14 @@ class PrefWindow(QDialog): def on_finishedPressed(self): self.restore_prefs() - @Slot() - def on_butReportPath_pressed(self): + def _pick_dir(self, edit, caption): path = QFileDialog.getExistingDirectory( - self, - caption="Select the default report directory", - dir=self.ui.editDefaultReportPath.text(), - options=file_dialog.options(), - ) + self, caption=caption, dir=edit.text(), options=file_dialog.options()) if path: - self.ui.editDefaultReportPath.setText(path) + edit.setText(path) - @Slot() - def on_butLogPath_pressed(self): - path = QFileDialog.getExistingDirectory( - self, - caption="Select the default log directory", - dir=self.ui.editDefaultLogPath.text(), - options=file_dialog.options(), - ) - if path: - self.ui.editDefaultLogPath.setText(path) - - @Slot() - def on_butPythonPath_pressed(self): + def _pick_file(self, edit, caption): path, _ = QFileDialog.getOpenFileName( - self, - caption="Select the python interpreter", - dir=self.ui.editPythonPath.text(), - options=file_dialog.options(), - ) + self, caption=caption, dir=edit.text(), options=file_dialog.options()) if path: - self.ui.editPythonPath.setText(path) - - @Slot() - def on_butLuaPath_pressed(self): - path, _ = QFileDialog.getOpenFileName( - self, - caption="Select the lua interpreter", - dir=self.ui.editLuaPath.text(), - options=file_dialog.options(), - ) - if path: - self.ui.editLuaPath.setText(path) + edit.setText(path) diff --git a/src/testium/main_win/testium_win.py b/src/testium/main_win/testium_win.py index 6ae5797..4a04a35 100755 --- a/src/testium/main_win/testium_win.py +++ b/src/testium/main_win/testium_win.py @@ -1,5 +1,7 @@ import sys import os +import shlex +import subprocess import webbrowser from multiprocessing import Queue from threading import Thread @@ -745,7 +747,21 @@ class MainWindow(QMainWindow, Ui_MainWindow): if (self.logFileName is not None) and os.access(self.logFileName, os.R_OK): ln = tm.line_number("@@{}@@".format(item.timestamp()), self.logFileName) if ln > 0: - os.system("{} -g {}:{} &".format("code", self.logFileName, ln + 1)) + self._open_in_editor(self.logFileName, ln + 1) + + def _open_in_editor(self, path, line): + """Open path at line via the configured editor template ({file}/{line}). + Empty template or failure falls back to opening the file without line.""" + tmpl = prefs.settings.editor_cmd + if tmpl: + try: + argv = [p.format(file=path, line=line) for p in shlex.split(tmpl)] + subprocess.Popen(bins.host_console_command(argv, os.path.dirname(path) or ".")) + return + except (KeyError, ValueError, IndexError, OSError): + pass + if not bins.host_open_path(path): + QDesktopServices.openUrl(QUrl.fromLocalFile(path)) def on_spacePressed(self): item = self.treeTests.currentItem()