Files
patbond-flutter/test/features/pets/care_reminders_page_test.dart
T
lixi 6038901099 修复:Material 内置组件中文化——挂 zh-CN 三件套 delegate(M3.5-01)
用户 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>
2026-09-10 17:59:21 +08:00

357 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 dismissedcompletedAt 键缺席。
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('viewercanWrite=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);
});
}