flutter架 构项目基础

This commit is contained in:
崔宝新
2026-09-04 15:22:20 +08:00
parent 70967229d0
commit 0c5aee59c0
265 changed files with 35484 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
// 单元测试:UserModel 的 JSON 解析兼容点
//
// 为什么测它:CurrentUserExtension.fromJson 里有 4 个"抗后端漂移"
// 的兼容点(见 user_model.dart 注释),任何一项回归都会导致线上
// 解析崩或字段丢,必须用测试锁死:
// ① data 包装:接口返回 {code, message, data:{...}} 时取 data;
// ② 字符串数字:userId/classId 后端可能返回 "820466383020136";
// ③ 缺字段兜底:后端漏字段时用默认值,不闪崩;
// ④ toJson/fromJson 必须成对,否则缓存回填丢字段。
//
// 运行:flutter test test/models/user_model_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:new_learn/core/models/user_model.dart';
void main() {
group('CurrentUserExtension.fromJson', () {
test('① data 包装:取 json["data"],忽略外层 code/message', () {
final user = CurrentUserExtension.fromJson({
'code': 200,
'message': 'ok',
'data': {'userId': 1001, 'userName': '小明', 'userPoints': 88.5},
});
expect(user.userId, 1001);
expect(user.userName, '小明');
expect(user.userPoints, 88.5);
});
test('① 直接返回(没有 data 包装)也支持', () {
final user = CurrentUserExtension.fromJson({
'userId': 7,
'userName': '小李',
});
expect(user.userId, 7);
expect(user.userName, '小李');
});
test('② 字符串数字:userId/classId/userPoints 传字符串能正确转换', () {
final user = CurrentUserExtension.fromJson({
'data': {
'userId': '820466383020136', // 大数必须 int 不丢精度
'classId': '12',
'userPoints': '105.5',
'graduationYear': '2026',
},
});
expect(user.userId, 820466383020136);
expect(user.classId, 12);
expect(user.userPoints, 105.5);
expect(user.graduationYear, 2026);
});
test('② 非法字符串数字兜底为 0,不抛异常', () {
final user = CurrentUserExtension.fromJson({
'data': {'userId': 'abc', 'userPoints': 'xyz'},
});
expect(user.userId, 0);
expect(user.userPoints, 0.0);
});
test('③ 空 JSON:全部字段走默认值,不闪崩', () {
final user = CurrentUserExtension.fromJson({});
expect(user.userId, 0);
expect(user.userName, '');
expect(user.userPoints, 0.0);
expect(user.levelName, '');
expect(user.isShowGuide, false);
expect(user.isShowReport, false);
expect(user.id, 0);
});
test('④ toJson/fromJson 往返一致(缓存回填不丢字段)', () {
const user = CurrentUserExtension(
userId: 1001,
userName: '小明',
userPoints: 88.5,
levelName: '青铜',
avatar: 'https://x/a.png',
className: '高三(1)班',
classId: 12,
gradeLevel: '高三',
graduationYear: 2026,
targetConfigId: 3,
targetConfigName: '目标A',
userAvatarStatus: 1,
userAvatarReason: '待审核',
isShowGuide: true,
isShowReport: true,
id: 99,
);
final roundTrip = CurrentUserExtension.fromJson(user.toJson());
expect(roundTrip.userId, user.userId);
expect(roundTrip.userName, user.userName);
expect(roundTrip.userPoints, user.userPoints);
expect(roundTrip.levelName, user.levelName);
expect(roundTrip.className, user.className);
expect(roundTrip.isShowGuide, user.isShowGuide);
expect(roundTrip.userAvatarReason, user.userAvatarReason);
});
});
group('UserPointLevel.fromJson', () {
test('数字字段解析 + 默认值兜底', () {
final level = UserPointLevel.fromJson({
'userId': 1001,
'totalPoints': 500,
'level': 2,
});
expect(level.userId, 1001);
expect(level.totalPoints, 500);
expect(level.level, 2);
expect(level.levelUpPoints, 0); // 缺字段走默认值
final empty = UserPointLevel.fromJson({});
expect(empty.totalPoints, 0);
expect(empty.level, 0);
});
});
}
+41
View File
@@ -0,0 +1,41 @@
// 冒烟测试:验证 App 根组件能正常构建 + 登录守卫行为
//
// 为什么不是直接断言 4 个 tab 文案:
// TabApp 的 go_router 带登录守卫(app_router.dart _guardLogin),
// 测试进程里没有 token → 访问 / 被 redirect 到 /login,
// 所以首帧渲染的是登录页而不是主框架。
//
// 测试要点:
// 1. app.dart 实际在 lib/core/ 下(旧路径 package:new_learn/app.dart 是错的);
// 2. TabApp 内部有 ConsumerWidget(登录页用了 ref),必须包 ProviderScope;
// 3. 登录页 initState 异步读"记住的账号/密码",依赖两个本地存储插件,
// 测试环境没有原生插件,必须先 mock(否则抛 MissingPluginException):
// SharedPreferences.setMockInitialValues(shared_preferences 官方)
// FlutterSecureStorage.setMockInitialValues(flutter_secure_storage 官方)
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:new_learn/core/app.dart';
void main() {
setUp(() {
// 登录页 initState 会异步读这两个插件,先给空的 mock 数据
SharedPreferences.setMockInitialValues({});
FlutterSecureStorage.setMockInitialValues({});
});
testWidgets('未登录守卫冒烟:TabApp 应重定向到登录页', (WidgetTester tester) async {
// 构建根组件并触发一帧,确认没有抛异常
await tester.pumpWidget(const ProviderScope(child: TabApp()));
await tester.pumpAndSettle();
// 守卫生效:无 token 访问 / → 被踢到 /login
expect(find.text('立即登录'), findsOneWidget);
expect(find.text('爱学蝶变'), findsOneWidget);
// 未登录时不应看到主框架的 tab 文案
expect(find.text('任务'), findsNothing);
});
}
+128
View File
@@ -0,0 +1,128 @@
// Widget 测试:登录页表单交互
//
// 测什么(都是不触网络的本地交互):
// ① 空账号点「立即登录」→ SnackBar 提示"请填写账号";
// ② 只有账号没密码 → 提示"请填写密码";
// ③ 账号密码都有但没勾协议 → 提示"请先阅读并同意《用户协议》和《隐私政策》";
// ④ 密码"眼睛"点击 → obscureText 在打码/明文间切换;
// ⑤ 勾选协议 → 未勾选图标消失(协议状态真的变了)。
//
// 为什么能跑:
// 登录页校验顺序是"先本地校验 → 再调接口"(login_page.dart _handleLogin),
// ①②③ 在调接口之前就 return,完全不碰网络/Dio。
// initState 异步读"记住的账号/密码"要 mock 两个存储插件(同 widget_test.dart)。
// LoginPage 用了 .w/.h/.sp → 必须包 ScreenUtilInit;用了 ref → 包 ProviderScope。
//
// 运行:flutter test test/widgets/login_form_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:new_learn/features/auth/login_page.dart';
/// 按登录页真实运行环境 pump(ScreenUtilInit + ProviderScope + MaterialApp)
Future<void> pumpLoginPage(WidgetTester tester) async {
// ⚠ 测试默认窗口是 800x600,ScreenUtil 会把 375 设计稿放大 ~2.1 倍,
// 登录页元素全部溢出屏幕外:find.text 找得到,但 tap 命中测试点不到。
// 先把手持窗口设成手机尺寸(逻辑 375x812,dpr=1),缩放比例回到 1。
tester.view.physicalSize = const Size(375, 812);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
ProviderScope(
child: ScreenUtilInit(
designSize: const Size(375, 812),
builder: (context, child) => const MaterialApp(home: LoginPage()),
),
),
);
await tester.pumpAndSettle();
}
/// 推进 3 秒走完 SnackBar 的 2s 计时器 + 退场动画(防测试结束时报 pending timer)
Future<void> flushSnackBar(WidgetTester tester) async {
await tester.pump(const Duration(seconds: 3));
await tester.pumpAndSettle();
}
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
FlutterSecureStorage.setMockInitialValues({});
});
testWidgets('① 空账号点登录 → 提示"请填写账号"', (WidgetTester tester) async {
await pumpLoginPage(tester);
await tester.tap(find.text('立即登录'));
await tester.pump(); // SnackBar 入场动画第一帧
expect(find.text('请填写账号'), findsOneWidget);
await flushSnackBar(tester);
});
testWidgets('② 只有账号没密码 → 提示"请填写密码"', (WidgetTester tester) async {
await pumpLoginPage(tester);
await tester.enterText(find.byType(TextField).first, '13800138000');
await tester.tap(find.text('立即登录'));
await tester.pump();
expect(find.text('请填写密码'), findsOneWidget);
await flushSnackBar(tester);
});
testWidgets('③ 账号密码齐全但没勾协议 → 提示先阅读并同意协议', (WidgetTester tester) async {
await pumpLoginPage(tester);
await tester.enterText(find.byType(TextField).first, '13800138000');
await tester.enterText(find.byType(TextField).at(1), '123456');
await tester.tap(find.text('立即登录'));
await tester.pump();
expect(find.text('请先阅读并同意《用户协议》和《隐私政策》'), findsOneWidget);
await flushSnackBar(tester);
});
testWidgets('④ 密码"眼睛"点击 → obscureText 在打码/明文间切换', (WidgetTester tester) async {
await pumpLoginPage(tester);
final passwordField = find.byType(TextField).at(1);
// 初始:密码打码
expect(tester.widget<TextField>(passwordField).obscureText, isTrue);
// 点击"眼睛"图标 → 明文
await tester.tap(find.byIcon(Icons.visibility_off_outlined));
await tester.pump();
expect(tester.widget<TextField>(passwordField).obscureText, isFalse);
// 图标本身也切换了
expect(find.byIcon(Icons.visibility_outlined), findsOneWidget);
// 再点一次 → 回到打码
await tester.tap(find.byIcon(Icons.visibility_outlined));
await tester.pump();
expect(tester.widget<TextField>(passwordField).obscureText, isTrue);
});
testWidgets('⑤ 勾选协议 → 未勾选图标消失', (WidgetTester tester) async {
await pumpLoginPage(tester);
// 初始:记住密码默认勾选(check_circle),协议未勾选(radio_button_unchecked)
final agreementCheckbox = find.byIcon(Icons.radio_button_unchecked);
expect(agreementCheckbox, findsOneWidget);
// 协议区在页面底部,SingleChildScrollView 内容超出 812 视口,
// 直接 tap 会点到屏幕外 → 先 ensureVisible 滚到可见再点
await tester.ensureVisible(agreementCheckbox);
await tester.tap(agreementCheckbox);
await tester.pump();
// 协议已勾选 → 页面上不再有未勾选图标
expect(find.byIcon(Icons.radio_button_unchecked), findsNothing);
});
}