Files
pangolin/client/test/unit/quota_controller_test.dart
wangjia b461a476a2 feat(client): 免费版连接倒计时 + 到点切断 + 灰按钮 + 看广告加时(#21 前端)
四端共享 Dart 实现,配合 #21 后端账户级卡控:

- me.dart: 加 quota_cap_min(当日总额度 = daily + 看广告 bonus,进度条分母)。
- quota_provider: total 取 quotaCapMin;isExhausted;markExhausted(倒计时归零本地置耗尽
  + 登录态拉 me 校准);watchAd() async 调 /ads/unlock(占位 ad_token=uuid)→ 刷新 me,返回 granted。
- connection_provider: 连接时锁定 _freeRemainingSec(会员 null 不倒计时);复用 elapsed 计时器
  每 tick 算 freeCountdown,归零 → _onFreeQuotaExhausted(主动切断不报节点异常 + markExhausted);
  倒计时用墙上时钟(后台漏跳回前台补上、准时切);连接遇后端 QUOTA_EXHAUSTED 兜底置耗尽。
- connect_button: enabled/onDisabledTap —— 额度耗尽 off 态灰化(锁图标),点击弹加时。
- quota_card: 三态(连接倒计时 mm:ss / 未连接剩余分钟 / 耗尽今日已用完);移动「看广告加时」、桌面「升级会员」。
- ad_reward_dialog(新): 移动端占位广告(播放中→3s→加时→奖励);桌面版硬 10 分钟提示去移动端/升级。
- l10n(zh/en): 倒计时/已用完/看广告加时/占位广告/桌面升级 双语。
- 测试: quota isExhausted/markExhausted;连接倒计时归零自动切断+置耗尽(注入时钟);
  额度卡三态 widget;/me 契约含 quota_cap_min;golden 更新(Linux 权威基线 quota_low/exhausted)。
- 文档: docs/free-quota-ad.html + 登记 docs/index.html。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:33:21 +08:00

70 lines
2.4 KiB
Dart

// quota_controller_test.dart — 免费额度状态(派生自 me/plans)
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pangolin_vpn/services/token_store.dart';
import 'package:pangolin_vpn/state/auth_provider.dart';
import 'package:pangolin_vpn/state/quota_provider.dart';
// 避免触碰真实 FlutterSecureStorage 插件(单测无平台)。
class _NullTokenStore implements TokenStore {
const _NullTokenStore();
@override
Future<void> saveTokens({required String access, required String refresh}) async {}
@override
Future<String?> loadAccessToken() async => null;
@override
Future<String?> loadRefreshToken() async => null;
@override
Future<void> clear() async {}
@override
Future<void> markOnboarded() async {}
@override
Future<bool> isOnboarded() async => true;
@override
Future<void> saveLastEmail(String email) async {}
@override
Future<String?> loadLastEmail() async => null;
}
ProviderContainer _container() => ProviderContainer(
overrides: [tokenStoreProvider.overrideWithValue(const _NullTokenStore())],
);
void main() {
// 纯状态逻辑(与数据源无关)。
group('FreeQuotaState', () {
test('progress = 剩余/总额', () {
expect(const FreeQuotaState(totalMinutes: 10, remainingMinutes: 5).progress,
closeTo(0.5, 1e-9));
});
test('isLow:剩余 ≤3 分钟为真', () {
expect(const FreeQuotaState(remainingMinutes: 3).isLow, true);
expect(const FreeQuotaState(remainingMinutes: 4).isLow, false);
});
test('isExhausted:剩余 ≤0 为真', () {
expect(const FreeQuotaState(remainingMinutes: 0).isExhausted, true);
expect(const FreeQuotaState(remainingMinutes: 1).isExhausted, false);
});
});
// 未登录默认态:总额 10 / 剩余 10 / 未耗尽。
group('quotaProvider', () {
test('默认 10/10 未耗尽', () {
final c = _container();
addTearDown(c.dispose);
final q = c.read(quotaProvider);
expect(q.totalMinutes, 10);
expect(q.remainingMinutes, 10);
expect(q.isExhausted, false);
});
test('markExhausted 本地立即置剩余 0', () {
final c = _container();
addTearDown(c.dispose);
c.read(quotaProvider.notifier).markExhausted();
expect(c.read(quotaProvider).remainingMinutes, 0);
expect(c.read(quotaProvider).isExhausted, true);
});
});
}