diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-20 12:13:00 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-20 12:13:00 +0200 |
| commit | d4cae7a87fd30341b5a936aa848e1fdc019a379e (patch) | |
| tree | 4a8a21966595468046f1b39b345a49a03c76f25e /tests/test_gui.py | |
| parent | c8306d6c87a40bb800241cccd90831e3893550b5 (diff) | |
| download | sb-builder-d4cae7a87fd30341b5a936aa848e1fdc019a379e.tar.gz sb-builder-d4cae7a87fd30341b5a936aa848e1fdc019a379e.zip | |
One frame for every tab: ribbons of actions, next steps as dialogs
Tabs are permanent and load on first show. The ribbon shows the current
tab's actions in workflow order, a small ribbon under it the view
controls; inapplicable actions are disabled with the reason as tooltip.
Starting a job stays on the tab; the Jobs tab reads (running), then shows
the end status. When a preview this session started ends, its log opens
with Run it (publish box) or Apply shadowing; Not now loses nothing, Run
it in Jobs reopens it. New hint moves to the Packages ribbon and acts on
the ticked packages without a hint.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'tests/test_gui.py')
| -rw-r--r-- | tests/test_gui.py | 219 |
1 files changed, 151 insertions, 68 deletions
diff --git a/tests/test_gui.py b/tests/test_gui.py index 1f3f7b0..ee568c1 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -29,7 +29,7 @@ import unittest # noqa: E402 ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") sys.path.insert(0, ROOT) -from PyQt6.QtCore import QCoreApplication, QEvent, QObject, QPointF, QProcess, Qt, pyqtSignal # noqa: E402 +from PyQt6.QtCore import QObject, QPointF, QProcess, pyqtSignal # noqa: E402 from PyQt6.QtGui import QColor # noqa: E402 from PyQt6.QtWidgets import QApplication # noqa: E402 @@ -309,7 +309,7 @@ class SmokeTest(unittest.TestCase): return w, remote def test_tabs_and_dashboard(self): - w, _ = self.window() + w, remote = self.window(**{"job-start": ({"id": "20260101-000001-sbpull"}, None)}) w.poll_jobs() self.assertEqual(w.conn.text(), "● connected") self.assertEqual(w.tabs.tabText(0), "Dashboard") @@ -328,8 +328,10 @@ class SmokeTest(unittest.TestCase): self.assertEqual(w.dashboard.legend.text(), "n/a") jobs = w.open_jobs() self.assertIs(w.open_jobs(), jobs) - self.assertEqual(w.tabs.count(), 2) - self.assertEqual(jobs.start_menu.actions()[0].text(), "sbpull personal (preview)") + self.assertEqual(w.tabs.count(), 5) + self.assertEqual([a.text() for a in jobs.pull_menu.actions()], ["personal (preview)"]) + jobs.pull_menu.actions()[0].trigger() + self.assertIn(("job-start", "sbpull", "personal", "--no"), remote.calls) def test_end_notification_once(self): w, remote = self.window(jobs=({"jobs": [JOB]}, None)) @@ -363,30 +365,6 @@ class SmokeTest(unittest.TestCase): [("ok", 3), ("failed", 1), ("removed", 1)]) self.assertEqual(w.dashboard.tiles["Built"].detail.text(), "of 10 · 6 never built") - def test_close_jobs_tab_while_following(self): - # regression: the follow process ended after the tab was deleted, its - # finished slot touched dead widgets and PyQt aborted the app - w, remote = self.window(jobs=({"jobs": [JOB]}, None)) - procs = QObject() - self.addCleanup(procs.deleteLater) - - def follow(jid, offset): - p = QProcess(procs) - p.start("sleep", ["5"]) - return p - - remote.follow = follow - w.poll_jobs() - jobs = w.open_jobs() - jobs.select(JOB["id"]) - self.assertIsNotNone(jobs.proc) - w._close_tab(w.tabs.indexOf(jobs)) - QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete.value) - end = time.monotonic() + 1 - while time.monotonic() < end: # let the killed process report in - app.processEvents() - self.assertIsNone(w.jobs_tab) - def test_packages_tab(self): w, remote = self.window(**{"job-start": ({"id": "20260101-000001-slackrepo"}, None)}) w.poll_jobs() @@ -422,34 +400,107 @@ class SmokeTest(unittest.TestCase): ["slackrepo", "build", "--preview", "curl", "nuclei"], ["slackrepo", "update", "--preview"]]) - def test_run_preview_with_signing(self): - w, remote = self.window(jobs=({"jobs": [PREVIEW]}, None), - **{"gpg-status": ({"unlocked": False, "key": "K"}, None), - "gpg-unlock": lambda args, stdin: ( - (None, "bad passphrase") if stdin == b"wrong\n" else ({}, None)), - "job-start": ({"id": "20260101-000001-slackrepo"}, None)}) + def end_preview(self, job, **answers): + """A window where job, started by this session, goes from running to its status.""" + w, remote = self.window(jobs=({"jobs": [dict(job, status="running")]}, None), **answers) + w.mine.add(job["id"]) w.poll_jobs() - jobs = w.open_jobs() - jobs.select(PREVIEW["id"]) - self.assertTrue(jobs.run_btn.isVisibleTo(jobs)) + remote.answers["jobs"] = ({"jobs": [job]}, None) + w.poll_jobs() + return w, remote + + def test_preview_next_step_with_signing(self): + w, remote = self.end_preview( + PREVIEW, **{"gpg-status": ({"unlocked": False, "key": "K"}, None), + "gpg-unlock": lambda args, stdin: ( + (None, "bad passphrase") if stdin == b"wrong\n" else ({}, None)), + "job-start": ({"id": "20260101-000001-slackrepo"}, None)}) + self.assertIs(w.tabs.currentWidget(), w.dashboard) # nobody was sent to Jobs + d = w.next_steps[PREVIEW["id"]] + self.assertEqual(d.windowTitle(), "Preview: slackrepo update nuclei") + self.assertTrue(d.publish.isChecked()) answers = iter(["wrong", "right"]) - jobs.ask_passphrase = lambda retry: next(answers) - jobs.publish_box.setChecked(False) - jobs.run_preview() + w.jobs_tab.ask_passphrase = lambda retry: next(answers) + d.publish.setChecked(False) + d.run_btn.click() self.assertIn(("gpg-unlock", b"wrong\n"), remote.calls) self.assertIn(("gpg-unlock", b"right\n"), remote.calls) - self.assertEqual(remote.calls[-2], ("job-start", "slackrepo", "update", "--no-publish", "nuclei")) + self.assertIn(("job-start", "slackrepo", "update", "--no-publish", "nuclei"), remote.calls) + self.assertEqual(w.next_steps, {}) - def test_signing_cancel_starts_nothing(self): - w, remote = self.window(jobs=({"jobs": [PREVIEW]}, None), - **{"gpg-status": ({"unlocked": False, "key": "K"}, None)}) - w.poll_jobs() + def test_preview_not_now_then_run_it(self): + w, remote = self.end_preview(PREVIEW, **{"job-start": ({"id": "20260101-000001-slackrepo"}, None)}) + w.next_steps[PREVIEW["id"]].reject() + self.assertEqual(w.next_steps, {}) jobs = w.open_jobs() jobs.select(PREVIEW["id"]) - jobs.ask_passphrase = lambda retry: None - jobs.run_preview() + self.assertTrue(jobs.act_run.isEnabled()) + jobs.act_run.trigger() # Not now loses nothing + w.next_steps[PREVIEW["id"]].run_btn.click() + self.assertIn(("job-start", "slackrepo", "update", "nuclei"), remote.calls) + + def test_signing_cancel_starts_nothing(self): + w, remote = self.end_preview(PREVIEW, **{"gpg-status": ({"unlocked": False, "key": "K"}, None)}) + w.jobs_tab.ask_passphrase = lambda retry: None + w.next_steps[PREVIEW["id"]].run_btn.click() self.assertFalse(any(c[0] == "job-start" for c in remote.calls)) + def test_preview_from_elsewhere_has_no_dialog(self): + w, remote = self.window(jobs=({"jobs": [dict(PREVIEW, status="running")]}, None)) + w.poll_jobs() + remote.answers["jobs"] = ({"jobs": [PREVIEW]}, None) + w.poll_jobs() + self.assertEqual(w.next_steps, {}) + self.assertEqual(w.notifier.sent[0][0], "sb-builder: finished") + + def test_shadow_preview_dialog_only_with_a_plan(self): + shadow = dict(PREVIEW, id="20260101-000000-sbshadow", kind="sbshadow", args=["--no"]) + for log, shown in (("shadow: misc/foo\n", True), + ("sbshadow: nothing to shadow or unshadow.\n", False)): + with self.subTest(log=log): + procs = QObject() + self.addCleanup(procs.deleteLater) + + def follow(jid, offset, log=log): + p = QProcess(procs) + p.start("printf", ["%s", log]) + return p + w, remote = self.window(jobs=({"jobs": [dict(shadow, status="running")]}, None), + **{"job-start": ({"id": "20260101-000001-sbshadow"}, None)}) + remote.follow = follow + w.mine.add(shadow["id"]) + w.poll_jobs() + remote.answers["jobs"] = ({"jobs": [shadow]}, None) + w.poll_jobs() + d = w.next_steps.get(shadow["id"]) + end = time.monotonic() + 3 + while d and not d.isVisible() and shadow["id"] in w.next_steps and time.monotonic() < end: + app.processEvents() + self.assertEqual(shadow["id"] in w.next_steps and d.isVisible(), shown) + if shown: + self.assertEqual(d.run_btn.text(), "Apply shadowing") + self.assertFalse(d.publish.isVisibleTo(d)) + d.confirm = lambda q: True + d.run_btn.click() + self.assertIn(("job-start", "sbshadow", "--yes"), remote.calls) + + def test_jobs_tab_badge(self): + w, remote = self.window(jobs=({"jobs": [JOB]}, None)) + w.poll_jobs() + jobs_i = w.tabs.indexOf(w.jobs_tab) + self.assertEqual(w.badge, "running") + self.assertFalse(w.tabs.tabIcon(jobs_i).isNull()) + self.assertEqual(w.tabs.tabText(jobs_i), "Jobs (running)") + remote.answers["jobs"] = ({"jobs": [dict(JOB, status="failed", end=5, exit=1)]}, None) + w.poll_jobs() + self.assertEqual(w.badge, "bad") # stays until Jobs is shown + self.assertEqual(w.tabs.tabText(jobs_i), "Jobs") + w.poll_jobs() + self.assertEqual(w.badge, "bad") + w.open_jobs() + self.assertIsNone(w.badge) + self.assertTrue(w.tabs.tabIcon(jobs_i).isNull()) + def test_key_indicator_and_tile_links(self): w, _ = self.window(**{"gpg-status": ({"unlocked": False, "key": "K"}, None)}) w.poll_jobs() @@ -459,16 +510,46 @@ class SmokeTest(unittest.TestCase): w.dashboard.jobs.clicked.emit() self.assertIs(w.tabs.currentWidget(), w.jobs_tab) - def test_ribbon_opens_tabs(self): - w, _ = self.window() + def test_ribbons_per_tab(self): + w, remote = self.window() w.poll_jobs() - self.assertEqual([a.text() for a in w.ribbon.actions()], + self.assertEqual([w.tabs.tabText(i) for i in range(w.tabs.count())], ["Dashboard", "Packages", "Hints", "Results", "Jobs"]) + self.assertFalse(w.tabs.tabsClosable()) + self.assertNotIn(("inventory",), remote.calls) # each tab loads on first show + expect = { + "Dashboard": (["Refresh"], []), + "Packages": (["Build", "Update", "Rebuild", "Remove", "Update all", "New hint", "Publish"], + ["Select visible", "Clear", "Refresh"]), + "Hints": (["Check upstream", "Update selected", "Delete hint"], + ["Select visible", "Clear", "No download", "Refresh"]), + "Results": (["Show build log"], ["Refresh"]), + "Jobs": (["Pull overlay", "Check shadowing", "Run it", "Kill"], []), + } + for i in range(w.tabs.count()): + w.tabs.setCurrentIndex(i) + main, view = expect[w.tabs.tabText(i)] + self.assertEqual([a.text() for a in w.ribbon.actions()], main) + self.assertEqual([a.text() for a in w.view_bar.actions()], view) + self.assertEqual(w.view_bar.isVisibleTo(w), bool(view)) + self.assertEqual(remote.calls.count(("inventory",)), 2) # Packages and Hints, once each + w.open_packages() + self.assertEqual(remote.calls.count(("inventory",)), 2) self.assertEqual([m.text() for m in w.menuBar().actions()], ["&File"]) - w.ribbon.actions()[1].trigger() - self.assertIs(w.tabs.currentWidget(), w.packages_tab) - w.ribbon.actions()[0].trigger() - self.assertIs(w.tabs.currentWidget(), w.dashboard) + + def test_disabled_actions_say_why(self): + w, _ = self.window() + w.poll_jobs() + p, h, j = w.open_packages(), w.open_hints(), w.open_jobs() + for a, why in ((p.act_build["build"], "tick packages first"), + (p.act_new_hint, "none of the ticked packages is without a hint"), + (h.act_update, "tick outdated packages after Check upstream"), + (h.act_delete, "none of the ticked packages has a hint"), + (j.act_run, "select a finished preview"), (j.act_kill, "select a running job")): + with self.subTest(action=a.text()): + self.assertFalse(a.isEnabled()) + self.assertTrue(a.toolTip().endswith(why), a.toolTip()) + self.assertEqual(h.view_actions[0].toolTip(), "Select visible (Ctrl+A)") def test_hints_tab(self): w, remote = self.window(**{"job-start": ({"id": "20260101-000001-hint-update"}, None), @@ -534,6 +615,7 @@ class SmokeTest(unittest.TestCase): None), "hint-revert": ({"ok": True}, None), "job-start": ({"id": "20260101-000001-slackrepo"}, None)}) + w.mine.add(job["id"]) w.poll_jobs() remote.answers["jobs"] = ({"jobs": [dict(job, status="failed", end=5, exit=1)]}, None) w.poll_jobs() @@ -561,17 +643,17 @@ class SmokeTest(unittest.TestCase): w.poll_jobs() tab = w.open_packages() tab.table.setCurrentIndex(tab.proxy.index(1, 1)) # foo, has a hint - self.assertFalse(tab.new_hint_btn.isVisibleTo(tab)) - tab.open_hint_btn.click() + self.assertTrue(tab.open_hint_btn.isVisibleTo(tab)) + tab.open_hint_btn.linkActivated.emit("#") self.assertIs(w.tabs.currentWidget(), w.hints_tab) - tab.table.setCurrentIndex(tab.proxy.index(3, 1)) # curl, no hint - self.assertTrue(tab.new_hint_btn.isVisibleTo(tab)) - tab.ask_new_hint = lambda name: None - tab.new_hint() - self.assertFalse(any(c[0] == "job-start" for c in remote.calls)) - tab.ask_new_hint = lambda name: ("8.0", True) - tab.new_hint() - self.assertIn(("job-start", "hint-new", "curl", "8.0", "--no-dl"), remote.calls) + tab.model.checked.update({"foo"}) + tab.update_count() + self.assertFalse(tab.act_new_hint.isEnabled()) # foo has one + tab.model.checked.update({"curl", "nuclei"}) + tab.update_count() + tab.act_new_hint.trigger() + self.assertIn(("job-start", "hint-new", "curl", "nuclei"), remote.calls) + self.assertIs(w.tabs.currentWidget(), w.hints_tab) # starting a job stays put def test_tab_shortcuts_wrap(self): w, _ = self.window() @@ -583,7 +665,7 @@ class SmokeTest(unittest.TestCase): nxt.activated.emit() self.assertEqual(w.tabs.currentIndex(), 0) # wrapped from Jobs to Dashboard prev.activated.emit() - self.assertEqual(w.tabs.currentIndex(), 2) + self.assertEqual(w.tabs.currentIndex(), 4) def test_results_tab(self): w, remote = self.window() @@ -597,10 +679,11 @@ class SmokeTest(unittest.TestCase): tab.chips["failed"].setChecked(False) self.assertEqual(tab.visible_paths(), ["games/pcsx2", "development/whisper.cpp", "misc/nuclei"]) tab.table.selectRow(1) # whisper.cpp: no log dir - self.assertFalse(tab.log_btn.isEnabled()) + self.assertFalse(tab.act_log.isEnabled()) + self.assertTrue(tab.act_log.toolTip().endswith("select a result with a log")) tab.table.selectRow(0) - self.assertTrue(tab.log_btn.isEnabled()) - tab.show_log() + self.assertTrue(tab.act_log.isEnabled()) + tab.act_log.trigger() self.assertIn(("build-log", "games/pcsx2"), remote.calls) viewer = tab.viewers[-1] self.assertIn("ERROR: make failed", viewer.text.toPlainText()) # ANSI stripped |
