From 8dbcdecf908071ead652ae8e7245257d5ee9155d Mon Sep 17 00:00:00 2001 From: DeeJanuz <45082401+DeeJanuz@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:29:04 -0600 Subject: [PATCH] Hands: fix Export doing nothing, and show it's busy at once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit af2ea7c put _stay_awake between exportSession and its @Slot, so the window's Export button called a method QML couldn't see. A new test checks every backend call in main.qml against Backend's slots and properties. Export and Upload now say Exporting…/Uploading… with a spinner the moment they're pressed. Co-Authored-By: Claude Opus 5.5 --- hands/rec/ft_handrec.py | 2 +- hands/rec/main.qml | 20 ++++++++++++-- hands/rec/tests/test_qml_backend.py | 43 +++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 hands/rec/tests/test_qml_backend.py diff --git a/hands/rec/ft_handrec.py b/hands/rec/ft_handrec.py index 1a1eb43..c3b46fc 100755 --- a/hands/rec/ft_handrec.py +++ b/hands/rec/ft_handrec.py @@ -787,7 +787,6 @@ class Backend(QObject): def exportSessionId(self): return self._export_session - @Slot(str, bool) def _stay_awake(self, why, on): """Hold off sleep while an export or upload runs, so the headset can be taken off and left plugged in: a host unit running systemd-inhibit, started with the first reason @@ -812,6 +811,7 @@ class Backend(QObject): print(f"keeping the Frame awake: {e}", file=sys.stderr, flush=True) self._thread(run) + @Slot(str, bool) def exportSession(self, session, keep_notes): if self.exporting or self._active_guard(session): return diff --git a/hands/rec/main.qml b/hands/rec/main.qml index cc75115..2314f0e 100644 --- a/hands/rec/main.qml +++ b/hands/rec/main.qml @@ -1396,12 +1396,20 @@ Kirigami.ApplicationWindow { } RowLayout { Controls.Button { - text: exportView.chosen && exportView.chosen.exported ? "Export again" : "Export" + text: backend.exporting ? "Exporting…" + : exportView.chosen && exportView.chosen.exported ? "Export again" : "Export" icon.name: "document-export" enabled: exportView.chosen !== null && !backend.exporting && backend.zstdFound && !exportView.chosen.active onClicked: backend.exportSession(exportView.chosen.id, false) } + // Shows the moment Export is pressed: the first progress can take a few seconds. + Controls.BusyIndicator { + visible: backend.exporting + running: visible + implicitWidth: Kirigami.Units.gridUnit * 1.5 + implicitHeight: implicitWidth + } Controls.Button { visible: backend.exporting text: "Cancel" @@ -1426,7 +1434,7 @@ Kirigami.ApplicationWindow { } Controls.Label { Layout.maximumWidth: Kirigami.Units.gridUnit * 26 - visible: backend.exportText !== "" && backend.exportSessionId === sessionBox.currentValue + visible: backend.exportText !== "" && (backend.exporting || backend.exportSessionId === sessionBox.currentValue) wrapMode: Text.WrapAnywhere text: backend.exportText } @@ -1643,11 +1651,17 @@ Kirigami.ApplicationWindow { RowLayout { visible: uploadView.session !== "" Controls.Button { - text: backend.hubDryRun ? "Upload (dry run)" : "Upload" + text: backend.uploading ? "Uploading…" : backend.hubDryRun ? "Upload (dry run)" : "Upload" icon.name: "cloud-upload" enabled: uploadView.blocked === "" && !backend.uploading onClicked: uploadView.startUpload() } + Controls.BusyIndicator { + visible: backend.uploading + running: visible + implicitWidth: Kirigami.Units.gridUnit * 1.5 + implicitHeight: implicitWidth + } Controls.Button { visible: backend.uploading text: "Cancel" diff --git a/hands/rec/tests/test_qml_backend.py b/hands/rec/tests/test_qml_backend.py new file mode 100644 index 0000000..5451f74 --- /dev/null +++ b/hands/rec/tests/test_qml_backend.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""main.qml against ft_handrec.Backend: every backend.name(...) the window calls is a slot, and +every backend.name it reads is a property or a slot. A method that lost its @Slot shows up in +QML only as "is not a function" when its button is pressed (2026-10-03: Export did nothing). +Needs PySide6 (the dev container); skipped without it. + + python3 hands/rec/tests/test_qml_backend.py +""" +import os +import re +import sys +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +REC = os.path.dirname(HERE) +sys.path.insert(0, REC) + + +class QmlBackendTest(unittest.TestCase): + def setUp(self): + try: + import ft_handrec + except ImportError as e: + self.skipTest(f"no PySide6: {e}") + meta = ft_handrec.Backend.staticMetaObject + self.slots = {bytes(meta.method(i).name()).decode() for i in range(meta.methodCount())} + self.props = {meta.property(i).name() for i in range(meta.propertyCount())} + with open(os.path.join(REC, "main.qml")) as f: + self.qml = f.read() + + def test_calls_are_slots(self): + called = set(re.findall(r"\bbackend\.(\w+)\s*\(", self.qml)) + self.assertTrue(called) + self.assertEqual(sorted(called - self.slots), [], "called from main.qml but not a slot") + + def test_reads_exist(self): + read = set(re.findall(r"\bbackend\.(\w+)\b(?!\s*\()", self.qml)) + self.assertTrue(read) + self.assertEqual(sorted(read - self.props - self.slots), [], "read in main.qml but not on Backend") + + +if __name__ == "__main__": + unittest.main()