feat(gui): open log line via configurable editor command (template {file}/{line})

refactor(settings): defaults carried by SettingsItem, getters/setters via _pref
refactor(pref-win): declarative Field table + _FIELD bridge + merged file pickers

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 14:44:23 +02:00
parent b5b8198c29
commit 4a72fe019e
5 changed files with 146 additions and 357 deletions

View File

@@ -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".

View File

@@ -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
==============

View File

@@ -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,127 +151,10 @@ 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
@@ -248,33 +162,3 @@ class TestiumSettings():
@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)

View File

@@ -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)

View File

@@ -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()