mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-09-22 23:08:08 +03:00
Show subscription usage and expiry
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Vendored
+8
-18
@@ -4,7 +4,6 @@ TRANSLATION = {
|
||||
"Furious.Backends.Xray.AssetWindow",
|
||||
"Furious.Backends.Xray.RoutingWindow",
|
||||
"Furious.Widget.ServerTableView",
|
||||
"Furious.Widget.SubscriptionTableView",
|
||||
"Furious.Window.SubscriptionPage"
|
||||
],
|
||||
"RU": "Удалить",
|
||||
@@ -394,7 +393,6 @@ TRANSLATION = {
|
||||
},
|
||||
"Invalid server configuration": {
|
||||
"source": [
|
||||
"Furious.Controllers.ConnectionController",
|
||||
"Furious.Window.TextEditorWindow"
|
||||
],
|
||||
"RU": "Неверная конфигурация сервера",
|
||||
@@ -846,22 +844,6 @@ TRANSLATION = {
|
||||
"ZH": "未知错误",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Failed to start core": {
|
||||
"source": [
|
||||
"Furious.Controllers.ConnectionController"
|
||||
],
|
||||
"RU": "Сбой при запуске ядра",
|
||||
"ZH": "内核启动失败",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Core terminated unexpectedly": {
|
||||
"source": [
|
||||
"Furious.Controllers.ConnectionController"
|
||||
],
|
||||
"RU": "Неожиданное завершение работы ядра",
|
||||
"ZH": "内核意外终止",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Disconnected": {
|
||||
"source": [
|
||||
"Furious.Controllers.ConnectionController",
|
||||
@@ -3317,5 +3299,13 @@ TRANSLATION = {
|
||||
"RU": "Ошибка обновления",
|
||||
"ZH": "更新失败",
|
||||
"isReviewed": "True"
|
||||
},
|
||||
"Usage / Expiry": {
|
||||
"source": [
|
||||
"Furious.Widget.SubscriptionTableView"
|
||||
],
|
||||
"RU": "Трафик / Срок",
|
||||
"ZH": "用量 / 到期",
|
||||
"isReviewed": "True"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,10 @@ class SubscriptionGroup:
|
||||
lastSyncStatus: str = ''
|
||||
lastSyncError: str = ''
|
||||
profileCount: int = 0
|
||||
subscriptionUpload: int = 0
|
||||
subscriptionDownload: int = 0
|
||||
subscriptionTotal: int = 0
|
||||
subscriptionExpire: int = 0
|
||||
extras: dict[str, Any] = field(default_factory=dict, repr=False)
|
||||
|
||||
@classmethod
|
||||
@@ -77,6 +81,10 @@ class SubscriptionGroup:
|
||||
('lastSyncStatus', ''),
|
||||
('lastSyncError', ''),
|
||||
('profileCount', 0),
|
||||
('subscriptionUpload', 0),
|
||||
('subscriptionDownload', 0),
|
||||
('subscriptionTotal', 0),
|
||||
('subscriptionExpire', 0),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -110,6 +118,17 @@ class SubscriptionGroup:
|
||||
except (TypeError, ValueError):
|
||||
known[name] = 0
|
||||
|
||||
for name in (
|
||||
'subscriptionUpload',
|
||||
'subscriptionDownload',
|
||||
'subscriptionTotal',
|
||||
'subscriptionExpire',
|
||||
):
|
||||
try:
|
||||
known[name] = min(max(0, int(known[name])), (1 << 63) - 1)
|
||||
except (TypeError, ValueError):
|
||||
known[name] = 0
|
||||
|
||||
extras = dict(nestedExtras) if isinstance(nestedExtras, Mapping) else {}
|
||||
extras.update(data)
|
||||
|
||||
@@ -133,6 +152,10 @@ class SubscriptionGroup:
|
||||
'lastSyncStatus': self.lastSyncStatus,
|
||||
'lastSyncError': self.lastSyncError,
|
||||
'profileCount': self.profileCount,
|
||||
'subscriptionUpload': self.subscriptionUpload,
|
||||
'subscriptionDownload': self.subscriptionDownload,
|
||||
'subscriptionTotal': self.subscriptionTotal,
|
||||
'subscriptionExpire': self.subscriptionExpire,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ and temporary resources, never durable collections, shared transition authority,
|
||||
data. Workers never read live repositories or Qt models; the GUI thread verifies the full source signature and group
|
||||
revision, commits while preserving live profile identity/local metadata, then publishes coalesced status/structure.
|
||||
Post-commit reconnect/test invalidation failure is reported without undoing the committed profiles.
|
||||
- Provider-reported subscription usage/expiry metadata is untrusted advisory input. Parse it with strict bounds at the
|
||||
network boundary and commit or clear it only alongside a successful current synchronization; failed synchronization
|
||||
preserves the last successful metadata.
|
||||
- Log transport, traffic collection, and metric history remain bounded and independent of page visibility. Rendering may
|
||||
be lazy; collection/draining ownership is not.
|
||||
- Logging accepts concurrent producers through one bounded ordered model; runtime-only clearing and retention cannot
|
||||
|
||||
@@ -85,6 +85,57 @@ SUBSCRIPTION_PROXY_OPTIONS = (
|
||||
'No proxy',
|
||||
)
|
||||
|
||||
SUBSCRIPTION_USERINFO_HEADER = b'Subscription-Userinfo'
|
||||
SUBSCRIPTION_USERINFO_KEYS = ('upload', 'download', 'total', 'expire')
|
||||
MAXIMUM_SUBSCRIPTION_USERINFO_LENGTH = 4096
|
||||
MAXIMUM_SUBSCRIPTION_USERINFO_VALUE = (1 << 63) - 1
|
||||
|
||||
|
||||
def _parseSubscriptionUserInfo(value) -> dict[str, int] | None:
|
||||
"""Parse bounded non-negative values from a subscription response header."""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if isinstance(value, QtCore.QByteArray):
|
||||
value = bytes(value)
|
||||
elif isinstance(value, (bytearray, memoryview)):
|
||||
value = bytes(value)
|
||||
|
||||
if isinstance(value, bytes):
|
||||
try:
|
||||
text = value[:MAXIMUM_SUBSCRIPTION_USERINFO_LENGTH].decode('ascii')
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
elif isinstance(value, str):
|
||||
text = value[:MAXIMUM_SUBSCRIPTION_USERINFO_LENGTH]
|
||||
else:
|
||||
return None
|
||||
|
||||
if not text.strip():
|
||||
return None
|
||||
|
||||
result = {key: 0 for key in SUBSCRIPTION_USERINFO_KEYS}
|
||||
|
||||
for part in text.split(';'):
|
||||
key, separator, rawValue = part.partition('=')
|
||||
key = key.strip().casefold()
|
||||
rawValue = rawValue.strip()
|
||||
|
||||
if (
|
||||
not separator
|
||||
or key not in result
|
||||
or not rawValue.isascii()
|
||||
or not rawValue.isdecimal()
|
||||
):
|
||||
continue
|
||||
|
||||
parsed = int(rawValue)
|
||||
|
||||
if parsed <= MAXIMUM_SUBSCRIPTION_USERINFO_VALUE:
|
||||
result[key] = parsed
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def resolveSubscriptionProxy(option: str):
|
||||
"""Resolve one persisted subscription proxy policy."""
|
||||
@@ -838,6 +889,14 @@ class SubscriptionManager(HttpGetManager):
|
||||
group.lastSyncError = ''
|
||||
group.profileCount = len(result.profileIds)
|
||||
|
||||
if 'subscriptionInfo' in param:
|
||||
subscriptionInfo = param.get('subscriptionInfo') or {}
|
||||
|
||||
group.subscriptionUpload = subscriptionInfo.get('upload', 0)
|
||||
group.subscriptionDownload = subscriptionInfo.get('download', 0)
|
||||
group.subscriptionTotal = subscriptionInfo.get('total', 0)
|
||||
group.subscriptionExpire = subscriptionInfo.get('expire', 0)
|
||||
|
||||
Storage.upsertSubscriptionGroup(group)
|
||||
except Exception:
|
||||
# Any non-exit exceptions
|
||||
@@ -955,7 +1014,14 @@ class SubscriptionManager(HttpGetManager):
|
||||
if 'batchId' in kwargs:
|
||||
data = bytes(networkReply.readAll().data())
|
||||
decoderId = kwargs.get('decoderId') or kwargs.get('lastDecoderId')
|
||||
context = {**kwargs, 'decoderId': decoderId}
|
||||
subscriptionInfo = _parseSubscriptionUserInfo(
|
||||
networkReply.rawHeader(SUBSCRIPTION_USERINFO_HEADER)
|
||||
)
|
||||
context = {
|
||||
**kwargs,
|
||||
'decoderId': decoderId,
|
||||
'subscriptionInfo': subscriptionInfo,
|
||||
}
|
||||
|
||||
if self.importer.registry.subscriptionDecoderWorkerSafe(decoderId):
|
||||
self._startImportPreparation(data, context)
|
||||
@@ -971,6 +1037,9 @@ class SubscriptionManager(HttpGetManager):
|
||||
failureArgs = kwargs.get('failureArgs', list())
|
||||
|
||||
data = bytes(networkReply.readAll().data())
|
||||
subscriptionInfo = _parseSubscriptionUserInfo(
|
||||
networkReply.rawHeader(SUBSCRIPTION_USERINFO_HEADER)
|
||||
)
|
||||
|
||||
source = SubscriptionSource(
|
||||
kwargs.get('unique', ''),
|
||||
@@ -1013,7 +1082,12 @@ class SubscriptionManager(HttpGetManager):
|
||||
)
|
||||
|
||||
successArgs.append(
|
||||
{**kwargs, 'profiles': result.profiles, 'decoderId': result.decoderId}
|
||||
{
|
||||
**kwargs,
|
||||
'profiles': result.profiles,
|
||||
'decoderId': result.decoderId,
|
||||
'subscriptionInfo': subscriptionInfo,
|
||||
}
|
||||
)
|
||||
|
||||
def failureCallback(self, networkReply, **kwargs):
|
||||
|
||||
@@ -33,6 +33,7 @@ from Furious.Repository import Storage, SubscriptionGroup
|
||||
from Furious.Service import (
|
||||
SUBSCRIPTION_AUTO_UPDATE_OPTIONS,
|
||||
SUBSCRIPTION_PROXY_OPTIONS,
|
||||
formatTrafficUsage,
|
||||
resolveSubscriptionProxy,
|
||||
)
|
||||
from Furious.Qt import (
|
||||
@@ -51,8 +52,9 @@ from PySide6.QtWidgets import QAbstractItemView, QHeaderView, QTableView
|
||||
|
||||
from typing import Union, Callable
|
||||
|
||||
import logging
|
||||
import datetime
|
||||
import functools
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -76,9 +78,10 @@ class SubscriptionTableHorizontalHeader(AppQHeaderView):
|
||||
'enabled',
|
||||
'lastSyncStatus',
|
||||
'lastUpdated',
|
||||
'subscriptionInfo',
|
||||
'profiles',
|
||||
)
|
||||
DefaultSectionSizes = (260, 520, 120, 150, 220, 140)
|
||||
DefaultSectionSizes = (260, 520, 120, 150, 220, 220, 140)
|
||||
LegacyColumnKeys = ('remark', 'webURL', 'autoupdate', 'proxy')
|
||||
|
||||
# Format discriminator for the semantic JSON stored under
|
||||
@@ -296,6 +299,41 @@ def subscriptionSyncStatusText(item: dict) -> str:
|
||||
}.get(str(item.get('lastSyncStatus', '')), _('Never'))
|
||||
|
||||
|
||||
def _nonnegativeInteger(value) -> int:
|
||||
"""Return a safe non-negative integer from persisted presentation input."""
|
||||
try:
|
||||
return max(int(value), 0)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return 0
|
||||
|
||||
|
||||
def subscriptionInfoText(item: dict) -> str:
|
||||
"""Return compact provider-reported traffic usage and expiry metadata."""
|
||||
parts = []
|
||||
total = _nonnegativeInteger(item.get('subscriptionTotal', 0))
|
||||
|
||||
if total:
|
||||
used = _nonnegativeInteger(
|
||||
item.get('subscriptionUpload', 0)
|
||||
) + _nonnegativeInteger(item.get('subscriptionDownload', 0))
|
||||
|
||||
parts.append(f'{formatTrafficUsage(used)} / {formatTrafficUsage(total)}')
|
||||
|
||||
expiresAt = _nonnegativeInteger(item.get('subscriptionExpire', 0))
|
||||
|
||||
if expiresAt:
|
||||
try:
|
||||
expires = datetime.datetime.fromtimestamp(
|
||||
expiresAt, tz=datetime.timezone.utc
|
||||
).date()
|
||||
except (OverflowError, OSError, ValueError):
|
||||
pass
|
||||
else:
|
||||
parts.append(expires.isoformat())
|
||||
|
||||
return ' · '.join(parts)
|
||||
|
||||
|
||||
class UserSubsTableModel(QtCore.QAbstractTableModel):
|
||||
"""Expose user subs table data through a Qt item model."""
|
||||
|
||||
@@ -338,6 +376,7 @@ class UserSubsTableModel(QtCore.QAbstractTableModel):
|
||||
'enabled',
|
||||
'lastSyncStatus',
|
||||
'lastUpdated',
|
||||
'subscriptionInfo',
|
||||
'profiles',
|
||||
]:
|
||||
flags |= QtCore.Qt.ItemFlag.ItemIsEditable
|
||||
@@ -409,6 +448,7 @@ class UserSubsTableModel(QtCore.QAbstractTableModel):
|
||||
'enabled',
|
||||
'lastSyncStatus',
|
||||
'lastUpdated',
|
||||
'subscriptionInfo',
|
||||
'profiles',
|
||||
]:
|
||||
return False
|
||||
@@ -501,6 +541,7 @@ _TRANSLATABLE_HEADERS = [
|
||||
_('Updated'),
|
||||
_('Update Failed'),
|
||||
_('Last Updated'),
|
||||
_('Usage / Expiry'),
|
||||
_('Profiles'),
|
||||
]
|
||||
|
||||
@@ -529,6 +570,7 @@ class SubscriptionTableView(Mixins.QTranslatable, AppQTableView):
|
||||
'Last Updated',
|
||||
lambda item: item.get('lastUpdated', ''),
|
||||
),
|
||||
SubscriptionTableColumn('Usage / Expiry', subscriptionInfoText),
|
||||
SubscriptionTableColumn('Profiles'),
|
||||
]
|
||||
|
||||
@@ -539,6 +581,7 @@ class SubscriptionTableView(Mixins.QTranslatable, AppQTableView):
|
||||
'enabled',
|
||||
'lastSyncStatus',
|
||||
'lastUpdated',
|
||||
'subscriptionInfo',
|
||||
'profiles',
|
||||
]
|
||||
|
||||
|
||||
@@ -200,6 +200,25 @@ class RepositoryContractTest(unittest.TestCase):
|
||||
self.assertEqual(tuple(repository.data()), ('A', 'B', 'C', 'D', 'E'))
|
||||
self.assertFalse(repository.moveGroups(('A',), 'up'))
|
||||
|
||||
def testSubscriptionProviderMetadataRoundTripsWithBoundedIntegers(self):
|
||||
"""Preserve current quota metadata while normalizing persisted input."""
|
||||
group = SubscriptionGroup.fromMapping(
|
||||
'group-a',
|
||||
{
|
||||
'subscriptionUpload': '1024',
|
||||
'subscriptionDownload': 2048,
|
||||
'subscriptionTotal': -1,
|
||||
'subscriptionExpire': 999999999999999999999999,
|
||||
'futureField': 'preserved',
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(group.subscriptionUpload, 1024)
|
||||
self.assertEqual(group.subscriptionDownload, 2048)
|
||||
self.assertEqual(group.subscriptionTotal, 0)
|
||||
self.assertEqual(group.subscriptionExpire, (1 << 63) - 1)
|
||||
self.assertEqual(group.toMapping()['futureField'], 'preserved')
|
||||
|
||||
def testMovingBetweenSubscriptionGroupsDetachesSyncOwnership(self):
|
||||
"""Keep no-op ownership but make cross-group moves locally managed."""
|
||||
with isolatedSettings():
|
||||
|
||||
@@ -29,6 +29,7 @@ from Furious.Qt import gettext
|
||||
from Furious.Service.SubscriptionManager import (
|
||||
SubscriptionManager,
|
||||
_SubscriptionBatchState,
|
||||
_parseSubscriptionUserInfo,
|
||||
)
|
||||
from Furious.Window.SubscriptionPage import SubscriptionPage
|
||||
from Furious.Widget.SubscriptionTableView import SubscriptionTableView
|
||||
@@ -56,9 +57,12 @@ class _Payload:
|
||||
class _Reply:
|
||||
"""Provide deterministic response data and failure diagnostics."""
|
||||
|
||||
def __init__(self, value=b'', error='request failed'):
|
||||
def __init__(self, value=b'', error='request failed', headers=None):
|
||||
self._value = value
|
||||
self._error = error
|
||||
self._headers = {
|
||||
bytes(name).lower(): value for name, value in (headers or {}).items()
|
||||
}
|
||||
|
||||
def readAll(self):
|
||||
return _Payload(self._value)
|
||||
@@ -66,6 +70,9 @@ class _Reply:
|
||||
def errorString(self):
|
||||
return self._error
|
||||
|
||||
def rawHeader(self, name):
|
||||
return self._headers.get(bytes(name).lower(), b'')
|
||||
|
||||
|
||||
class _AbortableReply:
|
||||
"""Record cancellation of one exact service-owned network reply."""
|
||||
@@ -127,7 +134,15 @@ class SubscriptionManagerTest(TestCase):
|
||||
return_value=None,
|
||||
):
|
||||
manager.successCallback(
|
||||
_Reply(b'payload'),
|
||||
_Reply(
|
||||
b'payload',
|
||||
headers={
|
||||
b'Subscription-Userinfo': (
|
||||
b'upload=1024; download=2048; total=8192; '
|
||||
b'expire=1893456000'
|
||||
)
|
||||
},
|
||||
),
|
||||
unique='group-a',
|
||||
remark='Group A',
|
||||
webURL='https://invalid.test/subscription',
|
||||
@@ -138,6 +153,15 @@ class SubscriptionManagerTest(TestCase):
|
||||
self.assertEqual(len(successful), 1)
|
||||
self.assertEqual(successful[0]['profiles'], (profile,))
|
||||
self.assertEqual(successful[0]['decoderId'], 'decoder')
|
||||
self.assertEqual(
|
||||
successful[0]['subscriptionInfo'],
|
||||
{
|
||||
'upload': 1024,
|
||||
'download': 2048,
|
||||
'total': 8192,
|
||||
'expire': 1893456000,
|
||||
},
|
||||
)
|
||||
self.assertEqual(failed, [])
|
||||
|
||||
manager.importer = SimpleNamespace(importPayload=mock.Mock(return_value=None))
|
||||
@@ -156,6 +180,115 @@ class SubscriptionManagerTest(TestCase):
|
||||
self.assertEqual(failed[-1]['error'], 'UnsupportedSubscriptionFormat')
|
||||
manager.deleteLater()
|
||||
|
||||
def testSubscriptionUserInfoParserRejectsInvalidAndUnboundedValues(self):
|
||||
"""Treat provider quota headers as bounded advisory network input."""
|
||||
self.assertIsNone(_parseSubscriptionUserInfo(b''))
|
||||
self.assertIsNone(_parseSubscriptionUserInfo(b'upload=\xff'))
|
||||
self.assertEqual(
|
||||
_parseSubscriptionUserInfo(QtCore.QByteArray(b'Upload=1')),
|
||||
{'upload': 1, 'download': 0, 'total': 0, 'expire': 0},
|
||||
)
|
||||
self.assertEqual(
|
||||
_parseSubscriptionUserInfo(
|
||||
b'upload=1024; download=2048; total=8192; expire=1893456000; '
|
||||
b'ignored=value; upload=-1; total=999999999999999999999999'
|
||||
),
|
||||
{
|
||||
'upload': 1024,
|
||||
'download': 2048,
|
||||
'total': 8192,
|
||||
'expire': 1893456000,
|
||||
},
|
||||
)
|
||||
|
||||
def testBatchResponseCarriesSubscriptionInfoIntoWorkerContext(self):
|
||||
"""Keep response metadata attached to the exact asynchronous request."""
|
||||
manager = self._manager()
|
||||
manager._isCurrentRequest = mock.Mock(return_value=True)
|
||||
manager.importer = SimpleNamespace(
|
||||
registry=SimpleNamespace(
|
||||
subscriptionDecoderWorkerSafe=mock.Mock(return_value=True)
|
||||
)
|
||||
)
|
||||
manager._startImportPreparation = mock.Mock()
|
||||
|
||||
manager.successCallback(
|
||||
_Reply(
|
||||
b'payload',
|
||||
headers={
|
||||
b'Subscription-Userinfo': (
|
||||
b'upload=1024; download=2048; total=8192; ' b'expire=1893456000'
|
||||
)
|
||||
},
|
||||
),
|
||||
unique='group-a',
|
||||
batchId=7,
|
||||
lastDecoderId='decoder',
|
||||
)
|
||||
|
||||
payload, context = manager._startImportPreparation.call_args.args
|
||||
self.assertEqual(payload, b'payload')
|
||||
self.assertEqual(
|
||||
context['subscriptionInfo'],
|
||||
{
|
||||
'upload': 1024,
|
||||
'download': 2048,
|
||||
'total': 8192,
|
||||
'expire': 1893456000,
|
||||
},
|
||||
)
|
||||
manager.deleteLater()
|
||||
|
||||
def testSuccessfulCommitReplacesSubscriptionInfoButFailurePreservesIt(self):
|
||||
"""Tie advisory metadata to the same successful group commit boundary."""
|
||||
group = SubscriptionGroup(
|
||||
id='group-a',
|
||||
subscriptionUpload=1,
|
||||
subscriptionDownload=2,
|
||||
subscriptionTotal=3,
|
||||
subscriptionExpire=4,
|
||||
)
|
||||
result = SimpleNamespace(profileIds=('profile-a', 'profile-b'))
|
||||
|
||||
with (
|
||||
mock.patch.object(Storage, 'SubscriptionGroup', return_value=group),
|
||||
mock.patch.object(Storage, 'upsertSubscriptionGroup') as upsert,
|
||||
):
|
||||
SubscriptionManager._recordGroupSuccess(
|
||||
{
|
||||
'unique': 'group-a',
|
||||
'decoderId': 'decoder',
|
||||
'subscriptionInfo': {
|
||||
'upload': 1024,
|
||||
'download': 2048,
|
||||
'total': 8192,
|
||||
'expire': 1893456000,
|
||||
},
|
||||
},
|
||||
result,
|
||||
)
|
||||
SubscriptionManager._recordGroupFailure(
|
||||
{'unique': 'group-a', 'error': 'offline'}
|
||||
)
|
||||
metadataAfterFailure = (
|
||||
group.subscriptionUpload,
|
||||
group.subscriptionDownload,
|
||||
group.subscriptionTotal,
|
||||
group.subscriptionExpire,
|
||||
)
|
||||
SubscriptionManager._recordGroupSuccess(
|
||||
{'unique': 'group-a', 'subscriptionInfo': None},
|
||||
result,
|
||||
)
|
||||
|
||||
self.assertEqual(metadataAfterFailure, (1024, 2048, 8192, 1893456000))
|
||||
self.assertEqual(group.subscriptionUpload, 0)
|
||||
self.assertEqual(group.subscriptionDownload, 0)
|
||||
self.assertEqual(group.subscriptionTotal, 0)
|
||||
self.assertEqual(group.subscriptionExpire, 0)
|
||||
self.assertEqual(group.lastSyncStatus, 'success')
|
||||
self.assertEqual(upsert.call_count, 3)
|
||||
|
||||
def testRequestFailureIsDataForPresentationNotAWidgetSideEffect(self):
|
||||
manager = self._manager()
|
||||
failed = []
|
||||
@@ -701,6 +834,41 @@ class SubscriptionManagerTest(TestCase):
|
||||
self.assertEqual(gettext('Updating...', 'RU'), 'Обновление...')
|
||||
self.assertEqual(gettext('Updated', 'ZH'), '已更新')
|
||||
self.assertEqual(gettext('Update Failed', 'ZH'), '更新失败')
|
||||
self.assertEqual(gettext('Usage / Expiry', 'RU'), 'Трафик / Срок')
|
||||
self.assertEqual(gettext('Usage / Expiry', 'ZH'), '用量 / 到期')
|
||||
|
||||
def testSubscriptionTableShowsOptionalUsageAndExpiryMetadata(self):
|
||||
"""Keep provider metadata compact and blank when it is unavailable."""
|
||||
subscriptions = {
|
||||
'group-a': self._subscription(
|
||||
subscriptionUpload=1024,
|
||||
subscriptionDownload=2048,
|
||||
subscriptionTotal=8192,
|
||||
subscriptionExpire=1893456000,
|
||||
),
|
||||
'group-b': self._subscription(
|
||||
remark='Group B',
|
||||
webURL='https://invalid.test/b',
|
||||
),
|
||||
}
|
||||
|
||||
with mock.patch.object(Storage, 'UserSubs', return_value=subscriptions):
|
||||
table = SubscriptionTableView()
|
||||
model = table.sourceModel
|
||||
column = table.ItemKey.index('subscriptionInfo')
|
||||
|
||||
self.assertEqual(
|
||||
model.data(model.index(0, column), QtCore.Qt.ItemDataRole.DisplayRole),
|
||||
'3 KiB / 8 KiB · 2030-01-01',
|
||||
)
|
||||
self.assertEqual(
|
||||
model.data(model.index(1, column), QtCore.Qt.ItemDataRole.DisplayRole),
|
||||
'',
|
||||
)
|
||||
self.assertFalse(
|
||||
model.flags(model.index(0, column)) & QtCore.Qt.ItemFlag.ItemIsEditable
|
||||
)
|
||||
table.deleteLater()
|
||||
|
||||
def testSubscriptionStateNotificationRepaintsOnlyAffectedMetadataRow(self):
|
||||
"""Resolve stable IDs at delivery and avoid a whole-table refresh."""
|
||||
|
||||
Reference in New Issue
Block a user