# Copyright (C) 2024–present Loren Eteval & contributors # # This file is part of Furious. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Exercise high-value Qt presentation and editor integration boundaries.""" from __future__ import annotations from Furious.Backends.ExternalCore.Configuration import ( BLANK_CONFIG_EXTERNAL_CORE, ConfigExternalCore, ) from Furious.Backends.ExternalCore.Editor import ExternalCoreEditor from Furious.Backends.Xray.RoutingWindow import RoutingRulesDialog from Furious.Actions.Connection import ConnectAction from Furious.Controllers.ConnectionController import ( ConnectionController, ConnectionState, ) from Furious.Controllers.SettingsController import ( LOG_AUTO_CLEAR_SETTING, LOG_AUTO_SCROLL_DOWN_SETTING, ) from Furious.Frozenlib import AppSettings from Furious.Models import ProfileMetadata, ServerProfile from Furious.Qt import AppQMessageBox, AppQSwitch from Furious.Service import ( APPLICATION_LOG_CATEGORY, CORE_LOG_CATEGORY, TUN2SOCKS_LOG_CATEGORY, LogManager, ) from Furious.Window.LogPage import LogPage from Furious.Window.SubscriptionPage import _SubscriptionEditorDialog from Furious.Widget.ConnectionButton import ConnectionButton from PySide6 import QtCore from PySide6.QtTest import QTest from tests.support import ( application, collectAtBoundary, isolatedSettings, processQtEvents, waitFor, ) import copy import unittest import weakref from unittest import mock class EditorMappingTest(unittest.TestCase): """Verify editor fields preserve structured configuration semantics.""" @classmethod def setUpClass(cls): """Create the process-wide headless QApplication.""" application() def tearDown(self): """Finish every deferred transient deletion between tests.""" collectAtBoundary() def testExternalCoreEditorRoundTripsStructuredFields(self): """Keep arguments, environment, process paths, and TUN data distinct.""" configuration = ConfigExternalCore(copy.deepcopy(BLANK_CONFIG_EXTERNAL_CORE)) configuration.update( { 'executable': 'C:/Program Files/Fixture/core.exe', 'workingDirectory': 'C:/Program Files/Fixture', 'arguments': ['--config', 'name with spaces.json'], 'environment': {'TOKEN': 'one=two', 'UNICODE': '测试'}, 'useApplicationTun2socks': True, 'tunRemoteAddress': '2001:db8::42', } ) profile = ServerProfile.fromConfiguration( configuration, ProfileMetadata(displayName='Fixture core'), ) with isolatedSettings(): editor = ExternalCoreEditor() editor.factoryToInput(profile) self.assertEqual( editor._argumentsInput.values(), ['--config', 'name with spaces.json'], ) self.assertEqual( editor._environmentInput.values(), {'TOKEN': 'one=two', 'UNICODE': '测试'}, ) self.assertTrue(editor._applicationTun2socksInput.isChecked()) tunLabel, tunSwitch = editor._applicationTun2socksInput.widgets() self.assertTrue(tunLabel.text()) self.assertIsInstance(tunSwitch, AppQSwitch) self.assertEqual(tunSwitch.size(), AppQSwitch.CompactControlSize) self.assertTrue(editor._tunRemoteAddressInput.widgets()[1].isEnabled()) self.assertEqual(editor._tunRemoteAddressInput.text(), '2001:db8::42') self.assertEqual(len(editor.groupBoxSequence()), 1) self.assertAlmostEqual(editor.height() / editor.width(), 1.618, places=2) editor._argumentsInput._input.setText( '--mode direct --label "a value with spaces"' ) editor._environmentInput._input.setPlainText('A=1\nB=two=three') editor._tunRemoteAddressInput._input.setText('server.example.com') self.assertTrue(editor.inputToFactory(profile)) self.assertEqual( profile.connection['arguments'], ['--mode', 'direct', '--label', 'a value with spaces'], ) self.assertEqual( profile.connection['environment'], {'A': '1', 'B': 'two=three'}, ) self.assertEqual( profile.connection.tunRemoteAddress(), 'server.example.com', ) editor.close() def testSubscriptionEditorNormalizesPresentationValues(self): """Return one complete subscription record from its visual controls.""" with isolatedSettings(): dialog = _SubscriptionEditorDialog( { 'remark': ' Fixture subscription ', 'webURL': 'https://example.test/subscription', 'enabled': False, 'autoupdate': 'Every 6 hours', 'proxy': 'Direct', 'userAgent': ' Fixture/1.0 ', 'filter': ' keep.* ', } ) values = dialog.subscription() self.assertIsInstance(dialog.enabledSwitch, AppQSwitch) self.assertEqual(dialog.enabledSwitch.size(), AppQSwitch.ControlSize) self.assertEqual( dialog.enabledSwitch.parentWidget().objectName(), 'SubscriptionEditorForm', ) self.assertEqual(values['remark'], 'Fixture subscription') self.assertEqual(values['webURL'], 'https://example.test/subscription') self.assertFalse(values['enabled']) self.assertEqual(values['userAgent'], 'Fixture/1.0') self.assertEqual(values['filter'], 'keep.*') dialog.accept() self.assertEqual( dialog.result(), _SubscriptionEditorDialog.DialogCode.Accepted, ) class UnifiedLogPageTest(unittest.TestCase): """Prove bounded collection is eager while hidden-page rendering is lazy.""" @classmethod def setUpClass(cls): """Create the process-wide headless QApplication.""" application() def tearDown(self): """Finish deferred document and widget cleanup between tests.""" collectAtBoundary() def assertRendered(self, page, *, highlighting=True): """Wait for the coalesced snapshot and optional highlighting batches.""" self.assertTrue(waitFor(lambda: not page._entriesDirty)) if highlighting: self.assertTrue(waitFor(lambda: page._highlightNextBlock is None)) @staticmethod def disposePage(page): """Release one persistent page and its owned timers.""" page.close() page.deleteLater() def testHiddenPageRendersOneOrderedSnapshotWhenShown(self): """Do not mutate the document while hidden; catch up exactly once.""" with isolatedSettings(): manager = LogManager(maximumEntries=5) page = LogPage(manager=manager) manager.append('application one', APPLICATION_LOG_CATEGORY) manager.append('core one', CORE_LOG_CATEGORY) processQtEvents() self.assertEqual(page.textBrowser.toPlainText(), '') self.assertTrue(page._entriesDirty) page.show() self.assertRendered(page) self.assertEqual( page.textBrowser.toPlainText().splitlines(), ['application one', 'core one'], ) page.hide() manager.append('core two', CORE_LOG_CATEGORY) processQtEvents() self.assertNotIn('core two', page.textBrowser.toPlainText()) page.show() self.assertRendered(page) self.assertEqual( page.textBrowser.toPlainText().splitlines(), ['application one', 'core one', 'core two'], ) coreIndex = page.filterComboBox.findData(CORE_LOG_CATEGORY) page.filterComboBox.setCurrentIndex(coreIndex) self.assertRendered(page) self.assertEqual( page.textBrowser.toPlainText().splitlines(), ['core one', 'core two'], ) self.disposePage(page) def testLargeHiddenBacklogDefersOneBulkDocumentCatchUp(self): """Return from show before rendering and avoid per-entry replay.""" with isolatedSettings(): entryCount = 5_000 manager = LogManager(maximumEntries=entryCount) page = LogPage(manager=manager) page.resize(900, 420) for index in range(entryCount): manager.append(f'bulk entry {index:05d}') self.assertEqual(page.textBrowser.toPlainText(), '') page.show() # showEvent only schedules the catch-up; it does not synchronously # build and highlight thousands of QTextDocument blocks. self.assertTrue(page._entriesDirty) self.assertEqual(page.textBrowser.toPlainText(), '') self.assertTrue(page.highlightOverlay.isVisible()) self.assertTrue(page.highlightSpinner.is_spinning) self.assertRendered(page) self.assertFalse(page.highlightOverlay.isVisible()) self.assertFalse(page.highlightSpinner.is_spinning) lines = page.textBrowser.toPlainText().splitlines() self.assertEqual(len(lines), entryCount) self.assertEqual(lines[0], 'bulk entry 00000') self.assertEqual(lines[-1], 'bulk entry 04999') self.assertEqual(page.textBrowser.document().blockCount(), entryCount) self.disposePage(page) def testFollowingTailSurvivesHideAndCatchUp(self): """Restore the newest entry after collecting while the page is hidden.""" with isolatedSettings(): manager = LogManager(maximumEntries=500) page = LogPage(manager=manager) page.resize(800, 320) for index in range(200): manager.append(f'before hide {index:03d}') page.show() self.assertRendered(page) scrollbar = page.textBrowser.verticalScrollBar() self.assertGreater(scrollbar.maximum(), 0) self.assertEqual(scrollbar.value(), scrollbar.maximum()) self.assertTrue(page._followTail) page.hide() for index in range(25): manager.append(f'while hidden {index:03d}') page.show() self.assertRendered(page) self.assertTrue(waitFor(lambda: scrollbar.value() == scrollbar.maximum())) self.assertTrue(page._followTail) self.assertTrue(page.plainText().endswith('while hidden 024')) self.disposePage(page) def testLogPreferencesDefaultOnAndPersistAcrossPageRecreation(self): """Restore both switch preferences without rewriting them at startup.""" with isolatedSettings(): firstManager = LogManager(maximumEntries=20) firstPage = LogPage(manager=firstManager) self.assertTrue(firstPage.autoScrollSwitch.isChecked()) self.assertTrue(firstPage.autoClearSwitch.isChecked()) self.assertTrue(AppSettings.isStateON_(LOG_AUTO_SCROLL_DOWN_SETTING)) self.assertTrue(AppSettings.isStateON_(LOG_AUTO_CLEAR_SETTING)) firstPage.autoScrollSwitch.setChecked(False) firstPage.autoClearSwitch.setChecked(False) self.assertFalse(AppSettings.isStateON_(LOG_AUTO_SCROLL_DOWN_SETTING)) self.assertFalse(AppSettings.isStateON_(LOG_AUTO_CLEAR_SETTING)) self.assertFalse(firstManager.autoClearEnabled) self.disposePage(firstPage) processQtEvents() secondManager = LogManager(maximumEntries=20) secondPage = LogPage(manager=secondManager) self.assertFalse(secondPage.autoScrollSwitch.isChecked()) self.assertFalse(secondPage.autoClearSwitch.isChecked()) self.assertFalse(secondManager.autoClearEnabled) secondPage.autoScrollSwitch.setChecked(True) secondPage.autoClearSwitch.setChecked(True) self.assertTrue(AppSettings.isStateON_(LOG_AUTO_SCROLL_DOWN_SETTING)) self.assertTrue(AppSettings.isStateON_(LOG_AUTO_CLEAR_SETTING)) self.assertTrue(secondManager.autoClearEnabled) self.disposePage(secondPage) def testAutoScrollPreferenceMastersTailFollowing(self): """Never move a disabled viewer and resume at the newest entry on enable.""" with isolatedSettings(): manager = LogManager(maximumEntries=500) page = LogPage(manager=manager) page.resize(800, 320) for index in range(200): manager.append(f'initial {index:03d}') page.show() self.assertRendered(page) scrollbar = page.textBrowser.verticalScrollBar() page.autoScrollSwitch.setChecked(False) scrollbar.setValue(0) manager.append('must not move the viewport') self.assertRendered(page) self.assertEqual(scrollbar.value(), 0) self.assertFalse(page._followTail) page.autoScrollSwitch.setChecked(True) self.assertTrue(waitFor(lambda: scrollbar.value() == scrollbar.maximum())) self.assertTrue(page._followTail) page.hide() manager.append('arrived while hidden') page.show() self.assertRendered(page) self.assertTrue(waitFor(lambda: scrollbar.value() == scrollbar.maximum())) self.assertTrue(page.plainText().endswith('arrived while hidden')) self.disposePage(page) def testHiddenCoreClearRebuildsWithoutForcingScroll(self): """Invalidate hidden runtime state and honor disabled tail follow on show.""" with isolatedSettings(): manager = LogManager( maximumEntries=50, autoClearMaximumEntries=3, ) page = LogPage(manager=manager) page.resize(800, 260) coreIndex = page.filterComboBox.findData(CORE_LOG_CATEGORY) page.filterComboBox.setCurrentIndex(coreIndex) page.autoScrollSwitch.setChecked(False) for index in range(3): manager.append(f'old core {index}', CORE_LOG_CATEGORY) manager.append('old tun2socks', TUN2SOCKS_LOG_CATEGORY) processQtEvents() manager.append('new core after clear', CORE_LOG_CATEGORY) manager.append('application retained', APPLICATION_LOG_CATEGORY) self.assertEqual(page.plainText(), '') self.assertEqual( tuple(entry.message for entry in manager.entries(CORE_LOG_CATEGORY)), ('new core after clear',), ) self.assertEqual(manager.entries(TUN2SOCKS_LOG_CATEGORY), tuple()) page.show() self.assertRendered(page) self.assertEqual(page.plainText(), 'new core after clear') self.assertFalse(page._followTail) self.assertEqual( tuple( entry.message for entry in manager.entries(APPLICATION_LOG_CATEGORY) ), ('application retained',), ) self.disposePage(page) def testManualHistoryReadingDisablesAndThenResumesTail(self): """Do not yank an upward-scrolled viewport until it returns to bottom.""" with isolatedSettings(): manager = LogManager(maximumEntries=500) page = LogPage(manager=manager) page.resize(800, 320) for index in range(200): manager.append(f'history {index:03d}') page.show() self.assertRendered(page) scrollbar = page.textBrowser.verticalScrollBar() scrollbar.setValue(0) page._updateFollowTailFromScrollbar() self.assertFalse(page._followTail) manager.append('arrived while reading') self.assertRendered(page) self.assertEqual(scrollbar.value(), 0) self.assertLess(scrollbar.value(), scrollbar.maximum()) scrollbar.setValue(scrollbar.maximum()) page._updateFollowTailFromScrollbar() self.assertTrue(page._followTail) manager.append('tail resumed') self.assertRendered(page) self.assertTrue(waitFor(lambda: scrollbar.value() == scrollbar.maximum())) self.assertTrue(page.plainText().endswith('tail resumed')) self.disposePage(page) def testFilteredTailCatchUpAndPruningRemainExact(self): """Keep filtered tail semantics and discard pruned filtered entries.""" with isolatedSettings(): manager = LogManager(maximumEntries=40) page = LogPage(manager=manager) page.resize(800, 260) for index in range(30): manager.append(f'application {index:03d}', APPLICATION_LOG_CATEGORY) manager.append(f'core {index:03d}', CORE_LOG_CATEGORY) coreIndex = page.filterComboBox.findData(CORE_LOG_CATEGORY) page.filterComboBox.setCurrentIndex(coreIndex) page.show() self.assertRendered(page) scrollbar = page.textBrowser.verticalScrollBar() self.assertTrue(page.plainText().endswith('core 029')) self.assertEqual(scrollbar.value(), scrollbar.maximum()) page.hide() for index in range(45): manager.append( f'new application {index:03d}', APPLICATION_LOG_CATEGORY, ) manager.append('new core tail', CORE_LOG_CATEGORY) page.show() self.assertRendered(page) self.assertTrue(waitFor(lambda: scrollbar.value() == scrollbar.maximum())) self.assertEqual(page.plainText(), 'new core tail') self.assertTrue(page._followTail) self.disposePage(page) def testIncrementalBatchesRehighlightChangedBoundaryBlocks(self): """Color paragraph boundaries invalidated by append and retention edits.""" with isolatedSettings(): manager = LogManager(maximumEntries=24) page = LogPage(manager=manager) def appendEntries(start, count): """Append deterministic lines matched by several log rules.""" for index in range(start, start + count): manager.append( '2026/08/17 18:45:' f'{index % 60:02d}.000000 from ' f'127.0.0.1:{5000 + index} accepted ' '//example.com:443 [http >> proxy]' ) def missingFormats(): """Return blocks that fell back to the default text format.""" document = page.textBrowser.document() return tuple( blockNumber for blockNumber in range(document.blockCount()) if not document.findBlockByNumber(blockNumber).layout().formats() ) appendEntries(0, 20) page.show() self.assertRendered(page) appendEntries(20, 3) self.assertRendered(page) self.assertEqual(missingFormats(), ()) # This batch prunes four old entries as it appends five new ones, # exercising both the new first and previous last block boundaries. appendEntries(23, 5) self.assertRendered(page) self.assertEqual(page.textBrowser.document().blockCount(), 24) self.assertEqual(missingFormats(), ()) self.disposePage(page) def testRepeatedVisibilityCyclesReuseTimersWithoutDuplicateEntries(self): """Keep one owned render pipeline stable through repeated page visits.""" with isolatedSettings(): manager = LogManager(maximumEntries=500) page = LogPage(manager=manager) timers = ( page._updateTimer, page._highlightTimer, page._scrollTimer, page._followStateTimer, ) page.show() self.assertRendered(page) expected = [] for index in range(30): page.hide() line = f'visibility cycle {index:02d}' expected.append(line) manager.append(line) page.show() self.assertRendered(page, highlighting=False) self.assertEqual(page.plainText().splitlines(), expected) self.assertEqual( ( page._updateTimer, page._highlightTimer, page._scrollTimer, page._followStateTimer, ), timers, ) self.disposePage(page) def testPageDestructionReleasesOwnedRenderTimers(self): """Do not retain the page or its persistent timers after destruction.""" with isolatedSettings(): manager = LogManager(maximumEntries=20) page = LogPage(manager=manager) references = tuple( weakref.ref(value) for value in ( page, page._updateTimer, page._highlightTimer, page._scrollTimer, page._followStateTimer, page.autoScrollSwitch, page.autoClearSwitch, page.highlightOverlay, page.highlightSpinner, page.highlightStatusLabel, ) ) page.show() self.assertRendered(page) self.disposePage(page) del page collectAtBoundary() self.assertTrue(all(reference() is None for reference in references)) class DialogBehaviorTest(unittest.TestCase): """Exercise no-selection guards and QMessageBox-compatible results.""" @classmethod def setUpClass(cls): """Create the process-wide headless QApplication.""" application() def tearDown(self): """Finish every deferred transient deletion between tests.""" collectAtBoundary() def testRoutingRulesActionsStayEnabledAndNoSelectionIsSafe(self): """Keep the compact top actions visible without creating a warning.""" dialog = RoutingRulesDialog({'rules': []}) self.assertTrue(dialog.addButton.isEnabled()) self.assertTrue(dialog.deleteButton.isEnabled()) self.assertTrue(dialog.closeWindowButton.isEnabled()) self.assertIsNotNone(dialog.layout().itemAt(0).layout()) self.assertIs(dialog.layout().itemAt(1).widget(), dialog.listWidget) dialog.deleteRule() dialog.editRule() self.assertEqual(AppQMessageBox._openMessageBoxes, {}) self.assertEqual(dialog.routing['rules'], []) dialog.closeWindowButton.click() def testMessageBoxButtonPublishesCompatibleResult(self): """Emit one clicked button and finish with its standard-button value.""" messageBox = AppQMessageBox( text='Continue?', buttons=( AppQMessageBox.StandardButton.Yes | AppQMessageBox.StandardButton.No ), ) clicked = [] finished = [] messageBox.buttonClicked.connect(clicked.append) messageBox.finished.connect(finished.append) yesButton = messageBox.button(AppQMessageBox.StandardButton.Yes) yesButton.click() processQtEvents() self.assertEqual(clicked, [yesButton]) self.assertEqual(finished, [int(AppQMessageBox.StandardButton.Yes)]) self.assertIs(messageBox.clickedButton(), yesButton) def testMessageBoxButtonsHaveAdaptiveFluentLayoutAndRoles(self): """Keep one, two, and three actions slim, separated, and content-driven.""" configurations = ( (AppQMessageBox.StandardButton.Ok, 1), ( AppQMessageBox.StandardButton.Ok | AppQMessageBox.StandardButton.Cancel, 2, ), ( AppQMessageBox.StandardButton.Save | AppQMessageBox.StandardButton.Discard | AppQMessageBox.StandardButton.Cancel, 3, ), ) widths = [] for buttons, count in configurations: with self.subTest(buttonCount=count): messageBox = AppQMessageBox( icon=AppQMessageBox.Icon.Question, text='Ready', buttons=buttons, ) messageBox.show() processQtEvents() actionButtons = messageBox.buttons() self.assertEqual(len(actionButtons), count) self.assertTrue( all( button.width() > button.height() * 2 for button in actionButtons ) ) geometries = sorted( (button.geometry() for button in actionButtons), key=lambda geometry: geometry.x(), ) for left, right in zip(geometries, geometries[1:]): self.assertGreaterEqual( right.left() - left.right() - 1, messageBox.ButtonSpacing, ) if count > 1: self.assertLessEqual( max(button.width() for button in actionButtons) - min(button.width() for button in actionButtons), 1, ) if buttons & AppQMessageBox.StandardButton.Discard: discard = messageBox.button(AppQMessageBox.StandardButton.Discard) self.assertEqual( discard.property('messageBoxRole'), 'destructive', ) widths.append(messageBox.width()) messageBox.close() self.assertLess(widths[0], widths[2]) def testMessageBoxEscapeUsesConfiguredCancelResult(self): """Preserve Escape semantics without leaving masks or open-box owners.""" messageBox = AppQMessageBox( text='Continue?', buttons=( AppQMessageBox.StandardButton.Yes | AppQMessageBox.StandardButton.Cancel ), ) finished = [] messageBox.finished.connect(finished.append) messageBox.setEscapeButton(AppQMessageBox.StandardButton.Cancel) messageBox.show() QTest.keyClick(messageBox, QtCore.Qt.Key.Key_Escape) processQtEvents() self.assertEqual( finished, [int(AppQMessageBox.StandardButton.Cancel)], ) self.assertEqual(AppQMessageBox._openMessageBoxes, {}) class SharedConnectionPresentationTest(unittest.TestCase): """Keep Home and tray adapters synchronized to one controller state.""" @classmethod def setUpClass(cls): application() def tearDown(self): collectAtBoundary() def testHomeSelectionPolicyAndTrayPresentationShareController(self): """Apply selection only to Home while lifecycle text remains identical.""" controller = ConnectionController() activation = mock.Mock(return_value=True) with ( mock.patch( 'Furious.Widget.ConnectionButton.AppConnectionController', return_value=controller, ), mock.patch( 'Furious.Actions.Connection.AppConnectionController', return_value=controller, ), mock.patch.object(controller, 'toggle', return_value=True) as toggle, ): home = ConnectionButton(activation) tray = ConnectAction() home.setSelectionCount(0) self.assertTrue(home.isEnabled()) home.click() toggle.assert_not_called() home.setSelectionCount(2) self.assertFalse(home.isEnabled()) home.setSelectionCount(1) self.assertTrue(home.isEnabled()) home.click() activation.assert_called_once() toggle.assert_called_once() for state, enabled in ( (ConnectionState.Connecting, False), (ConnectionState.Connected, True), (ConnectionState.Disconnecting, False), (ConnectionState.Disconnected, True), ): controller._setState(state) processQtEvents() self.assertEqual(home.text(), tray.text()) self.assertEqual(home.isEnabled(), enabled) self.assertEqual(tray.isEnabled(), enabled) tray.deleteLater() home.deleteLater() controller.deleteLater() if __name__ == '__main__': unittest.main()