flutter架 构项目基础
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user