mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-09-22 23:08:08 +03:00
326 lines
11 KiB
Python
326 lines
11 KiB
Python
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
|
||
#
|
||
# 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 <https://www.gnu.org/licenses/>.
|
||
|
||
"""Protect rolling metrics history, aggregation, lazy rendering, and hover."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from Furious.Qt import gettext as _
|
||
from Furious.Service.MetricsHistory import (
|
||
DOWNLOAD_SPEED_METRIC,
|
||
DOWNLOAD_USAGE_METRIC,
|
||
UPLOAD_SPEED_METRIC,
|
||
MetricSeriesPoint,
|
||
MetricsHistory,
|
||
)
|
||
from Furious.Service.TrafficStatsManager import formatTrafficSpeed
|
||
from Furious.Widget.MetricsGraph import MetricsGraphWidget
|
||
from Furious.Window.MetricsPage import MetricsPage
|
||
|
||
from PySide6 import QtCore
|
||
|
||
from tests.support import application, collectAtBoundary, processQtEvents, waitFor
|
||
|
||
import math
|
||
import time
|
||
import unittest
|
||
|
||
|
||
class MetricsTimeSeriesBehaviorTest(unittest.TestCase):
|
||
"""Verify raw history and display buckets remain distinct and deterministic."""
|
||
|
||
def testRawSamplesRemainImmutableAsVisibleWindowMoves(self):
|
||
"""Advance now without rewriting timestamps or historical metric values."""
|
||
manager = MetricsHistory(maximumHistorySeconds=1000)
|
||
manager.recordSample({DOWNLOAD_SPEED_METRIC: 10}, sampledAt=100)
|
||
manager.recordSample({DOWNLOAD_SPEED_METRIC: 30}, sampledAt=104)
|
||
manager.recordSample({DOWNLOAD_SPEED_METRIC: 50}, sampledAt=111)
|
||
|
||
rawBefore = manager.rawSamples()
|
||
valuesBefore = tuple(
|
||
sample.values[DOWNLOAD_SPEED_METRIC] for sample in rawBefore
|
||
)
|
||
firstWindow = manager.series(
|
||
DOWNLOAD_SPEED_METRIC,
|
||
20,
|
||
granularitySeconds=10,
|
||
now=115,
|
||
)
|
||
secondWindow = manager.series(
|
||
DOWNLOAD_SPEED_METRIC,
|
||
20,
|
||
granularitySeconds=10,
|
||
now=118,
|
||
)
|
||
|
||
self.assertEqual(manager.rawSamples(), rawBefore)
|
||
self.assertEqual(
|
||
tuple(
|
||
sample.values[DOWNLOAD_SPEED_METRIC] for sample in manager.rawSamples()
|
||
),
|
||
valuesBefore,
|
||
)
|
||
self.assertEqual(firstWindow, secondWindow)
|
||
self.assertEqual(
|
||
tuple((point.sampledAt, point.value) for point in firstWindow),
|
||
((104.0, 20.0), (111.0, 50.0)),
|
||
)
|
||
self.assertGreater(
|
||
MetricsGraphWidget._normalizedTimePosition(104, 20, 115),
|
||
MetricsGraphWidget._normalizedTimePosition(104, 20, 118),
|
||
)
|
||
|
||
def testWindowEntryExitAndTimestampAlignedBuckets(self):
|
||
"""Drop old points naturally while stable absolute buckets retain values."""
|
||
manager = MetricsHistory(maximumHistorySeconds=1000)
|
||
|
||
for sampledAt, value in ((1, 10), (9, 30), (10, 50), (19, 70), (20, 90)):
|
||
manager.recordSample(
|
||
{
|
||
DOWNLOAD_SPEED_METRIC: value,
|
||
DOWNLOAD_USAGE_METRIC: value * 100,
|
||
},
|
||
sampledAt=sampledAt,
|
||
)
|
||
|
||
speedAt20 = manager.series(
|
||
DOWNLOAD_SPEED_METRIC,
|
||
20,
|
||
granularitySeconds=10,
|
||
now=20,
|
||
)
|
||
usageAt20 = manager.series(
|
||
DOWNLOAD_USAGE_METRIC,
|
||
20,
|
||
granularitySeconds=10,
|
||
now=20,
|
||
)
|
||
speedAt21 = manager.series(
|
||
DOWNLOAD_SPEED_METRIC,
|
||
20,
|
||
granularitySeconds=10,
|
||
now=21,
|
||
)
|
||
|
||
self.assertEqual(
|
||
tuple((point.sampledAt, point.value) for point in speedAt20),
|
||
((9.0, 20.0), (19.0, 60.0), (20.0, 90.0)),
|
||
)
|
||
self.assertEqual(
|
||
tuple((point.sampledAt, point.value) for point in usageAt20),
|
||
((9.0, 3000.0), (19.0, 7000.0), (20.0, 9000.0)),
|
||
)
|
||
self.assertEqual(
|
||
tuple((point.sampledAt, point.value) for point in speedAt21),
|
||
((9.0, 20.0), (19.0, 60.0), (20.0, 90.0)),
|
||
)
|
||
|
||
def testAutoGranularityAndNonMonotonicInputArePredictable(self):
|
||
"""Select documented buckets and normalize backward timestamps safely."""
|
||
manager = MetricsHistory(maximumHistorySeconds=24 * 60 * 60)
|
||
|
||
self.assertEqual(manager.effectiveGranularity(5 * 60), 5)
|
||
self.assertEqual(manager.effectiveGranularity(15 * 60), 10)
|
||
self.assertEqual(manager.effectiveGranularity(60 * 60), 30)
|
||
self.assertEqual(manager.effectiveGranularity(24 * 60 * 60), 15 * 60)
|
||
self.assertEqual(manager.effectiveGranularity(60, 120), 60)
|
||
|
||
manager.recordSample({UPLOAD_SPEED_METRIC: 10}, sampledAt=50)
|
||
manager.recordSample({UPLOAD_SPEED_METRIC: 20}, sampledAt=40)
|
||
|
||
self.assertEqual(
|
||
tuple(sample.sampledAt for sample in manager.rawSamples()),
|
||
(50.0, 50.0),
|
||
)
|
||
self.assertEqual(
|
||
tuple(
|
||
point.value
|
||
for point in manager.series(
|
||
UPLOAD_SPEED_METRIC,
|
||
60,
|
||
granularitySeconds=10,
|
||
now=50,
|
||
)
|
||
),
|
||
(15.0,),
|
||
)
|
||
|
||
def testUsageHistoryCanClearWithoutMutatingSpeedHistory(self):
|
||
"""Reset cumulative graphs while retaining immutable speed samples."""
|
||
manager = MetricsHistory()
|
||
manager.recordSample(
|
||
{
|
||
DOWNLOAD_SPEED_METRIC: 25,
|
||
DOWNLOAD_USAGE_METRIC: 100,
|
||
},
|
||
sampledAt=10,
|
||
)
|
||
manager.clearTrafficUsageHistory()
|
||
|
||
samples = manager.rawSamples()
|
||
|
||
self.assertEqual(len(samples), 1)
|
||
self.assertEqual(samples[0].values[DOWNLOAD_SPEED_METRIC], 25.0)
|
||
self.assertNotIn(DOWNLOAD_USAGE_METRIC, samples[0].values)
|
||
|
||
|
||
class MetricsPageAndGraphTest(unittest.TestCase):
|
||
"""Exercise hidden-page rendering and graph lookup with real widgets."""
|
||
|
||
@classmethod
|
||
def setUpClass(cls):
|
||
"""Create the process-wide headless QApplication."""
|
||
application()
|
||
|
||
def tearDown(self):
|
||
"""Drain deferred widget destruction after every UI case."""
|
||
collectAtBoundary()
|
||
|
||
def testTrafficGraphsAndSelectorsShareOneCard(self):
|
||
"""Keep the page title separate while grouping all metrics controls."""
|
||
manager = MetricsHistory()
|
||
page = MetricsPage(manager)
|
||
contentLayout = page.scrollArea.widget().layout()
|
||
cardLayout = page.metricsCard.layout()
|
||
headerLayout = cardLayout.itemAt(0).layout()
|
||
|
||
self.assertIs(contentLayout.itemAt(0).widget(), page.pageTitleLabel)
|
||
self.assertIs(contentLayout.itemAt(1).widget(), page.metricsCard)
|
||
self.assertIs(contentLayout.itemAt(2).widget(), page.endpointInfoWidget)
|
||
self.assertEqual(page.metricsCard.objectName(), 'TrafficMetricsCard')
|
||
self.assertIs(headerLayout.itemAt(0).widget(), page.metricsCard.titleLabel)
|
||
self.assertEqual(page.metricsCard.titleLabel.text(), _('Traffic Statistics'))
|
||
self.assertEqual(headerLayout.stretch(1), 1)
|
||
self.assertIs(headerLayout.itemAt(2).widget(), page.timeRangeLabel)
|
||
self.assertIs(headerLayout.itemAt(3).widget(), page.timeRangeComboBox)
|
||
self.assertIs(headerLayout.itemAt(5).widget(), page.granularityLabel)
|
||
self.assertIs(headerLayout.itemAt(6).widget(), page.granularityComboBox)
|
||
|
||
for control in (
|
||
page.timeRangeLabel,
|
||
page.timeRangeComboBox,
|
||
page.granularityLabel,
|
||
page.granularityComboBox,
|
||
):
|
||
self.assertIs(control.parentWidget(), page.metricsCard)
|
||
|
||
self.assertIs(
|
||
page.downloadSpeedGraph.parentWidget(),
|
||
page.metricsCard.downloadSpeedCard,
|
||
)
|
||
self.assertIs(
|
||
page.downloadUsageGraph.parentWidget(),
|
||
page.metricsCard.downloadUsageCard,
|
||
)
|
||
self.assertIs(
|
||
page.uploadSpeedGraph.parentWidget(),
|
||
page.metricsCard.uploadSpeedCard,
|
||
)
|
||
self.assertIs(
|
||
page.uploadUsageGraph.parentWidget(),
|
||
page.metricsCard.uploadUsageCard,
|
||
)
|
||
|
||
page.close()
|
||
page.deleteLater()
|
||
manager.deleteLater()
|
||
|
||
def testHiddenPageStoresHistoryWithoutRenderingThenCatchesUp(self):
|
||
"""Keep collection eager and graph submission strictly visibility-bound."""
|
||
manager = MetricsHistory()
|
||
page = MetricsPage(manager)
|
||
sampledAt = math.floor((time.monotonic() - 20) / 10) * 10 + 1
|
||
manager.recordSample({DOWNLOAD_SPEED_METRIC: 32}, sampledAt=sampledAt)
|
||
|
||
processQtEvents()
|
||
|
||
self.assertEqual(page._renderRevision, 0)
|
||
self.assertEqual(page.downloadSpeedGraph._points, tuple())
|
||
self.assertFalse(page._renderTimer.isActive())
|
||
self.assertFalse(page._timelineTimer.isActive())
|
||
|
||
page.show()
|
||
|
||
self.assertTrue(waitFor(lambda: page._renderRevision == 1))
|
||
self.assertEqual(
|
||
tuple(point.value for point in page.downloadSpeedGraph._points),
|
||
(32.0,),
|
||
)
|
||
self.assertTrue(page._timelineTimer.isActive())
|
||
|
||
page.hide()
|
||
|
||
processQtEvents()
|
||
|
||
hiddenRevision = page._renderRevision
|
||
|
||
manager.recordSample({DOWNLOAD_SPEED_METRIC: 64}, sampledAt=sampledAt + 1)
|
||
page._renderLatest()
|
||
|
||
processQtEvents()
|
||
|
||
self.assertEqual(page._renderRevision, hiddenRevision)
|
||
self.assertFalse(page._renderTimer.isActive())
|
||
self.assertFalse(page._timelineTimer.isActive())
|
||
|
||
page.show()
|
||
|
||
self.assertTrue(waitFor(lambda: page._renderRevision > hiddenRevision))
|
||
self.assertEqual(
|
||
tuple(point.value for point in page.downloadSpeedGraph._points),
|
||
(48.0,),
|
||
)
|
||
|
||
page.close()
|
||
page.deleteLater()
|
||
manager.deleteLater()
|
||
|
||
def testHoverLookupMatchesDisplayedPointAndTimestampRange(self):
|
||
"""Report the exact prepared point rather than recomputing raw history."""
|
||
graph = MetricsGraphWidget(
|
||
MetricsGraphWidget.Download,
|
||
formatTrafficSpeed,
|
||
)
|
||
graph.resize(640, 360)
|
||
graph.setMetricLabel('Download Speed')
|
||
point = MetricSeriesPoint(95, 2048, 91, 95, 3)
|
||
graph.setSeries((point,), 20, 100, currentWallTime=1000)
|
||
graph.show()
|
||
|
||
processQtEvents()
|
||
|
||
position = graph._pointPosition(
|
||
point,
|
||
graph._chartRect(),
|
||
graph._maximumValue(),
|
||
).toPoint()
|
||
|
||
self.assertIs(graph._nearestPoint(position), point)
|
||
|
||
tooltip = graph._tooltipText(point)
|
||
|
||
self.assertIn('Download Speed: 2 KiB/s', tooltip)
|
||
self.assertIn(' – ', tooltip)
|
||
self.assertNotIn('×3', tooltip)
|
||
|
||
graph.close()
|
||
graph.deleteLater()
|
||
|
||
|
||
if __name__ == '__main__':
|
||
unittest.main()
|