refactor: 拆分主页与公共组件,扫码登录改走 WinHTTP

- 抽出 lib/core 的日期/JSON 工具,收敛各页重复解析
- 主页与音乐风险页拆成组件,风险功能共用一套运行框架
- 修复设置页作为主页页签保存时弹掉根路由导致的黑屏
- 登录对齐 Astral aimedb 协议:84 位二维码校验、签名与请求头修正,
  补 GlobalSign 中间证书;Windows 改用 WinHTTP 绕过服务端对
  BoringSSL 的 TLS 指纹拦截(证书校验保持开启)
- 补开发预览数据与测试
This commit is contained in:
Bad0RANG3 committed 2026-10-25 15:51:10 +08:00
1 parent 0cb5320290
commit e9357f5969
55 files changed
+4387 -3392

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ichino/core/date_time_format.dart';
void main() {
test('上海时区固定为 UTC+8', () {
expect(shanghaiOffset, const Duration(hours: 8));
expect(
shanghaiFromUnixSeconds(0),
DateTime.utc(1970, 1, 1, 8),
);
});
test('formatDate 补零', () {
expect(formatDate(DateTime(2026, 1, 5, 7, 3, 9)), '2026-01-05');
expect(formatDate(DateTime(2026, 12, 31, 23, 59, 59)), '2026-12-31');
});
test('formatDateTime 与 formatPlayDateTime 的秒与小数位', () {
final dt = DateTime(2026, 10, 2, 22, 19, 46);
expect(formatDateTime(dt), '2026-10-02 22:19:46');
expect(formatPlayDateTime(dt), '2026-10-02 22:19:46.0');
});
}
+84
View File
@@ -0,0 +1,84 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ichino/core/json_value.dart';
void main() {
group('jsonInt', () {
test('int / num / 数字字符串都能读出来', () {
expect(jsonInt(12), 12);
expect(jsonInt(12.9), 12);
expect(jsonInt('42'), 42);
expect(jsonInt('-3'), -3);
});
test('null、非数字字符串、其他类型退回 fallback', () {
expect(jsonInt(null), 0);
expect(jsonInt('12abc'), 0);
expect(jsonInt('12abc', fallback: -1), -1);
expect(jsonInt({'v': 1}), 0);
});
});
group('jsonDouble', () {
test('int / double / 数字字符串都能读出来', () {
expect(jsonDouble(3), 3.0);
expect(jsonDouble(3.25), 3.25);
expect(jsonDouble('3.25'), 3.25);
});
test('解析失败退回 fallback', () {
expect(jsonDouble(null), 0.0);
expect(jsonDouble('x'), 0.0);
expect(jsonDouble('x', fallback: 1.5), 1.5);
});
});
group('jsonBool', () {
test('bool、数字、常见字符串形式', () {
expect(jsonBool(true), isTrue);
expect(jsonBool(false), isFalse);
expect(jsonBool(1), isTrue);
expect(jsonBool(0), isFalse);
expect(jsonBool('1'), isTrue);
expect(jsonBool(' TRUE '), isTrue);
expect(jsonBool('true'), isTrue);
expect(jsonBool('0'), isFalse);
expect(jsonBool('false'), isFalse);
});
test('无法判断时用 fallback', () {
expect(jsonBool(null), isFalse);
expect(jsonBool(null, fallback: true), isTrue);
expect(jsonBool('yes'), isFalse);
});
});
group('jsonString', () {
test('null 用 fallback,其他类型转字符串', () {
expect(jsonString(null), '');
expect(jsonString(null, fallback: '-'), '-');
expect(jsonString(7), '7');
expect(jsonString('abc'), 'abc');
});
});
group('jsonMap / jsonMapList', () {
test('jsonMap 只接受 Map<String, dynamic>', () {
final map = <String, dynamic>{'a': 1};
expect(jsonMap(map), same(map));
expect(jsonMap(null), isEmpty);
expect(jsonMap([1, 2]), isEmpty);
});
test('jsonMapList 丢掉非 Map 元素,其余原样保留', () {
final rows = [
<String, dynamic>{'id': 1},
'oops',
null,
<String, dynamic>{'id': 2},
];
expect(jsonMapList(rows).map((e) => e['id']), [1, 2]);
expect(jsonMapList(null), isEmpty);
expect(jsonMapList(<String, dynamic>{'id': 1}), isEmpty);
});
});
}
+78
View File
@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ichino/config/strings.dart';
import 'package:ichino/config/title_server_config.dart';
import 'package:ichino/dev/ui_preview_data.dart';
import 'package:ichino/models/session_model.dart';
import 'package:ichino/pages/about_page.dart';
import 'package:ichino/pages/best50_page.dart';
import 'package:ichino/pages/high_risk_feature_page.dart';
import 'package:ichino/pages/home/home_tab_view.dart';
import 'package:ichino/pages/home_page.dart';
import 'package:ichino/pages/settings_page.dart';
import 'package:ichino/pages/ticket_page.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() async {
SharedPreferences.setMockInitialValues({});
await TitleServerConfigHolder().clear();
SessionModel.instance.reset();
});
testWidgets('主页启动时只构建首页,其他页签首次访问才创建', (tester) async {
await tester.pumpWidget(
const MaterialApp(home: HomePage(userId: 1, token: 't')),
);
await tester.pump();
expect(find.byType(HomeTabView), findsOneWidget);
expect(find.byType(TicketPage), findsNothing);
expect(find.byType(Best50Page), findsNothing);
expect(find.byType(HighRiskFeaturePage), findsNothing);
expect(find.byType(SettingsPage), findsNothing);
expect(find.byType(AboutPage), findsNothing);
await tester.tap(find.byIcon(Icons.bar_chart_outlined));
await tester.pump();
expect(find.byType(Best50Page), findsOneWidget);
expect(find.widgetWithText(AppBar, AppStrings.tabBest50), findsOneWidget);
// 其余页签仍未构建。
expect(find.byType(TicketPage), findsNothing);
expect(find.byType(SettingsPage), findsNothing);
await tester.tap(find.byIcon(Icons.settings_outlined));
await tester.pump();
expect(find.byType(SettingsPage), findsOneWidget);
// 已访问过的页签保留在 IndexedStack 里,切换回来不重建。
expect(find.byType(Best50Page, skipOffstage: false), findsOneWidget);
expect(find.byType(TicketPage), findsNothing);
});
testWidgets('有预览种子数据时,未配置 Title Server 也展示主页数据', (tester) async {
tester.view.physicalSize = const Size(1280, 1600);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
const MaterialApp(
home: HomePage(
userId: uiPreviewUserId,
token: uiPreviewToken,
restored: SessionRestoreResult(
reusedCookie: true,
userData: uiPreviewUserData,
preview: uiPreviewPreview,
loggedIn: true,
),
),
),
);
await tester.pump();
expect(find.text(AppStrings.titleServerNotConfigured), findsNothing);
expect(find.text('Ichino'), findsOneWidget);
});
}
+5 -2
View File
@@ -11,14 +11,17 @@ void main() {
await tester.pumpWidget(const MyApp());
await tester.pump();
// 72 位:截取的后 64 位里没有 SGWCMAID 前缀
// 72 位:既不是 84 位二维码串,也不是二维码链接
await tester.enterText(find.byType(TextField), 'ABCDEF' * 12);
await tester.pump();
await tester.tap(find.text(AppStrings.login));
await tester.pump();
expect(find.text(AppStrings.qrTokenUnrecognized), findsOneWidget);
expect(
find.textContaining(AppStrings.qrTokenUnrecognized),
findsOneWidget,
);
});
testWidgets('粘贴连接信息时按钮切成「恢复会话」', (tester) async {
+88
View File
@@ -0,0 +1,88 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ichino/config/title_server_config.dart';
import 'package:ichino/services/api_service.dart';
/// 扫码登录的输入归一化与 Keychip 拼装:纯逻辑,不发请求。
void main() {
const payload = 'A63E01C304710072610022219460000000000000000000000000000000000000';
const qrString = 'SGWCMAID261002221946$payload';
const qrUrl =
'https://wq.wahlap.net/qrcode/req/MAID261002221946$payload.html'
'?l=123456&t=261002221946&d=1';
group('normalizeQr / extractQRCode', () {
test('84 位二维码串取后 64 位载荷', () {
expect(payload.length, 64);
expect(qrString.length, 84);
expect(ApiService.extractQRCode(qrString), payload);
});
test('二维码链接还原成同一条串', () {
expect(ApiService.normalizeQr(qrUrl), qrString);
expect(ApiService.extractQRCode(qrUrl), payload);
});
test('长度、前缀、GameID、载荷字符集不合预期时抛 FormatException', () {
expect(
() => ApiService.normalizeQr('ABCDEF' * 12),
throwsFormatException,
);
expect(
() => ApiService.normalizeQr('XGWC${qrString.substring(4)}'),
throwsFormatException,
);
expect(
() => ApiService.normalizeQr('SGWCXBID${qrString.substring(8)}'),
throwsFormatException,
);
expect(
() => ApiService.normalizeQr('SGWCMAID261002221946${'G' * 64}'),
throwsFormatException,
);
expect(
() => ApiService.normalizeQr('https://wq.wahlap.net/other/x.html'),
throwsFormatException,
);
});
});
group('formatTimestamp', () {
test('本机本地墙上时间,yymmddHHMMss', () {
expect(
ApiService.formatTimestamp(DateTime(2026, 10, 2, 22, 19, 46)),
'261002221946',
);
expect(
ApiService.formatTimestamp(DateTime(2026, 1, 5, 7, 3, 9)),
'260105070309',
);
});
});
group('TitleServerConfig.fullKeychipId', () {
TitleServerConfig config(String keychip, {int placeId = 1007}) =>
TitleServerConfig(
titleServerUrl: 'https://example.invalid',
aesKey: 'k',
aesIv: 'v',
clientId: 'A63E01C3047',
placeId: placeId,
keychipId: keychip,
);
test('完整形式(带连字符 / 连写 / 小写)归一到大写带连字符', () {
expect(config('A63E-01C30471007').fullKeychipId, 'A63E-01C30471007');
expect(config('A63E01C30471007').fullKeychipId, 'A63E-01C30471007');
expect(config('a63e-01c30471007').fullKeychipId, 'A63E-01C30471007');
});
test('只填 11 位短值时按 placeId 补全', () {
expect(config('A63E01C3047').fullKeychipId, 'A63E-01C30471007');
});
test('短于 11 位当没配,交给登录层报错', () {
expect(config('').fullKeychipId, isEmpty);
expect(config('A63E01').fullKeychipId, isEmpty);
});
});
}
+92
View File
@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ichino/config/strings.dart';
import 'package:ichino/widgets/risk/risk_list_widgets.dart';
Widget _wrap(Widget child) =>
MaterialApp(home: Scaffold(body: Column(children: [child])));
void main() {
testWidgets('RiskPendingList 空列表显示提示,非空显示行', (tester) async {
await tester.pumpWidget(
_wrap(
const RiskPendingList(
title: '待解锁',
emptyHint: '还没有添加',
count: 0,
rows: [],
),
),
);
expect(find.text('待解锁 (0)'), findsOneWidget);
expect(find.text('还没有添加'), findsOneWidget);
await tester.pumpWidget(
_wrap(
RiskPendingList(
title: '待解锁',
emptyHint: '还没有添加',
count: 1,
rows: const [
RiskPendingRow(leading: '#834', trailing: 'Master', onRemove: _noop),
],
),
),
);
expect(find.text('待解锁 (1)'), findsOneWidget);
expect(find.text('还没有添加'), findsNothing);
expect(find.textContaining('#834'), findsOneWidget);
});
testWidgets('RiskPendingRow 禁用时删除按钮不触发回调', (tester) async {
var removed = 0;
await tester.pumpWidget(
_wrap(
RiskPendingRow(
leading: '#1',
trailing: 'x',
onRemove: () => removed++,
enabled: false,
),
),
);
final button = tester.widget<IconButton>(find.byType(IconButton));
expect(button.onPressed, isNull);
expect(find.byTooltip(AppStrings.listRemoveTooltip), findsOneWidget);
await tester.pumpWidget(
_wrap(
RiskPendingRow(
leading: '#1',
trailing: 'x',
onRemove: () => removed++,
),
),
);
await tester.tap(find.byTooltip(AppStrings.listRemoveTooltip));
expect(removed, 1);
});
testWidgets('RiskNumberField 与 RiskAddButton 共用统一样式', (tester) async {
final controller = TextEditingController();
addTearDown(controller.dispose);
var added = 0;
await tester.pumpWidget(
_wrap(
Column(
children: [
RiskNumberField(controller: controller, label: 'A', hint: 'B'),
RiskAddButton(onPressed: () => added++),
],
),
),
);
expect(find.text('A'), findsOneWidget);
expect(find.text('B'), findsOneWidget);
await tester.tap(find.text(AppStrings.listAddButton));
expect(added, 1);
});
}
void _noop() {}
+43
View File
@@ -0,0 +1,43 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ichino/models/session_model.dart';
void main() {
final session = SessionModel.instance;
setUp(session.reset);
tearDown(session.reset);
test('updateCookies 只在 Cookie 变化时通知,空值忽略', () {
var notified = 0;
session.addListener(() => notified++);
session.updateCookies(null);
session.updateCookies('');
expect(notified, 0);
expect(session.cookies, isNull);
session.updateCookies('JSESSIONID=A');
expect(session.cookies, 'JSESSIONID=A');
expect(notified, 1);
// 同一个值重复回写不制造额外重建。
session.updateCookies('JSESSIONID=A');
expect(notified, 1);
session.updateCookies('JSESSIONID=B');
expect(session.cookies, 'JSESSIONID=B');
expect(notified, 2);
});
test('reset 清空 Cookie 与登录态并通知', () {
session.updateCookies('JSESSIONID=A');
var notified = 0;
session.addListener(() => notified++);
session.reset();
expect(session.cookies, isNull);
expect(session.gameLogin, isNull);
expect(notified, 1);
});
}
+39
View File
@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ichino/config/strings.dart';
import 'package:ichino/config/title_server_config.dart';
import 'package:ichino/models/session_model.dart';
import 'package:ichino/pages/settings_page.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() async {
SharedPreferences.setMockInitialValues({});
await TitleServerConfigHolder().clear();
SessionModel.instance.reset();
});
testWidgets('设置页作为主页页签保存时不会弹掉根路由', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(body: SettingsPage(showAppBar: false)),
),
);
final fields = find.byType(TextFormField);
expect(fields, findsNWidgets(11));
for (var i = 0; i < 11; i++) {
await tester.enterText(fields.at(i), 'value');
}
final save = find.widgetWithText(FilledButton, AppStrings.save);
await tester.ensureVisible(save);
await tester.tap(save);
await tester.pumpAndSettle();
// 根路由还在,页面没有变成黑屏;改为用 SnackBar 提示已保存。
expect(find.byType(SettingsPage), findsOneWidget);
expect(find.text(AppStrings.configSaved), findsOneWidget);
expect(tester.state<NavigatorState>(find.byType(Navigator)).canPop(), isFalse);
});
}
+93
View File
@@ -0,0 +1,93 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:ichino/config/title_server_config.dart';
import 'package:ichino/services/title_api_service.dart';
const _config = TitleServerConfig(
titleServerUrl: 'http://127.0.0.1:9',
aesKey: '0123456789abcdef',
aesIv: 'fedcba9876543210',
clientId: 'A63E01C2805',
);
void main() {
test('describeApiError 只取协议层异常的 message', () {
expect(
describeApiError(const TitleApiException('GetUserDataApi returned 500')),
'GetUserDataApi returned 500',
);
expect(describeApiError(const FormatException('bad qr')), 'FormatException: bad qr');
expect(describeApiError('oops'), 'oops');
});
test('响应头里的 JSESSIONID 会写回实例并通知调用方', () async {
final captured = <String>[];
final sentCookies = <String?>[];
final service = TitleApiService(
_config,
cookies: 'JSESSIONID=OLD',
onCookiesChanged: captured.add,
);
var requests = 0;
final client = MockClient((request) async {
sentCookies.add(request.headers['Cookie']);
requests++;
if (requests == 1) {
return http.Response(
'',
500,
headers: {'Set-Cookie': 'JSESSIONID=NEW; Path=/; HttpOnly'},
);
}
return http.Response('', 500);
});
await http.runWithClient(() async {
// Cookie 捕获发生在状态码检查之前:即使这次请求失败,
// 服务器轮换的 Cookie 也不能丢。
await expectLater(
service.getUserData(1),
throwsA(isA<TitleApiException>()),
);
await expectLater(
service.getUserData(1),
throwsA(isA<TitleApiException>()),
);
}, () => client);
expect(captured, ['JSESSIONID=NEW']);
expect(service.cookies, 'JSESSIONID=NEW');
expect(sentCookies, ['JSESSIONID=OLD', 'JSESSIONID=NEW']);
});
test('响应没有 JSESSIONID 时不回写', () async {
final captured = <String>[];
final service = TitleApiService(
_config,
cookies: 'JSESSIONID=OLD',
onCookiesChanged: captured.add,
);
await http.runWithClient(() async {
await expectLater(
service.getUserData(1),
throwsA(isA<TitleApiException>()),
);
}, () => MockClient((_) async => http.Response('', 500)));
expect(captured, isEmpty);
expect(service.cookies, 'JSESSIONID=OLD');
});
test('lastRawResponse 是实例状态,不会跨实例串数据', () {
final first = TitleApiService(_config);
final second = TitleApiService(_config);
first.lastRawResponse = '{"userData":{}}';
expect(first.lastRawResponse, '{"userData":{}}');
expect(second.lastRawResponse, isNull);
});
}