From cb6fc25bc93579838fbab75e1441457a164d361a Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Thu, 25 Jun 2026 12:06:34 +0800 Subject: [PATCH] =?UTF-8?q?test(client):=20L1=20=E6=95=B0=E5=80=BC?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=8C=96=E7=BA=AF=E5=87=BD=E6=95=B0=E5=8D=95?= =?UTF-8?q?=E6=B5=8B=20+=20=E4=BB=8E=20widget=20=E6=8A=BD=E5=87=BA(?= =?UTF-8?q?=E6=94=AF=E6=9F=B13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/util/format.dart:抽出 formatSpeed/formatDuration(原私有于 connect_page _speed / connect_button _fmt,混在 widget 里没法单测) - connect_page/connect_button 改用公共纯函数(渲染输出不变) - test/unit/format_test.dart:10 用例覆盖速率 B/s→GB/s 各单位边界 + 时长 HH:MM:SS 补零/小时不封顶/取模 60 - 验证:format 10 + widget 8 全过、analyze 无 error/warning Co-Authored-By: Claude Opus 4.8 --- client/lib/screens/connect_page.dart | 16 ++----- client/lib/util/format.dart | 21 ++++++++++ client/lib/widgets/connect_button.dart | 8 +--- client/test/unit/format_test.dart | 58 ++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 19 deletions(-) create mode 100644 client/lib/util/format.dart create mode 100644 client/test/unit/format_test.dart diff --git a/client/lib/screens/connect_page.dart b/client/lib/screens/connect_page.dart index be1b07a..90bbe16 100644 --- a/client/lib/screens/connect_page.dart +++ b/client/lib/screens/connect_page.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../bridge/vpn_bridge.dart'; import '../core/responsive/form_factor.dart'; +import '../util/format.dart'; import '../l10n/app_text.dart'; import '../models/node.dart'; import '../pangolin_theme.dart'; @@ -33,8 +34,8 @@ class ConnectPage extends ConsumerWidget { final isFree = ref.watch(isFreePlanProvider); final quota = ref.watch(quotaProvider); final stats = ref.watch(vpnStatsProvider).valueOrNull; - final down = _speed(stats?.downloadSpeed); - final up = _speed(stats?.uploadSpeed); + final down = formatSpeed(stats?.downloadSpeed); + final up = formatSpeed(stats?.uploadSpeed); // 延迟来源:连接后优先用内核 urltest 实测(代理真实 RTT);urltest 还没探过时 // 回退用节点页的 TCP 探针 ping,尽量不显示 —。都没有才 —。 var livePing = conn.phase == VpnPhase.on ? _bestUrltest(stats) : node.ping; @@ -202,17 +203,6 @@ class ConnectPage extends ConsumerWidget { } } -/// 字节/秒 → 动态单位 (数值, 单位):B/s · KB/s · MB/s · GB/s(小数据也看得清)。 -/// null = 未取到,显示 —。 -(String, String) _speed(double? bytesPerSec) { - if (bytesPerSec == null) return ('—', 'KB/s'); - final b = bytesPerSec; - if (b < 1024) return (b.toStringAsFixed(0), 'B/s'); - if (b < 1024 * 1024) return ((b / 1024).toStringAsFixed(1), 'KB/s'); - if (b < 1024 * 1024 * 1024) return ((b / (1024 * 1024)).toStringAsFixed(1), 'MB/s'); - return ((b / (1024 * 1024 * 1024)).toStringAsFixed(2), 'GB/s'); -} - /// 内核 urltest 各出站中的最小正延迟(ms);无则 0。 int _bestUrltest(VpnStatsEvent? s) { if (s == null) return 0; diff --git a/client/lib/util/format.dart b/client/lib/util/format.dart new file mode 100644 index 0000000..19f64dd --- /dev/null +++ b/client/lib/util/format.dart @@ -0,0 +1,21 @@ +// format.dart — 纯数值格式化(支柱 3:纯逻辑与 IO 分离)。 +// +// 从 widget 抽出的无副作用函数:不碰 BuildContext / 时钟 / 网络,纯输入→输出, +// 可在 test/unit 里直接断言边界(见 test/unit/format_test.dart)。 + +/// 字节/秒 → 动态单位 (数值, 单位):B/s · KB/s · MB/s · GB/s(小数据也看得清)。 +/// null = 未取到 → ('—', 'KB/s')。 +(String, String) formatSpeed(double? bytesPerSec) { + if (bytesPerSec == null) return ('—', 'KB/s'); + final b = bytesPerSec; + if (b < 1024) return (b.toStringAsFixed(0), 'B/s'); + if (b < 1024 * 1024) return ((b / 1024).toStringAsFixed(1), 'KB/s'); + if (b < 1024 * 1024 * 1024) return ((b / (1024 * 1024)).toStringAsFixed(1), 'MB/s'); + return ((b / (1024 * 1024 * 1024)).toStringAsFixed(2), 'GB/s'); +} + +/// 时长 → HH:MM:SS(连接计时;小时不封顶)。 +String formatDuration(Duration d) { + String two(int n) => n.toString().padLeft(2, '0'); + return '${two(d.inHours)}:${two(d.inMinutes % 60)}:${two(d.inSeconds % 60)}'; +} diff --git a/client/lib/widgets/connect_button.dart b/client/lib/widgets/connect_button.dart index 985276e..d2de8d6 100644 --- a/client/lib/widgets/connect_button.dart +++ b/client/lib/widgets/connect_button.dart @@ -12,6 +12,7 @@ import 'package:flutter/material.dart'; import '../pangolin_theme.dart'; import '../state/connection_provider.dart'; +import '../util/format.dart'; import 'pangolin_icons.dart'; class ConnectButton extends StatefulWidget { @@ -50,11 +51,6 @@ class _ConnectButtonState extends State with SingleTickerProvider super.dispose(); } - String _fmt(Duration d) { - String two(int n) => n.toString().padLeft(2, '0'); - return '${two(d.inHours)}:${two(d.inMinutes % 60)}:${two(d.inSeconds % 60)}'; - } - @override Widget build(BuildContext context) { final c = context.pangolin; @@ -124,7 +120,7 @@ class _ConnectButtonState extends State with SingleTickerProvider Icon(icon, size: 50, color: fg), const SizedBox(height: 6), if (s == VpnPhase.on) ...[ - Text(_fmt(widget.elapsed), + Text(formatDuration(widget.elapsed), style: PangolinText.mono .copyWith(color: fg, fontSize: 18, fontWeight: FontWeight.w600)), Text(widget.secureLabel, diff --git a/client/test/unit/format_test.dart b/client/test/unit/format_test.dart new file mode 100644 index 0000000..b6faf1b --- /dev/null +++ b/client/test/unit/format_test.dart @@ -0,0 +1,58 @@ +// format_test.dart — L1 数值格式化纯函数单测(支柱 3:纯逻辑、确定性、零依赖)。 +import 'package:flutter_test/flutter_test.dart'; +import 'package:pangolin_vpn/util/format.dart'; + +void main() { + group('formatSpeed(字节/秒 → 数值+单位)', () { + test('null → —/KB/s(未取到)', () { + expect(formatSpeed(null), ('—', 'KB/s')); + }); + + test('0 → 0 B/s', () { + expect(formatSpeed(0), ('0', 'B/s')); + }); + + test('< 1KB 用 B/s 且取整', () { + expect(formatSpeed(512), ('512', 'B/s')); + expect(formatSpeed(1023), ('1023', 'B/s')); + }); + + test('KB/s 边界(1024)与一位小数', () { + expect(formatSpeed(1024), ('1.0', 'KB/s')); + expect(formatSpeed(1536), ('1.5', 'KB/s')); + }); + + test('MB/s 边界(1024^2)', () { + expect(formatSpeed(1024 * 1024), ('1.0', 'MB/s')); + expect(formatSpeed(1024 * 1024 * 5 + 512 * 1024), ('5.5', 'MB/s')); + }); + + test('GB/s 边界(1024^3)与两位小数', () { + expect(formatSpeed(1024 * 1024 * 1024), ('1.00', 'GB/s')); + expect(formatSpeed(2.5 * 1024 * 1024 * 1024), ('2.50', 'GB/s')); + }); + }); + + group('formatDuration(时长 → HH:MM:SS)', () { + test('0 → 00:00:00', () { + expect(formatDuration(Duration.zero), '00:00:00'); + }); + + test('秒/分补零', () { + expect(formatDuration(const Duration(seconds: 5)), '00:00:05'); + expect(formatDuration(const Duration(minutes: 3, seconds: 9)), '00:03:09'); + }); + + test('小时不封顶(对齐连接计时 01:04:59)', () { + expect( + formatDuration(const Duration(hours: 1, minutes: 4, seconds: 59)), + '01:04:59', + ); + expect(formatDuration(const Duration(hours: 100)), '100:00:00'); + }); + + test('分/秒取模 60(不溢出进位错乱)', () { + expect(formatDuration(const Duration(minutes: 90)), '01:30:00'); + }); + }); +}