diff --git a/CHANGELOG.md b/CHANGELOG.md
index c97fc77..a660f14 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- In-app **Update now** on the update-available dialog: downloads and runs the
+ platform install scripts (``install.ps1`` on Windows, ``install.sh`` on macOS /
+ Linux) for the selected GitHub release tag, then prompts you to restart the
+ app from the Start menu or applications launcher.
+- **View release notes** on the same dialog opens full release notes (and a
+ GitHub link) in a separate window.
+- ``dbs_annotator.utils.auto_update`` with optional ``dry_run=True`` (PowerShell
+ ``-WhatIf`` / ``install.sh --dry-run``) for maintainers to preview an install.
- Read the Docs screenshot pipeline: home screen (light and dark theme), clinical
and session scales settings dialogs, and regenerated workflow captures at
native resolution (HiDPI-aware screen grab, improved PNG settings).
@@ -24,7 +32,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Help** dialog rewritten around the standard **Complete Workflow** programming
pipeline, timestamped ``task-programming`` TSV output, and Word/PDF reports;
+ notes that each stimulation configuration is saved and summarised in reports;
copyright lists MGH, Wyss Center, and Charité; MIT license noted.
+- Update-available dialog: removed the extra “don't notify automatically”
+ checkbox (that preference stays under **Help** only); release notes are no
+ longer inlined—use **View release notes**; **Open download page** removed in
+ favour of **Update now**.
+- Pre-release update notice: “If you encounter issues, please report them to …”.
- Annotations-only file step header title: **Clinical Annotations Setup** (was
"Output File").
- Home screen buttons: **Complete Workflow** and **Annotations-only Workflow**.
diff --git a/docs/index.rst b/docs/index.rst
index 3058eb8..6d74ce4 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -8,22 +8,26 @@ DBS Annotator
:align: center
:width: 180px
-|
-
**DBS Annotator** is a desktop application for recording and analysing
-Deep Brain Stimulation (DBS) clinical programming sessions. It guides the
-clinician or researcher through the **Complete Workflow** — from initial
-electrode configuration and baseline scales, through real-time stimulation
-adjustments, to the automatic generation of structured Word and PDF reports.
+Deep Brain Stimulation (DBS) clinical programming sessions. It guides
+the clinician or researcher through a DBS programming pipeline: initial
+electrode configuration, clinical scales, and general annotations;
+real-time stimulation adjustments; and session-specific scale changes and
+notes. Finally, it can generate structured Word and PDF reports from the
+programming session data.
-Developed at the **Brain Modulation Lab, Massachusetts General Hospital** (Boston, USA),
-the **Wyss Center for Bio and Neuroengineering** (Geneva, Switzerland), and
-**Charité Universitätsmedizin Berlin** (Germany).
+Developed at the **Brain Modulation Lab, Massachusetts General Hospital**
+(Boston, USA), the **Wyss Center for Bio and Neuroengineering** (Geneva,
+Switzerland), and **Charité Universitätsmedizin Berlin** (Germany).
.. note::
- | Version |release|.
- | Copyright © Massachusetts General Hospital, Wyss Center for Bio and Neuroengineering, and Charité Universitätsmedizin Berlin.
- | Contact: lucia.poma@wysscenter.ch
+
+ **Version:** |release|
+
+ Copyright © Massachusetts General Hospital, Wyss Center for Bio and
+ Neuroengineering, and Charité Universitätsmedizin Berlin.
+
+ **Contact:** lucia.poma@wysscenter.ch
----
@@ -68,17 +72,19 @@ Quick Overview
:header-rows: 0
* - **Complete Workflow**
- - Record stimulation parameters, clinical scales, and notes step-by-step.
- Export a structured report (Word / PDF) with tables, electrode diagrams,
- and session-scale timeline charts.
+ - Record stimulation parameters, clinical scales, and notes
+ step-by-step in a timestamped TSV table. Export a structured report
+ (Word / PDF) with tables, electrode diagrams, and session-scale
+ timeline charts.
* - **Annotations-only Workflow**
- - Quick timestamped text notes without the full stimulation workflow.
- * - **Longitudinal report**
- - Combine multiple session files into a single comparative document with
- overview tables, clinical and session-scale charts, electrode diagrams,
- and programming summaries.
+ - Quick timestamped text notes.
+ * - **Session and longitudinal reports**
+ - Combine single or multiple session files into a single comparative
+ document with overview tables, clinical and session-scale charts,
+ electrode diagrams, and programming summaries.
* - **BIDS-compliant output**
- - Data saved as ``sub-XX_ses-YYYYMMDD_task-programming_run-XX_events.tsv``.
+ - Data saved as
+ ``sub-XXXX_ses-YYYYMMDD_task- The main use of {html.escape(APP_NAME)} is a standard pipeline for
- DBS programming sessions: baseline setup, session scales, and
+ DBS programming sessions: baseline setup, session scales, and
real-time recording of each stimulation configuration you test in clinic. {html.escape(APP_NAME)} records each stimulation configuration
+ you enter during a session in a consistent, reviewable form. Word and
+ PDF reports summarise that programming history for clinical follow-up,
+ audit, and research, together with scale scores and clinical
+ observations. Every entry is written immediately to a tab-separated
@@ -418,7 +428,7 @@ def _help_dialog_html(self) -> str:
clinical inspection without reopening the raw TSV. You can also build a longitudinal report from several
General Overview
+ Timestamped data for analysis
task-programming files (same subject), or use
- Annotations-only Workflow for lightweight timestamped notes.
© 2025-{year} {html.escape(COPYRIGHT_HOLDERS)}
@@ -486,6 +496,15 @@ def _show_info_dialog(self) -> None: def _manual_update_check(self, button: QPushButton) -> None: """Force an update check and show the result, used by the Help dialog.""" + checker = self._update_checker + if checker.is_busy(): + QMessageBox.information( + self, + "Checking for updates", + "An update check is already in progress. Please wait a moment.", + ) + return + button.setEnabled(False) original_text = button.text() button.setText("Checking…") @@ -501,6 +520,15 @@ def cleanup() -> None: except (RuntimeError, TypeError): pass connections.clear() + try: + checker.update_available.connect(self._on_update_available) + except RuntimeError: + pass + + try: + checker.update_available.disconnect(self._on_update_available) + except RuntimeError: + pass def on_available(release: ReleaseInfo) -> None: cleanup() @@ -522,13 +550,52 @@ def on_failed(error: str) -> None: f"Could not reach the update server:\n\n{error}", ) - connections.append((self._update_checker.update_available, on_available)) - connections.append((self._update_checker.up_to_date, on_up_to_date)) - connections.append((self._update_checker.failed, on_failed)) + connections.append((checker.update_available, on_available)) + connections.append((checker.up_to_date, on_up_to_date)) + connections.append((checker.failed, on_failed)) for signal, slot in connections: signal.connect(slot) - self._update_checker.check_async(force=True) + if not checker.check_async(force=True): + cleanup() + QMessageBox.warning( + self, + "Update check failed", + "Could not start the update check. Please try again in a moment.", + ) + + def _show_release_notes_dialog(self, release: ReleaseInfo) -> None: + """Show full release notes for *release* in a separate dialog.""" + dialog = QDialog(self) + dialog.setWindowTitle(f"Release notes — {release.version}") + dialog.setMinimumSize(520, 400) + + layout = QVBoxLayout(dialog) + browser = QTextBrowser() + browser.setOpenExternalLinks(True) + notes = release.body.strip() if release.body else "" + if notes: + notes_html = html.escape(notes).replace("\n", "{notes_html}
") + else: + browser.setHtml( + "No release notes were published for this version.
" + ) + if release.html_url: + browser.append( + f'" + ) + browser.anchorClicked.connect(QDesktopServices.openUrl) + layout.addWidget(browser) + + close_btn = QPushButton("Close") + close_btn.clicked.connect(dialog.accept) + row = QHBoxLayout() + row.addStretch() + row.addWidget(close_btn) + layout.addLayout(row) + dialog.exec() def _on_update_available(self, release: ReleaseInfo) -> None: """Show a non-blocking dialog when a newer release is published.""" @@ -543,40 +610,51 @@ def _on_update_available(self, release: ReleaseInfo) -> None: f"{html.escape(release.version)} " f"(you have {html.escape(APP_VERSION)})." ) - info_parts: list[str] = [] if release.is_prerelease: mail_href = html.escape(f"mailto:{UPDATE_FEEDBACK_EMAIL}", quote=True) issues_href = html.escape(APP_ISSUES_URL, quote=True) email_lbl = html.escape(UPDATE_FEEDBACK_EMAIL) - info_parts.append( + box.setInformativeText( "Note: This is not a stable release; bugs may occur. " - "If you try this version, please report issues to " + "If you encounter issues, please report them to " f'{email_lbl} or on ' f'GitHub.
' ) - notes = release.body.strip() if release.body else "" - if notes: - excerpt = notes if len(notes) <= 600 else notes[:600] + "…" - excerpt_html = html.escape(excerpt).replace("\n", "Release notes:
{excerpt_html}
") - if info_parts: - box.setInformativeText("".join(info_parts)) - - opt_out_cb = QCheckBox("Don't notify me automatically about new updates") - opt_out_cb.setChecked(False) - box.setCheckBox(opt_out_cb) - - download_btn = box.addButton( - "Open download page", QMessageBox.ButtonRole.AcceptRole + + update_btn = None + notes_btn = box.addButton( + "View release notes", QMessageBox.ButtonRole.ActionRole ) + if automatic_update_supported(): + update_btn = box.addButton("Update now", QMessageBox.ButtonRole.AcceptRole) box.addButton("Remind me later", QMessageBox.ButtonRole.RejectRole) box.exec() - if opt_out_cb.isChecked(): - self._update_checker.set_auto_update_checks_enabled(False) - - if box.clickedButton() is download_btn and release.html_url: - QDesktopServices.openUrl(QUrl(release.html_url)) + clicked = box.clickedButton() + if clicked is update_btn: + ok, detail = launch_automatic_update(release.tag_name) + if ok: + extra = "" + if not automatic_update_targets_packaged_install(): + extra = ( + "Running from source: the installer places the " + "released build in the standard install location " + "(not this development checkout).
" + ) + started = QMessageBox(self) + started.setIcon(QMessageBox.Icon.Information) + started.setWindowTitle("Update started") + started.setTextFormat(Qt.TextFormat.RichText) + started.setText(f"{html.escape(detail)}
{extra}") + started.exec() + else: + QMessageBox.warning( + self, + "Update failed", + f"Could not start the automatic updater:\n\n{detail}", + ) + elif clicked is notes_btn: + self._show_release_notes_dialog(release) def _toggle_theme(self) -> None: """Toggle between dark and light themes.""" diff --git a/tests/conftest.py b/tests/conftest.py index 6941a11..76da7a8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,6 +17,8 @@ else: os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +from unittest.mock import MagicMock, patch + import pytest from dbs_annotator.views.wizard_window import WizardWindow @@ -25,7 +27,13 @@ @pytest.fixture def wizard(qtbot, qapp): """Main wizard window bound to the session QApplication.""" - w = WizardWindow(qapp) + # Do not schedule deferred GitHub update checks (network + cross-test leaks). + with ( + patch("dbs_annotator.views.wizard_window.UpdateChecker") as checker_cls, + patch("dbs_annotator.views.wizard_window.QTimer.singleShot"), + ): + checker_cls.return_value = MagicMock() + w = WizardWindow(qapp) qtbot.addWidget(w) w.show() return w diff --git a/tests/unit/test_auto_update.py b/tests/unit/test_auto_update.py new file mode 100644 index 0000000..3db3300 --- /dev/null +++ b/tests/unit/test_auto_update.py @@ -0,0 +1,75 @@ +"""Tests for :mod:`dbs_annotator.utils.auto_update`.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +from dbs_annotator.utils import auto_update as au + + +def test_install_script_url() -> None: + url = au._install_script_url("install.ps1") + assert "Brain-Modulation-Lab/DBSAnnotator" in url + assert url.endswith("/scripts/install.ps1") + + +def test_launch_windows_invokes_powershell(tmp_path: Path) -> None: + script = tmp_path / "install.ps1" + script.write_text("# stub", encoding="utf-8") + + with ( + patch.object(au, "_download_install_script", return_value=script), + patch.object(au.subprocess, "Popen") as mock_popen, + ): + ok, msg = au._launch_windows("v0.4.0b2") + + assert ok is True + assert "new window" in msg.lower() + mock_popen.assert_called_once() + cmd = mock_popen.call_args[0][0] + assert cmd[0] == "powershell.exe" + assert "-VersionTag" in cmd + assert "v0.4.0b2" in cmd + assert "-WhatIf" not in cmd + + +def test_launch_windows_dry_run_adds_whatif(tmp_path: Path) -> None: + script = tmp_path / "install.ps1" + script.write_text("# stub", encoding="utf-8") + + with ( + patch.object(au, "_download_install_script", return_value=script), + patch.object(au.subprocess, "Popen") as mock_popen, + ): + ok, msg = au._launch_windows("v0.4.0b2", dry_run=True) + + assert ok is True + assert "dry run" in msg.lower() + cmd = mock_popen.call_args[0][0] + assert "-WhatIf" in cmd + + +def test_launch_automatic_update_empty_tag() -> None: + ok, msg = au.launch_automatic_update("") + assert ok is False + assert "tag" in msg.lower() + + +@pytest.mark.skipif(sys.platform != "win32", reason="windows-only path") +def test_launch_automatic_update_windows() -> None: + with ( + patch.object(au, "_launch_windows", return_value=(True, "ok")) as mock_win, + patch.object(au, "_launch_unix"), + ): + ok, msg = au.launch_automatic_update("v1.0.0") + assert ok is True + mock_win.assert_called_once_with("v1.0.0", dry_run=False) + + +def test_automatic_update_supported_on_windows() -> None: + with patch.object(sys, "platform", "win32"): + assert au.automatic_update_supported() is True diff --git a/tests/unit/test_updater.py b/tests/unit/test_updater.py index 6a69a3d..4410df2 100644 --- a/tests/unit/test_updater.py +++ b/tests/unit/test_updater.py @@ -10,8 +10,14 @@ import pytest +from dbs_annotator.config import RELEASES_GITHUB_REPO from dbs_annotator.utils import updater as updater_mod -from dbs_annotator.utils.updater import UpdateChecker, _CheckSignals, _CheckWorker +from dbs_annotator.utils.updater import ( + DEFAULT_RELEASES_REPO, + UpdateChecker, + _CheckSignals, + _CheckWorker, +) class _FakeResp: @@ -53,6 +59,10 @@ def test_fetch_releases_404_raises() -> None: worker._fetch_newest_applicable_release() +def test_default_releases_repo_matches_config() -> None: + assert DEFAULT_RELEASES_REPO == RELEASES_GITHUB_REPO + + def test_urlopen_uses_certifi_ssl_context() -> None: signals = _CheckSignals() worker = _CheckWorker("o/r", "1.0.0", 10.0, signals) @@ -69,6 +79,49 @@ def test_urlopen_uses_certifi_ssl_context() -> None: assert mock_open.call_args.kwargs.get("context") is mock_ctx.return_value +def test_check_async_uses_fresh_installed_version(monkeypatch) -> None: + checker = UpdateChecker(current_version=None) + checker._settings = MagicMock() + checker._settings.value.return_value = True + seen: list[str] = [] + + def fake_get_version() -> str: + v = "1.0.0" if len(seen) == 0 else "2.0.0" + seen.append(v) + return v + + monkeypatch.setattr("dbs_annotator.utils.updater.get_version", fake_get_version) + + pool = MagicMock() + with patch( + "dbs_annotator.utils.updater.QThreadPool.globalInstance", + return_value=pool, + ): + checker.check_async(force=True) + worker = pool.start.call_args[0][0] + assert worker._current_version == "1.0.0" + + checker._finish_check() + checker.check_async(force=True) + worker2 = pool.start.call_args[0][0] + assert worker2._current_version == "2.0.0" + + +def test_check_async_busy_blocks_second_start() -> None: + checker = UpdateChecker(current_version="1.0.0") + checker._settings = MagicMock() + checker._settings.value.return_value = True + checker._check_in_progress = True + + pool = MagicMock() + with patch( + "dbs_annotator.utils.updater.QThreadPool.globalInstance", + return_value=pool, + ): + assert checker.check_async(force=True) is False + pool.start.assert_not_called() + + @pytest.mark.parametrize("code", [403, 500, 502]) def test_fetch_releases_other_http_raises(code: int) -> None: signals = _CheckSignals()