6038901099
用户 Linux 桌面实测反馈「日期选择器全英文」。根因是 MaterialApp 从一迭代
建起就没配 localizationsDelegates/supportedLocales/locale,pubspec 也没有
flutter_localizations;Flutter 在缺 delegate 时静默回退内置的
DefaultMaterialLocalizations(只有英文),于是业务自绘文案全中文、Material
内置组件全英文,同一弹窗里中英混排:
Select date → 选择日期
OK / Cancel → 确定 / 取消
Enter Date → 输入日期
Switch to input → 切换到输入模式
Invalid format. / Out of range. → 格式无效。/ 超出范围。
mm/dd/yyyy → yyyy/mm/dd(zh 顺序年在前)
September 2026 → 2026年9月
处置:
- pubspec 加 flutter_localizations(sdk)与 intl(日期符号底座,显式直接
依赖以锁定与 SDK 一致的版本)。
- 新增 lib/app/app_localization.dart 把 Global{Material,Cupertino,Widgets}
三件套 delegate、supportedLocales、locale 收在一处常量:widget 测试若只
pumpWidget(MaterialApp(home: ...)) 而不挂 delegate,测到的「中文」是假的,
测试直接引用同一份常量,生产与测试不会各配一套而漂移。
- 单语言 zh-CN,不列 en:避免设备语言为英文时回退英文,再造混排。
- pets 域三份 widget 测试的 MaterialApp 同步挂上 delegate,
tap(find.text('OK')) 相应改 '确定'——让测试与生产一致,而不是在英文兜底下测。
新增 test/app/app_localization_test.dart 守住这一行不被回删:断言根
MaterialApp 实际挂上三件套 + locale zh-CN,并从运行期 MaterialLocalizations
取回中文文案。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
357 lines
12 KiB
Dart
357 lines
12 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||
import 'package:patbond_flutter/app/app_localization.dart';
|
||
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
||
import 'package:patbond_flutter/core/widgets/empty_state_illustration.dart';
|
||
import 'package:patbond_flutter/features/pets/care_reminder_form_page.dart';
|
||
import 'package:patbond_flutter/features/pets/care_reminders_page.dart';
|
||
import 'package:patbond_flutter/features/pets/health_record_analytics.dart';
|
||
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||
import 'package:patbond_flutter/widgets/common.dart';
|
||
|
||
import '../../helpers/pet_test_helpers.dart';
|
||
|
||
void main() {
|
||
late FakePetsRepository repository;
|
||
late List<(String, Map<String, dynamic>?)> events;
|
||
late HealthRecordAnalytics analytics;
|
||
|
||
setUp(() {
|
||
repository = FakePetsRepository();
|
||
events = [];
|
||
analytics = HealthRecordAnalytics(
|
||
(name, [props]) async => events.add((name, props)),
|
||
);
|
||
});
|
||
|
||
List<Map<String, dynamic>?> eventsOf(String name) => [
|
||
for (final e in events)
|
||
if (e.$1 == name) e.$2,
|
||
];
|
||
|
||
Future<void> pumpPage(WidgetTester tester, {bool canWrite = true}) async {
|
||
tester.view.physicalSize = const Size(700, 1600);
|
||
tester.view.devicePixelRatio = 1.0;
|
||
addTearDown(tester.view.reset);
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
theme: buildAppTheme(),
|
||
// M3.5-01:与生产一致挂 zh-CN delegate,日期选择器等内置组件文案
|
||
// 才是用户真机看到的中文。
|
||
localizationsDelegates: appLocalizationsDelegates,
|
||
supportedLocales: appSupportedLocales,
|
||
locale: appLocale,
|
||
home: const Scaffold(body: Text('详情基底')),
|
||
),
|
||
);
|
||
final navigator = tester.state<NavigatorState>(find.byType(Navigator));
|
||
unawaited(
|
||
navigator.push(
|
||
MaterialPageRoute<void>(
|
||
builder: (_) => CareRemindersPage(
|
||
repository: repository,
|
||
petId: 'p-1',
|
||
canWrite: canWrite,
|
||
analytics: analytics,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
}
|
||
|
||
/// 相对当前时刻的待办样本(逾期判定依赖真实 now)。
|
||
CareReminder pendingIn(
|
||
String id,
|
||
Duration offset, {
|
||
Map<String, Object?> overrides = const {},
|
||
}) => buildReminder(
|
||
id,
|
||
overrides: {
|
||
'dueAt': DateTime.now().add(offset).toUtc().toIso8601String(),
|
||
...overrides,
|
||
},
|
||
);
|
||
|
||
testWidgets('四态 · ready:类型/时间/状态齐备,逾期待办红标;viewed 一次', (tester) async {
|
||
repository.listCareRemindersHandler = (petId, status) async => [
|
||
pendingIn(
|
||
'r-1',
|
||
const Duration(days: -3),
|
||
overrides: {'title': '体内外驱虫', 'reminderType': 'deworming'},
|
||
),
|
||
pendingIn('r-2', const Duration(days: 5)),
|
||
buildReminder(
|
||
'r-3',
|
||
overrides: {
|
||
'title': '疫苗加强针',
|
||
'reminderType': 'medication',
|
||
'status': 'completed',
|
||
'completedAt': '2026-09-02T10:00:00+08:00',
|
||
},
|
||
),
|
||
];
|
||
|
||
await pumpPage(tester);
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('体内外驱虫'), findsOneWidget);
|
||
expect(find.text('年度体检'), findsOneWidget);
|
||
expect(find.text('疫苗加强针'), findsOneWidget);
|
||
// 逾期视觉标识(工单硬项):过期待办标「已逾期」,未到期标「待办」。
|
||
expect(find.widgetWithText(TagPill, '已逾期'), findsOneWidget);
|
||
expect(find.widgetWithText(TagPill, '待办'), findsOneWidget);
|
||
expect(find.widgetWithText(TagPill, '已完成'), findsOneWidget);
|
||
// 待办行有完成/忽略动作,终态行没有。
|
||
expect(find.text('标记完成'), findsNWidgets(2));
|
||
expect(find.text('忽略'), findsNWidgets(2));
|
||
|
||
final viewed = eventsOf('health_record_viewed');
|
||
expect(viewed.single, {'recordType': 'reminder', 'source': 'pet_detail'});
|
||
});
|
||
|
||
testWidgets('四态 · loading / empty:空态插画 + CTA;过滤空态无 CTA', (tester) async {
|
||
final completer = Completer<List<CareReminder>>();
|
||
final captured = <CareReminderStatus?>[];
|
||
repository.listCareRemindersHandler = (petId, status) {
|
||
captured.add(status);
|
||
if (captured.length == 1) return completer.future;
|
||
return Future.value(const []);
|
||
};
|
||
|
||
await pumpPage(tester);
|
||
await tester.pump();
|
||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||
|
||
completer.complete(const []);
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.byType(EmptyStateIllustration), findsOneWidget);
|
||
expect(find.text('还没有照护提醒'), findsOneWidget);
|
||
expect(find.text('添加第一条'), findsOneWidget);
|
||
|
||
// 切「已完成」过滤:status 参数透传服务端;过滤空态不给 CTA。
|
||
await tester.tap(find.text('已完成'));
|
||
await tester.pumpAndSettle();
|
||
expect(captured, [null, CareReminderStatus.completed]);
|
||
expect(find.text('暂无「已完成」提醒'), findsOneWidget);
|
||
expect(find.text('添加第一条'), findsNothing);
|
||
});
|
||
|
||
testWidgets('四态 · error/retry:横幅 + 重试恢复', (tester) async {
|
||
var calls = 0;
|
||
repository.listCareRemindersHandler = (petId, status) async {
|
||
calls++;
|
||
if (calls == 1) throw const ApiNetworkException('断网');
|
||
return [pendingIn('r-1', const Duration(days: 5))];
|
||
};
|
||
|
||
await pumpPage(tester);
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('网络异常,请检查网络后重试'), findsOneWidget);
|
||
|
||
await tester.tap(find.text('重试'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('年度体检'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('创建闭环:CTA → 表单(record_form 路由名)→ 请求形状与事件 → 成功重拉', (tester) async {
|
||
var listCalls = 0;
|
||
repository.listCareRemindersHandler = (petId, status) async {
|
||
listCalls++;
|
||
return listCalls == 1
|
||
? const []
|
||
: [
|
||
pendingIn(
|
||
'r-new',
|
||
const Duration(days: 30),
|
||
overrides: {'title': '体内外驱虫'},
|
||
),
|
||
];
|
||
};
|
||
CreateCareReminderRequest? captured;
|
||
repository.createCareReminderHandler = (petId, request) async {
|
||
captured = request;
|
||
return pendingIn(
|
||
'r-new',
|
||
const Duration(days: 30),
|
||
overrides: {'title': '体内外驱虫'},
|
||
);
|
||
};
|
||
|
||
await pumpPage(tester);
|
||
await tester.pumpAndSettle();
|
||
|
||
await tester.tap(find.text('添加第一条'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.byType(CareReminderFormPage), findsOneWidget);
|
||
final route = ModalRoute.of(
|
||
tester.element(find.byType(CareReminderFormPage)),
|
||
)!;
|
||
expect(route.settings.name, 'record_form');
|
||
|
||
// 类型必选 + 标题必填 + 到期日期必选:先空提交拦截。
|
||
await tester.tap(find.text('保存提醒'));
|
||
await tester.pump();
|
||
expect(find.text('请选择提醒类型'), findsOneWidget);
|
||
expect(find.text('请输入提醒内容'), findsOneWidget);
|
||
expect(find.text('请选择到期日期'), findsOneWidget);
|
||
|
||
await tester.tap(find.text('驱虫'));
|
||
await tester.pump();
|
||
await tester.enterText(
|
||
find.widgetWithText(TextFormField, '提醒内容(如:体内外驱虫)'),
|
||
'体内外驱虫',
|
||
);
|
||
await tester.tap(find.text('到期日期'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.text('确定'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.text('保存提醒'));
|
||
await tester.pumpAndSettle();
|
||
|
||
final json = captured!.toJson();
|
||
expect(json['reminderType'], 'deworming');
|
||
expect(json['title'], '体内外驱虫');
|
||
expect(json['dueAt'], endsWith('Z'));
|
||
expect(json.containsKey('status'), isFalse);
|
||
|
||
expect(find.byType(CareReminderFormPage), findsNothing);
|
||
expect(find.text('已添加提醒'), findsOneWidget);
|
||
expect(listCalls, 2);
|
||
|
||
// 创建漏斗事件(recordType=reminder)。
|
||
final started = eventsOf('health_record_create_started');
|
||
expect(started.single, {
|
||
'recordType': 'reminder',
|
||
'entryPoint': 'record_list',
|
||
});
|
||
final failed = eventsOf('health_record_create_failed');
|
||
expect(failed.single!['failureReason'], 'validation_error');
|
||
final succeeded = eventsOf('health_record_create_succeeded');
|
||
expect(succeeded.single!['recordType'], 'reminder');
|
||
});
|
||
|
||
testWidgets('标记完成:completedAt 必带(UTC);忽略:禁带 completedAt;成功重拉', (
|
||
tester,
|
||
) async {
|
||
var listCalls = 0;
|
||
repository.listCareRemindersHandler = (petId, status) async {
|
||
listCalls++;
|
||
return [
|
||
pendingIn('r-1', const Duration(days: 5)),
|
||
pendingIn(
|
||
'r-2',
|
||
const Duration(days: 9),
|
||
overrides: {'title': '体内外驱虫'},
|
||
),
|
||
];
|
||
};
|
||
final captured = <(String, Map<String, Object?>)>[];
|
||
repository.updateCareReminderHandler = (reminderId, request) async {
|
||
captured.add((reminderId, request.toJson()));
|
||
return buildReminder(
|
||
reminderId,
|
||
overrides: {
|
||
'status': request.status.name,
|
||
'completedAt': request.completedAt?.toIso8601String(),
|
||
},
|
||
);
|
||
};
|
||
|
||
await pumpPage(tester);
|
||
await tester.pumpAndSettle();
|
||
|
||
// 完成 r-1:对话框(默认今天)确认 → PATCH completed + completedAt。
|
||
await tester.tap(find.text('标记完成').first);
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.text('确认完成'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(captured.length, 1);
|
||
expect(captured[0].$1, 'r-1');
|
||
expect(captured[0].$2['status'], 'completed');
|
||
expect(captured[0].$2['completedAt'], endsWith('Z'));
|
||
expect(find.text('已标记完成'), findsOneWidget);
|
||
expect(listCalls, 2);
|
||
|
||
// 忽略 r-2:确认对话框 → PATCH dismissed,completedAt 键缺席。
|
||
await tester.tap(find.text('忽略').last);
|
||
await tester.pumpAndSettle();
|
||
expect(find.text('忽略这条提醒?'), findsOneWidget);
|
||
// 对话框主按钮文案与动作同名,用 FilledButton 定位。
|
||
await tester.tap(find.widgetWithText(FilledButton, '忽略'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(captured.length, 2);
|
||
expect(captured[1].$2, {'status': 'dismissed'});
|
||
expect(listCalls, 3);
|
||
// 提醒完成/忽略不埋事件(06 §7 缺口 3 既定取舍)。
|
||
expect(eventsOf('health_record_edit_succeeded'), isEmpty);
|
||
});
|
||
|
||
testWidgets('42202 规则兜底:提示 + 重拉', (tester) async {
|
||
var listCalls = 0;
|
||
repository.listCareRemindersHandler = (petId, status) async {
|
||
listCalls++;
|
||
return [pendingIn('r-1', const Duration(days: 5))];
|
||
};
|
||
repository.updateCareReminderHandler = (reminderId, request) async {
|
||
throw const CareReminderRuleException(message: '规则违反');
|
||
};
|
||
|
||
await pumpPage(tester);
|
||
await tester.pumpAndSettle();
|
||
|
||
await tester.tap(find.text('标记完成'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.text('确认完成'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('提醒状态不满足流转规则,已刷新,请重试'), findsOneWidget);
|
||
expect(listCalls, 2);
|
||
});
|
||
|
||
testWidgets('40902 并发流转抢先:提示已被处理 + 重拉', (tester) async {
|
||
var listCalls = 0;
|
||
repository.listCareRemindersHandler = (petId, status) async {
|
||
listCalls++;
|
||
return [pendingIn('r-1', const Duration(days: 5))];
|
||
};
|
||
repository.updateCareReminderHandler = (reminderId, request) async {
|
||
throw const PetVersionConflictException(message: '数据已被修改');
|
||
};
|
||
|
||
await pumpPage(tester);
|
||
await tester.pumpAndSettle();
|
||
|
||
await tester.tap(find.text('标记完成'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.text('确认完成'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('提醒已在其他设备被处理,已刷新'), findsOneWidget);
|
||
expect(listCalls, 2);
|
||
});
|
||
|
||
testWidgets('viewer(canWrite=false):无添加入口、无完成/忽略动作、空态无 CTA', (tester) async {
|
||
repository.listCareRemindersHandler = (petId, status) async => [
|
||
pendingIn('r-1', const Duration(days: 5)),
|
||
];
|
||
|
||
await pumpPage(tester, canWrite: false);
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.byIcon(Icons.add), findsNothing);
|
||
expect(find.text('标记完成'), findsNothing);
|
||
expect(find.text('忽略'), findsNothing);
|
||
});
|
||
}
|