From cc7e7f56d50b71393da7c36065a45ba9bef594c7 Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Sat, 19 Sep 2026 15:40:02 +0800 Subject: [PATCH] Fix platform-dependent regression test fixtures Signed-off-by: Loren Eteval --- tests/README.md | 16 +++++++++ tests/fixtures/offscreen.json | 12 +++++++ tests/support.py | 18 +++++++++- tests/test_architecture_refactors.py | 7 ++-- tests/test_external_core.py | 34 ++++++++++++++----- tests/test_hysteria2_compatibility.py | 8 +++-- tests/test_main_window_geometry.py | 31 ++++++++++++++---- tests/test_qt_interactions.py | 47 ++++++++++++++++++--------- tests/test_ui_behavior.py | 6 ++-- 9 files changed, 140 insertions(+), 39 deletions(-) create mode 100644 tests/fixtures/offscreen.json diff --git a/tests/README.md b/tests/README.md index ca987ed..cb09662 100644 --- a/tests/README.md +++ b/tests/README.md @@ -252,6 +252,22 @@ cover an aliased root and rejection of paths outside that root. The QR responsiv queues unrelated work after generation starts and requires delivery before completion; it does not assume a platform-specific order between zero-delay Qt timers. +On the CI Qt 6.8.3 runtime, the shared offscreen application uses +`fixtures/offscreen.json` to provide a 1920×1080 virtual desktop. Geometry fixtures +account for translated controls' Qt minimum sizes instead of assuming one host's +font metrics. Menu tests explicitly reactivate the owning test window after popup +dismissal: macOS offscreen does not supply the desktop's reactivation event. They +still assert the retained child focus and focus-scoped shortcuts. Selection-color +checks sample an empty cell in logical coordinates, avoiding text glyphs and +high-DPI image-coordinate assumptions. Log catch-up checks wait for the separate +queued scroll update as well as document rendering. + +The singleton race uses a short unique socket name that fits under macOS's long +temporary paths and retains child stderr in failure diagnostics. The Windows +executable-path test copies a Python interpreter and its DLLs into a disposable +path with spaces, so it also works when Python and the checkout are on different +volumes; it waits for child output before asserting shutdown. + The Home/Log workflow regressions exercise debounced typing, explicit submit/clear, navigation catch-up, focus-scoped Find/edit shortcuts, and live log filtering. These are real Qt tests in `test_qt_interactions.py` and `test_ui_behavior.py`; no production log source or profile network is started. The two optional pause regressions diff --git a/tests/fixtures/offscreen.json b/tests/fixtures/offscreen.json new file mode 100644 index 0000000..c3350b2 --- /dev/null +++ b/tests/fixtures/offscreen.json @@ -0,0 +1,12 @@ +{ + "screens": [ + { + "name": "Furious test screen", + "width": 1920, + "height": 1080, + "logicalDpi": 96, + "logicalBaseDpi": 96, + "dpr": 1 + } + ] +} diff --git a/tests/support.py b/tests/support.py index 35cc2a6..7d52548 100644 --- a/tests/support.py +++ b/tests/support.py @@ -63,7 +63,23 @@ class TestApplication(QApplication): def __init__(self): """Initialize one side-effect-free application for this test process.""" - super().__init__([]) + arguments = [] + + if os.environ.get('QT_QPA_PLATFORM') == 'offscreen': + # The plugin's default 800x800 desktop cannot contain the translated + # MainWindow minimum on all hosts. Give geometry tests a real-sized + # virtual desktop without altering any physical display. + configuration = Path(__file__).with_name('fixtures') / 'offscreen.json' + # Platform arguments are colon-separated, including on Windows. + configuration = os.path.relpath(configuration) + + arguments = [ + 'furious-tests', + '-platform', + f'offscreen:configfile={configuration}', + ] + + super().__init__(arguments) self.setApplicationName('Furious Tests') self.setOrganizationName('Furious Tests') diff --git a/tests/test_architecture_refactors.py b/tests/test_architecture_refactors.py index d003004..1a20287 100644 --- a/tests/test_architecture_refactors.py +++ b/tests/test_architecture_refactors.py @@ -675,7 +675,9 @@ class ApplicationLifecycleTransactionTest(TestCase): def testConcurrentProcessesElectExactlyOnePrimary(self): """Exercise the real Qt local-server race with isolated processes.""" - serverName = f'furious-singleton-test-{uuid.uuid4()}' + # Qt prefixes relative socket names with the temporary directory. macOS + # paths are long enough that a descriptive prefix exceeds sun_path. + serverName = uuid.uuid4().hex script = textwrap.dedent(r""" import os import sys @@ -799,7 +801,8 @@ class ApplicationLifecycleTransactionTest(TestCase): for process in processes: stdout, stderr = process.communicate(timeout=15) - outputs.append(stdout) + outputs.append(stdout + stderr) + self.assertEqual( process.returncode, 0, diff --git a/tests/test_external_core.py b/tests/test_external_core.py index 93db789..10fb2a6 100644 --- a/tests/test_external_core.py +++ b/tests/test_external_core.py @@ -48,6 +48,7 @@ import sys import json import time import tempfile +import shutil import threading import subprocess import unittest @@ -593,27 +594,42 @@ class ExternalCoreProcessTest(unittest.TestCase): lease.release() - @unittest.skipUnless(os.name == 'nt', 'Windows executable hard-link coverage') + @unittest.skipUnless(os.name == 'nt', 'Windows executable path coverage') def testExecutablePathContainingSpaces(self): - """Launch an executable hard link whose local path contains spaces.""" + """Launch a copied interpreter whose local path contains spaces.""" with tempfile.TemporaryDirectory( prefix='furious executable path ', dir=Path.cwd() ) as directory: executable = Path(directory) / 'python executable.exe' + # The interpreter and checkout can be on different Windows volumes. + # Copy the base interpreter (not a venv redirector) and its DLLs; + # PYTHONHOME supplies its existing standard library without installing + # an environment or writing into the interpreter's directory. + shutil.copy2(sys._base_executable, executable) - os.link(sys.executable, executable) + for library in Path(sys.base_prefix).glob('python*.dll'): + shutil.copy2(library, directory) config = self.configuration( - ['-c', 'import time; time.sleep(60)'], + ['-c', 'import time; print("ready", flush=True); time.sleep(60)'], directory, + environment={'PYTHONHOME': sys.base_prefix}, ) config['executable'] = str(executable) + messages = [] + runtime = ExternalCoreProcess(config, msgCallback=messages.append) - runtime = ExternalCoreProcess(config) - - runtime.start() - - runtime.stop() + try: + runtime.start() + self.assertTrue( + self.waitFor( + lambda: any('ready' in message for message in messages) + ), + messages, + ) + self.assertTrue(runtime.isRunning()) + finally: + runtime.stop() self.assertFalse(runtime.isRunning()) diff --git a/tests/test_hysteria2_compatibility.py b/tests/test_hysteria2_compatibility.py index 56adb40..b29c9ed 100644 --- a/tests/test_hysteria2_compatibility.py +++ b/tests/test_hysteria2_compatibility.py @@ -33,7 +33,7 @@ from Furious.Backends.Hysteria2.Protocols import Hysteria2ProtocolHandler from Furious.Backends.Hysteria2.TunSettingsDialog import ( GuiHysteria2TUNSettingsGroupBoxInterface, ) -from Furious.Frozenlib import AppSettings, Mixins +from Furious.Frozenlib import AppSettings, Mixins, PLATFORM from Furious.Models.Profile import ServerProfile from Furious.Plugins.Runtime import serializeRuntimeConfiguration from Furious.Qt import AppStyleSheet @@ -226,7 +226,7 @@ class Hysteria2CompatibilityTest(unittest.TestCase): ) self.assertEqual( proxyBandwidth.bandwidthFields._widget.layout().contentsMargins().top(), - 20, + 2 if PLATFORM == 'Darwin' else 20, ) editor.show() @@ -346,7 +346,9 @@ class Hysteria2CompatibilityTest(unittest.TestCase): self.assertEqual(rowLayout.contentsMargins().left(), 0) self.assertEqual(rowLayout.contentsMargins().right(), 0) - self.assertEqual(rowLayout.spacing(), 8) + self.assertEqual( + rowLayout.spacing(), 10 if PLATFORM == 'Darwin' else 8 + ) self.assertIsNotNone( rowLayout.itemAt(rowLayout.count() - 1).spacerItem() ) diff --git a/tests/test_main_window_geometry.py b/tests/test_main_window_geometry.py index 687322e..4c414d5 100644 --- a/tests/test_main_window_geometry.py +++ b/tests/test_main_window_geometry.py @@ -243,6 +243,8 @@ class AppQMainWindowLifecycleTest(unittest.TestCase): ) with isolatedSettings(): + AppSettings.set('Language', 'EN') + for windowType, expectedSize in cases: with self.subTest(windowType=windowType.__name__): window = windowType() @@ -250,7 +252,13 @@ class AppQMainWindowLifecycleTest(unittest.TestCase): with patch('Furious.Qt.QtWidgets.moveToCenter') as moveToCenter: window.show() - self.assertEqual(window.size(), expectedSize) + self.assertEqual( + window.size(), + # Use the enforced layout constraint: a fixed-size + # window may deliberately override its size hint. + expectedSize.expandedTo(window.minimumSize()), + ) + moveToCenter.assert_called_once_with(window) window.move(61, 73) @@ -343,12 +351,18 @@ class MainWindowNavigationSessionTest(unittest.TestCase): def testNewWindowResetsSessionStateButRestoresGeometry(self): """Reset page and expansion while retaining persistent geometry.""" - expectedSize = QtCore.QSize(760, 600) - with isolatedSettings() as settings: + AppSettings.set('Language', 'EN') + with _realMainWindow() as firstWindow: firstWindow.show() + expectedSize = ( + QtCore.QSize(760, 600) + .expandedTo(firstWindow.minimumSizeHint()) + .expandedTo(firstWindow.minimumSize()) + ) firstWindow.resize(expectedSize) + self.assertEqual(firstWindow.size(), expectedSize) firstWindow.navigationView.setExpanded(True, animated=False) firstWindow.showPage('settings') firstWindow.cleanup() @@ -723,7 +737,14 @@ class MainWindowGeometryTest(unittest.TestCase): def testRoutingWindowKeepsValidSmallGeometryAndIgnoresInvalidState(self): """Preserve intentional compact geometry independently from layout state.""" with isolatedSettings(): - expected = QtCore.QRect(35, 45, 420, 260) + AppSettings.set('Language', 'EN') + window = XrayRoutingWindow() + compactSize = ( + QtCore.QSize(420, 260) + .expandedTo(window.minimumSizeHint()) + .expandedTo(window.minimumSize()) + ) + expected = QtCore.QRect(QtCore.QPoint(35, 45), compactSize) AppSettings.set( 'UserRoutingWindowGeometry', self._saveGeometry(expected), @@ -733,8 +754,6 @@ class MainWindowGeometryTest(unittest.TestCase): QtCore.QByteArray(b'broken'), ) - window = XrayRoutingWindow() - with patch('Furious.Qt.QtWidgets.moveToCenter') as moveToCenter: window.show() diff --git a/tests/test_qt_interactions.py b/tests/test_qt_interactions.py index 55a9719..a89b0f6 100644 --- a/tests/test_qt_interactions.py +++ b/tests/test_qt_interactions.py @@ -1316,6 +1316,16 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): p.metadata.profileId for p in (profiles[0], profiles[2]) } + def selectionColor(): + # Grab in logical widget coordinates; raw image pixels + # otherwise need conversion for the device pixel ratio. + return ( + table.viewport() + .grab(QtCore.QRect(sample, QtCore.QSize(1, 1))) + .toImage() + .pixelColor(0, 0) + ) + for theme in (AppStyleSheet.Light, AppStyleSheet.Dark): with self.subTest(theme=theme), mock.patch.object( application(), 'theme', return_value=theme @@ -1338,11 +1348,12 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): QTest.keyRelease(table, QtCore.Qt.Key_Control) processQtEvents() - rect = table.visualRect(table.proxyModel.index(0, 0)) - sample = QtCore.QPoint(rect.right() - 12, rect.center().y()) - before = ( - table.viewport().grab().toImage().pixelColor(sample) - ) + # The address is empty in this fixture. Sample its + # background rather than a font-dependent remark glyph. + index = table.proxyModel.index(0, 2) + self.assertEqual(index.data(), '') + sample = table.visualRect(index).center() + before = selectionColor() self.assertEqual( before, @@ -1357,7 +1368,7 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): self.assertFalse(home.testMenu.isVisible()) self.assertEqual( - table.viewport().grab().toImage().pixelColor(sample), + selectionColor(), before, ) @@ -1373,7 +1384,7 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): expectedIds, ) self.assertEqual( - table.viewport().grab().toImage().pixelColor(sample), + selectionColor(), before, ) @@ -1393,9 +1404,13 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): expectedIds, ) - self.assertTrue(home.testButton.hasFocus()) + # macOS offscreen activates popups but does not + # reactivate their owner on dismissal. Supply only + # window activation; Qt must retain the focused child. + home.activateWindow() + self.assertTrue(waitFor(home.testButton.hasFocus)) self.assertEqual( - table.viewport().grab().toImage().pixelColor(sample), + selectionColor(), before, ) @@ -1416,7 +1431,7 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): self.assertFalse(table.property('keepSelectionHighlighted')) self.assertEqual( - table.viewport().grab().toImage().pixelColor(sample), + selectionColor(), QtGui.QColor( AppStyleSheet.paletteForTheme(theme)['raised'] ), @@ -1426,14 +1441,12 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): home.testButton.setFocus(QtCore.Qt.TabFocusReason) processQtEvents() - before = table.viewport().grab().toImage().pixelColor(sample) + before = selectionColor() QTest.keyPress(home.testButton, QtCore.Qt.Key_Space) processQtEvents() - self.assertEqual( - table.viewport().grab().toImage().pixelColor(sample), before - ) + self.assertEqual(selectionColor(), before) QTest.keyRelease(home.testButton, QtCore.Qt.Key_Space) processQtEvents() @@ -1443,7 +1456,8 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): QTest.keyClick(home.testMenu, QtCore.Qt.Key_Escape) processQtEvents() - self.assertTrue(home.testButton.hasFocus()) + home.activateWindow() + self.assertTrue(waitFor(home.testButton.hasFocus)) home.searchLineEdit.setFocus() @@ -1510,8 +1524,9 @@ class SharedSettingsQtWorkflowTest(unittest.TestCase): self.assertEqual(imported, [True]) table = home.userServersQTableWidget + home.activateWindow() table.setFocus() - processQtEvents() + self.assertTrue(waitFor(table.hasFocus)) QTest.keyClick(table, QtCore.Qt.Key_V, QtCore.Qt.ControlModifier) processQtEvents() diff --git a/tests/test_ui_behavior.py b/tests/test_ui_behavior.py index df378e0..919d89e 100644 --- a/tests/test_ui_behavior.py +++ b/tests/test_ui_behavior.py @@ -1999,7 +1999,8 @@ class UnifiedLogPageTest(unittest.TestCase): scrollbar = page.textBrowser.verticalScrollBar() self.assertGreater(scrollbar.maximum(), 0) - self.assertEqual(scrollbar.value(), scrollbar.maximum()) + # Rendering completion schedules a separate zero-delay scroll update. + self.assertTrue(waitFor(lambda: scrollbar.value() == scrollbar.maximum())) self.assertTrue(page._followTail) page.hide() @@ -3130,7 +3131,8 @@ class DialogBehaviorTest(unittest.TestCase): [rule['ruleTag'] for rule in view.rules()], ['0', '1', '2', '3'] ) self.assertEqual(view.selectedIndex, [2]) - self.assertTrue(view.hasFocus()) + dialog.activateWindow() + self.assertTrue(waitFor(view.hasFocus)) def testHomeAndRoutingMoveMenusRetranslateWithoutChangingActions(self): with isolatedSettings():