b461a476a2
四端共享 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>
90 lines
3.0 KiB
Dart
90 lines
3.0 KiB
Dart
// api_contract_test.dart — HTTP API 契约快照(支柱 2:契约单源)。
|
|
//
|
|
// 冻结 /v1/me、/v1/usage 响应的「字段面」(snake_case JSON)。
|
|
// 字段增删改名会让本测试失败,逼开发者同步后端响应 + 文档,避免控制面与客户端悄悄分叉。
|
|
//
|
|
// ⚠️ 改下面的冻结集 = 改 HTTP 契约,必须同步:
|
|
// - 后端:server/internal/httpapi/account.go(/v1/me)、server/internal/usage/handler.go(/v1/usage)
|
|
// - 客户端:client/lib/models/{me,usage_point}.dart
|
|
// - 文档:各 model 头注 + docs/dev-conventions.html 支柱 2
|
|
// 注:/v1/usage/devices(DeviceUsage)契约待 stats-overhaul 合并后补(device_usage.dart 当前 WIP、未提交)。
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:pangolin_vpn/models/me.dart';
|
|
import 'package:pangolin_vpn/models/usage_point.dart';
|
|
|
|
// ── 契约冻结集(后端 snake_case JSON key)──────────────────────────
|
|
const _frozenMeKeys = <String>{
|
|
'email',
|
|
'plan',
|
|
'expires_at',
|
|
'devices_used',
|
|
'devices_max',
|
|
'quota_today_min',
|
|
'quota_cap_min',
|
|
'data_today_gb',
|
|
'weekly_gb',
|
|
'totp_enabled',
|
|
};
|
|
const _frozenUsagePointKeys = <String>{
|
|
'date',
|
|
'bytes_up',
|
|
'bytes_down',
|
|
'minutes_used',
|
|
};
|
|
Map<String, dynamic> _meSample() => {
|
|
'email': 'me@example.com',
|
|
'plan': 'pro',
|
|
'expires_at': '2026-12-31T00:00:00Z',
|
|
'devices_used': 2,
|
|
'devices_max': 5,
|
|
'quota_today_min': null, // pro/team 不限 → null
|
|
'quota_cap_min': null, // pro/team 不限 → null
|
|
'data_today_gb': 1.5,
|
|
'weekly_gb': [0.1, 0.2, 0.0, 1.0, 2.0, 0.5, 1.5],
|
|
'totp_enabled': true,
|
|
};
|
|
|
|
Map<String, dynamic> _usagePointSample() => {
|
|
'date': '2026-06-24',
|
|
'bytes_up': 1000,
|
|
'bytes_down': 2000,
|
|
'minutes_used': 12,
|
|
};
|
|
|
|
void main() {
|
|
group('/v1/me 契约快照', () {
|
|
test('字段面冻结(增删字段须同步后端 account.go + 文档)', () {
|
|
expect(_meSample().keys.toSet(), _frozenMeKeys);
|
|
});
|
|
|
|
test('fromJson 正确解析全部字段', () {
|
|
final me = Me.fromJson(_meSample());
|
|
expect(me.email, 'me@example.com');
|
|
expect(me.plan, 'pro');
|
|
expect(me.isFree, isFalse);
|
|
expect(me.expiresAt, DateTime.tryParse('2026-12-31T00:00:00Z'));
|
|
expect(me.devicesUsed, 2);
|
|
expect(me.devicesMax, 5);
|
|
expect(me.quotaTodayMin, isNull);
|
|
expect(me.dataTodayGb, 1.5);
|
|
expect(me.weeklyGb, hasLength(7));
|
|
expect(me.totpEnabled, isTrue);
|
|
});
|
|
});
|
|
|
|
group('/v1/usage 契约快照(UsagePoint)', () {
|
|
test('字段面冻结', () {
|
|
expect(_usagePointSample().keys.toSet(), _frozenUsagePointKeys);
|
|
});
|
|
|
|
test('fromJson 正确解析 + bytesTotal 派生', () {
|
|
final p = UsagePoint.fromJson(_usagePointSample());
|
|
expect(p.date, '2026-06-24');
|
|
expect(p.bytesUp, 1000);
|
|
expect(p.bytesDown, 2000);
|
|
expect(p.minutesUsed, 12);
|
|
expect(p.bytesTotal, 3000);
|
|
});
|
|
});
|
|
}
|