feat: 二级页面色彩刷新 + 用药/通知/设备重构 + 后端健康档案/通知管线增强 + 大量测试
## 后端 - 健康档案: 新增手术状态字段 + EF 迁移; HealthArchiveService 新增查询方法 - 健康记录: HealthRecordService 新增批量/统计方法; 契约扩展 - 用药: 新增 MedicationScheduleStatus 枚举; MedicationService 排班逻辑调整 - 通知: EfUserNotificationPipeline 重构; 新增 EfReminderCatchUpService; 通知管线支持更多场景 - 用户: UserService 账号删除逻辑; 新增 local_account_file_cleanup; EfUserRepository 扩展 - AI: medication_agent_handler 微调; prompt_manager 优化; AiConversationService 上下文处理 - Endpoint: doctor/medication/exercise/health/notification/user 等多接口调整 - BackgroundService: health_record_reminder_service 重构, 提醒补漏逻辑 - 测试: 新增 account_deletion/doctor_endpoint/medication_schedule/medication_update/prompt_manager 测试 ## 前端 - UI 系统: app_theme 大幅重构; app_colors/app_design_tokens/app_module_visuals 调整; 二级页面色彩刷新 - 主页: home_page 背景渐变 + 消息列表提取 _HomeMessages + 通知检查逻辑; chat_messages_view 全面重构 - 用药: medication_list/edit/checkin 三页重构, 新增 medication_ui_logic 抽取 - 通知: notification_prefs_page 重构, 新增 notification_prefs_logic; notification_center 优化 - 设备: device_management 重构, 新增 device_sync_ui_logic; device_scan 优化 - 趋势图: trend_page 大幅重构 - 登录: login_page 重构 - 个人资料: 新增 profile_edit_page; profile_page 优化 - 运动: 新增 exercise/ 目录 + care_plan_ui_logic - 其他: remaining_pages/report_pages/health_drawer/admin/doctor 等多页面调整 - 组件: common_widgets/app_empty_state/app_error_state/app_future_view/app_toast/ai_content 优化 - Provider: chat_provider/consultation_provider/data_providers/auth_provider 调整 - AndroidManifest: 移除多余权限 - 测试: 新增 ai_content/care_plan/home_message/login_flow/medication_checkin/medication_ui/notification_prefs/profile_device/secondary_page/swipe_delete 等大量测试 ## 文档 - 新增 ui-design-system.md 设计系统文档 - 新增 secondary-page-color-refresh 计划 + specs 目录
This commit is contained in:
20
health_app/test/ai_content_test.dart
Normal file
20
health_app/test/ai_content_test.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:health_app/widgets/ai_content.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('AI generated note shows text without an icon', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(home: Scaffold(body: AiGeneratedNote())),
|
||||
);
|
||||
|
||||
expect(find.text('内容由 AI 生成'), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(AiGeneratedNote),
|
||||
matching: find.byType(Icon),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:health_app/core/app_router.dart';
|
||||
import 'package:health_app/core/navigation_provider.dart';
|
||||
import 'package:health_app/pages/report/report_pages.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
@@ -24,4 +25,26 @@ void main() {
|
||||
expect(find.text('页面参数错误'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('report upload route opens report manager upload options', (
|
||||
tester,
|
||||
) async {
|
||||
late Widget page;
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
child: Consumer(
|
||||
builder: (context, ref, _) {
|
||||
page = buildPage(
|
||||
const RouteInfo('reports', params: {'openUpload': 'true'}),
|
||||
ref,
|
||||
);
|
||||
return const MaterialApp(home: SizedBox());
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(page, isA<ReportListPage>());
|
||||
expect((page as ReportListPage).openUploadOnEnter, isTrue);
|
||||
});
|
||||
}
|
||||
|
||||
74
health_app/test/audit_fix_guardrails_test.dart
Normal file
74
health_app/test/audit_fix_guardrails_test.dart
Normal file
@@ -0,0 +1,74 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:health_app/pages/history/conversation_history_page.dart';
|
||||
import 'package:health_app/pages/notifications/notification_center_page.dart';
|
||||
|
||||
void main() {
|
||||
test('notification opening continues when acknowledge fails', () async {
|
||||
var opened = false;
|
||||
|
||||
final acknowledged = await acknowledgeBeforeNotificationOpen(
|
||||
acknowledge: () async => throw Exception('offline'),
|
||||
open: () => opened = true,
|
||||
);
|
||||
|
||||
expect(acknowledged, isFalse);
|
||||
expect(opened, isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'conversation dismiss is rejected when backend deletion fails',
|
||||
() async {
|
||||
expect(
|
||||
await deleteConversationForDismiss(
|
||||
() async => throw Exception('delete failed'),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(await deleteConversationForDismiss(() async {}), isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test('doctor picker does not keep a stale list for the whole app session', () {
|
||||
final source = File('lib/providers/data_providers.dart').readAsStringSync();
|
||||
final adminSource = File(
|
||||
'lib/pages/admin/admin_add_doctor_page.dart',
|
||||
).readAsStringSync();
|
||||
|
||||
expect(
|
||||
source,
|
||||
matches(
|
||||
RegExp(
|
||||
r'final doctorListProvider\s*=\s*FutureProvider\.autoDispose<List<Map<String, dynamic>>>',
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(adminSource, contains('ref.invalidate(doctorListProvider)'));
|
||||
});
|
||||
|
||||
test('home header buttons use a larger touch area', () {
|
||||
final source = File('lib/pages/home/home_page.dart').readAsStringSync();
|
||||
final buttonSource = source.substring(
|
||||
source.indexOf('class _HeaderIconButton'),
|
||||
);
|
||||
|
||||
expect(buttonSource, contains('width: 44'));
|
||||
expect(buttonSource, contains('height: 44'));
|
||||
});
|
||||
|
||||
test('settings logout action is white with dark text', () {
|
||||
final source = File(
|
||||
'lib/pages/settings/settings_pages.dart',
|
||||
).readAsStringSync();
|
||||
final logoutButtonStart = source.indexOf('ElevatedButton.icon(');
|
||||
final logoutButtonEnd = source.indexOf(
|
||||
'Future<void> _deleteAccount',
|
||||
logoutButtonStart,
|
||||
);
|
||||
final logoutButton = source.substring(logoutButtonStart, logoutButtonEnd);
|
||||
|
||||
expect(logoutButton, contains('backgroundColor: Colors.white'));
|
||||
expect(logoutButton, contains('foregroundColor: AppColors.textPrimary'));
|
||||
});
|
||||
}
|
||||
86
health_app/test/care_plan_ui_logic_test.dart
Normal file
86
health_app/test/care_plan_ui_logic_test.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:health_app/pages/care_plan_ui_logic.dart';
|
||||
|
||||
void main() {
|
||||
group('care plan lifecycle', () {
|
||||
final today = DateTime(2026, 7, 13);
|
||||
|
||||
test('separates active, upcoming, and ended records', () {
|
||||
expect(
|
||||
resolveCarePlanPhase(
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-20',
|
||||
today: today,
|
||||
),
|
||||
CarePlanPhase.active,
|
||||
);
|
||||
expect(
|
||||
resolveCarePlanPhase(
|
||||
startDate: '2026-07-14',
|
||||
endDate: '2026-07-20',
|
||||
today: today,
|
||||
),
|
||||
CarePlanPhase.upcoming,
|
||||
);
|
||||
expect(
|
||||
resolveCarePlanPhase(
|
||||
startDate: '2026-06-01',
|
||||
endDate: '2026-07-12',
|
||||
today: today,
|
||||
),
|
||||
CarePlanPhase.ended,
|
||||
);
|
||||
});
|
||||
|
||||
test('disabled medication is treated as ended', () {
|
||||
expect(
|
||||
resolveCarePlanPhase(
|
||||
enabled: false,
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-20',
|
||||
today: today,
|
||||
),
|
||||
CarePlanPhase.ended,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('medication form validation', () {
|
||||
test('requires dosage', () {
|
||||
expect(
|
||||
validateMedicationForm(
|
||||
name: '阿司匹林',
|
||||
dosage: '',
|
||||
times: const ['08:00'],
|
||||
startDate: DateTime(2026, 7, 13),
|
||||
),
|
||||
'请输入服药剂量',
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects duplicate dose times', () {
|
||||
expect(
|
||||
validateMedicationForm(
|
||||
name: '阿司匹林',
|
||||
dosage: '100mg',
|
||||
times: const ['08:00', '08:00'],
|
||||
startDate: DateTime(2026, 7, 13),
|
||||
),
|
||||
'服药时间不能重复',
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects an end date before the start date', () {
|
||||
expect(
|
||||
validateMedicationForm(
|
||||
name: '阿司匹林',
|
||||
dosage: '100mg',
|
||||
times: const ['08:00'],
|
||||
startDate: DateTime(2026, 7, 13),
|
||||
endDate: DateTime(2026, 7, 12),
|
||||
),
|
||||
'结束日期不能早于开始日期',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,35 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:health_app/providers/chat_provider.dart';
|
||||
|
||||
void main() {
|
||||
test('chat state can clear a loaded conversation id for a new chat', () {
|
||||
const state = ChatState(conversationId: 'old-conversation');
|
||||
|
||||
final next = state.copyWith(conversationId: null);
|
||||
|
||||
expect(next.conversationId, isNull);
|
||||
});
|
||||
|
||||
test('history continues with the same conversation and visible context', () {
|
||||
final chatSource = File(
|
||||
'lib/providers/chat_provider.dart',
|
||||
).readAsStringSync();
|
||||
|
||||
expect(chatSource, contains('conversationId: convId'));
|
||||
expect(
|
||||
chatSource,
|
||||
isNot(
|
||||
contains(
|
||||
'state = const ChatState();\n }\n\n'
|
||||
' Future<void> _cancelActiveStream()',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('delayed agent welcome is skipped after session reset', () async {
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:health_app/pages/diet/diet_record_logic.dart';
|
||||
|
||||
@@ -15,4 +16,15 @@ void main() {
|
||||
test('blank food draft is not valid for saving', () {
|
||||
expect(isSavableFood(name: '', portion: '', calories: 0), isFalse);
|
||||
});
|
||||
|
||||
test('diet row maps right swipe to edit and left swipe to delete', () {
|
||||
expect(
|
||||
dietRecordSwipeAction(DismissDirection.startToEnd),
|
||||
DietRecordSwipeAction.edit,
|
||||
);
|
||||
expect(
|
||||
dietRecordSwipeAction(DismissDirection.endToStart),
|
||||
DietRecordSwipeAction.delete,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
162
health_app/test/home_message_order_test.dart
Normal file
162
health_app/test/home_message_order_test.dart
Normal file
@@ -0,0 +1,162 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:health_app/pages/home/widgets/chat_messages_view.dart';
|
||||
import 'package:health_app/providers/chat_provider.dart';
|
||||
|
||||
void main() {
|
||||
test('chat messages keep their natural display order', () {
|
||||
final messages = [
|
||||
ChatMessage(
|
||||
id: 'oldest',
|
||||
role: 'user',
|
||||
content: '第一条',
|
||||
createdAt: DateTime(2026, 1, 1),
|
||||
),
|
||||
ChatMessage(
|
||||
id: 'newest',
|
||||
role: 'assistant',
|
||||
content: '最新一条',
|
||||
createdAt: DateTime(2026, 1, 2),
|
||||
),
|
||||
];
|
||||
|
||||
expect(messageAtDisplayIndex(messages, 0).id, 'oldest');
|
||||
expect(messageAtDisplayIndex(messages, 1).id, 'newest');
|
||||
});
|
||||
|
||||
test('today health card stays in the normal message stream', () {
|
||||
final taskCard = ChatMessage(
|
||||
id: 'task_card',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
createdAt: DateTime(2026, 1, 1),
|
||||
type: MessageType.taskCard,
|
||||
);
|
||||
final userMessage = ChatMessage(
|
||||
id: 'user',
|
||||
role: 'user',
|
||||
content: '你好',
|
||||
createdAt: DateTime(2026, 1, 2),
|
||||
);
|
||||
|
||||
final messages = [taskCard, userMessage];
|
||||
|
||||
expect(messageAtDisplayIndex(messages, 0).id, 'task_card');
|
||||
expect(messageAtDisplayIndex(messages, 1).id, 'user');
|
||||
});
|
||||
|
||||
test('today health keeps one cached snapshot during background refresh', () {
|
||||
final providers = File(
|
||||
'lib/providers/data_providers.dart',
|
||||
).readAsStringSync();
|
||||
final card = File(
|
||||
'lib/pages/home/widgets/chat_messages_view.dart',
|
||||
).readAsStringSync();
|
||||
final home = File('lib/pages/home/home_page.dart').readAsStringSync();
|
||||
|
||||
expect(providers, contains('todayHealthSnapshotProvider'));
|
||||
expect(
|
||||
providers,
|
||||
isNot(
|
||||
contains(
|
||||
'notificationUnreadCountProvider = FutureProvider.autoDispose',
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
providers,
|
||||
isNot(
|
||||
contains(
|
||||
'medicationReminderProvider =\n FutureProvider.autoDispose',
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
providers,
|
||||
isNot(
|
||||
contains(
|
||||
'currentExercisePlanProvider =\n FutureProvider.autoDispose',
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(card, contains('ref.watch(todayHealthSnapshotProvider)'));
|
||||
expect(card, contains('final snapshot = snapshotAsync.value'));
|
||||
expect(card, contains('正在获取今日健康'));
|
||||
expect(home, contains('_refreshTodayHealthIfCached()'));
|
||||
});
|
||||
|
||||
test('drawer keeps seven recent conversations and expands in place', () {
|
||||
final source = File('lib/widgets/health_drawer.dart').readAsStringSync();
|
||||
|
||||
expect(source, contains('static const int _previewCount = 7;'));
|
||||
expect(source, contains('bool _expanded = false;'));
|
||||
expect(source, isNot(contains("pushRoute(ref, 'conversationHistory')")));
|
||||
});
|
||||
|
||||
test('history actions stay inside the section hit-test area', () {
|
||||
final source = File('lib/widgets/health_drawer.dart').readAsStringSync();
|
||||
|
||||
expect(source, isNot(contains('top: -46')));
|
||||
expect(source, contains('trailing:'));
|
||||
});
|
||||
|
||||
test('drawer sections use spacing without horizontal divider lines', () {
|
||||
final source = File('lib/widgets/health_drawer.dart').readAsStringSync();
|
||||
|
||||
expect(source, isNot(contains('Divider(')));
|
||||
expect(source, contains('const SizedBox(height: 22)'));
|
||||
});
|
||||
|
||||
test('home drawer follows the native drag gesture', () {
|
||||
final source = File('lib/pages/home/home_page.dart').readAsStringSync();
|
||||
|
||||
expect(source, contains('drawerEnableOpenDragGesture: true'));
|
||||
expect(source, contains('drawerDragStartBehavior: DragStartBehavior.down'));
|
||||
expect(source, contains('drawerEdgeDragWidth:'));
|
||||
expect(source, isNot(contains('_drawerDragStartX')));
|
||||
expect(source, isNot(contains('onHorizontalDragUpdate:')));
|
||||
});
|
||||
|
||||
test('agent capsules use slightly larger icons and labels', () {
|
||||
final source = File('lib/pages/home/home_page.dart').readAsStringSync();
|
||||
final cards = File(
|
||||
'lib/pages/home/widgets/chat_messages_view.dart',
|
||||
).readAsStringSync();
|
||||
final drawer = File('lib/widgets/health_drawer.dart').readAsStringSync();
|
||||
|
||||
expect(source, contains('width: 18'));
|
||||
expect(source, contains('height: 18'));
|
||||
expect(source, contains('Icon(visual.icon, size: 13'));
|
||||
expect(source, contains('fontSize: 15'));
|
||||
expect(
|
||||
source,
|
||||
contains('selected ? AppColors.actionOutlineGradient : null'),
|
||||
);
|
||||
expect(source, contains('selected ? null : Colors.transparent'));
|
||||
expect(source, contains('color: Colors.white'));
|
||||
expect(cards, isNot(contains('LucideIcons.footprints')));
|
||||
expect(cards, contains('AppModuleVisuals.exercise.icon'));
|
||||
expect(drawer, contains('AppModuleVisuals.exercise.icon'));
|
||||
});
|
||||
|
||||
test('upload and send actions stay inside one input capsule', () {
|
||||
final source = File('lib/pages/home/home_page.dart').readAsStringSync();
|
||||
final input = source.substring(
|
||||
source.indexOf('Widget _buildInputBar()'),
|
||||
source.indexOf('void _showAttachmentPicker'),
|
||||
);
|
||||
|
||||
expect(input, contains('borderRadius: AppRadius.pillBorder'));
|
||||
expect(input, contains('LucideIcons.plus'));
|
||||
expect(input, contains('LucideIcons.send'));
|
||||
expect(input, contains('border: InputBorder.none'));
|
||||
expect(input, contains('crossAxisAlignment: CrossAxisAlignment.center'));
|
||||
expect(
|
||||
RegExp('gradient: AppColors.primaryGradient').allMatches(input).length,
|
||||
2,
|
||||
);
|
||||
expect(input, isNot(contains('Border.all(color: AppColors.borderLight)')));
|
||||
expect(source, isNot(contains('class _RoundToolButton')));
|
||||
});
|
||||
}
|
||||
40
health_app/test/login_flow_guardrails_test.dart
Normal file
40
health_app/test/login_flow_guardrails_test.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
final loginSource = File('lib/pages/auth/login_page.dart').readAsStringSync();
|
||||
final appSource = File('lib/app.dart').readAsStringSync();
|
||||
|
||||
test('login validates fields before asking for agreement', () {
|
||||
final submit = loginSource.substring(
|
||||
loginSource.indexOf('Future<void> _submit()'),
|
||||
loginSource.indexOf('Future<bool?> _showAgreementDialog()'),
|
||||
);
|
||||
expect(
|
||||
submit.indexOf("_error = '请输入手机号和验证码'"),
|
||||
lessThan(submit.indexOf('_showAgreementDialog()')),
|
||||
);
|
||||
});
|
||||
|
||||
test('registration keeps the authenticated session', () {
|
||||
final submit = loginSource.substring(
|
||||
loginSource.indexOf('Future<void> _submit()'),
|
||||
loginSource.indexOf('Future<bool?> _showAgreementDialog()'),
|
||||
);
|
||||
expect(submit, isNot(contains('authProvider.notifier).logout()')));
|
||||
});
|
||||
|
||||
test('boot gate stays visible until authenticated route changes', () {
|
||||
expect(appSource, contains("currentRoute == 'login'"));
|
||||
});
|
||||
|
||||
test('keyboard moves the foreground without resizing the background', () {
|
||||
expect(loginSource, contains('resizeToAvoidBottomInset: false'));
|
||||
expect(loginSource, contains('MediaQuery.viewInsetsOf(context).bottom'));
|
||||
});
|
||||
|
||||
test('development sms code is only filled in debug builds', () {
|
||||
expect(loginSource, contains('kDebugMode && result.devCode != null'));
|
||||
});
|
||||
}
|
||||
27
health_app/test/medication_checkin_logic_test.dart
Normal file
27
health_app/test/medication_checkin_logic_test.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:health_app/pages/medication/medication_checkin_page.dart';
|
||||
|
||||
void main() {
|
||||
test('same-name medications remain separate check-in groups', () {
|
||||
final groups = groupMedicationReminders([
|
||||
{
|
||||
'id': 'med-a',
|
||||
'name': '阿司匹林',
|
||||
'dosage': '100 mg',
|
||||
'scheduledTime': '08:00',
|
||||
'status': 'taken',
|
||||
},
|
||||
{
|
||||
'id': 'med-b',
|
||||
'name': '阿司匹林',
|
||||
'dosage': '50 mg',
|
||||
'scheduledTime': '08:00',
|
||||
'status': 'upcoming',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(groups.keys, containsAll(['med-a', 'med-b']));
|
||||
expect(groups['med-a']!.single['status'], 'taken');
|
||||
expect(groups['med-b']!.single['status'], 'upcoming');
|
||||
});
|
||||
}
|
||||
29
health_app/test/medication_ui_logic_test.dart
Normal file
29
health_app/test/medication_ui_logic_test.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:health_app/pages/medication/medication_ui_logic.dart';
|
||||
|
||||
void main() {
|
||||
test('medication list dates omit the current year', () {
|
||||
final now = DateTime(2026, 7, 14);
|
||||
|
||||
expect(formatMedicationPeriod('2026-07-14', null, now: now), '07.14 - 长期');
|
||||
expect(
|
||||
formatMedicationPeriod('2026-07-13', '2026-07-14', now: now),
|
||||
'07.13 - 07.14',
|
||||
);
|
||||
});
|
||||
|
||||
test('medication list dates keep years when the range crosses years', () {
|
||||
expect(
|
||||
formatMedicationPeriod(
|
||||
'2026-12-20',
|
||||
'2027-01-15',
|
||||
now: DateTime(2026, 7, 14),
|
||||
),
|
||||
'2026.12.20 - 2027.01.15',
|
||||
);
|
||||
});
|
||||
|
||||
test('form dates use hyphens and keep the year', () {
|
||||
expect(formatMedicationFormDate(DateTime(2026, 7, 14)), '2026-07-14');
|
||||
});
|
||||
}
|
||||
67
health_app/test/notification_prefs_logic_test.dart
Normal file
67
health_app/test/notification_prefs_logic_test.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:health_app/pages/settings/notification_prefs_logic.dart';
|
||||
|
||||
void main() {
|
||||
test('notification preferences parse the complete backend payload', () {
|
||||
final prefs = NotificationPrefs.fromJson({
|
||||
'pushEnabled': false,
|
||||
'medicationReminder': false,
|
||||
'followUpReminder': true,
|
||||
'doctorReply': true,
|
||||
'abnormalAlert': false,
|
||||
'dndEnabled': true,
|
||||
'dndStartMinutes': 1200,
|
||||
'dndEndMinutes': 420,
|
||||
'healthRecordReminder': true,
|
||||
'healthRecordReminderBloodPressure': true,
|
||||
'healthRecordReminderHeartRate': false,
|
||||
'healthRecordReminderGlucose': true,
|
||||
'healthRecordReminderSpO2': false,
|
||||
'healthRecordReminderWeight': false,
|
||||
});
|
||||
|
||||
expect(prefs.pushEnabled, false);
|
||||
expect(prefs.doctorReply, true);
|
||||
expect(prefs.dndStartMinutes, 1200);
|
||||
expect(prefs.enabledHealthMetrics, {
|
||||
HealthReminderMetric.bloodPressure,
|
||||
HealthReminderMetric.glucose,
|
||||
});
|
||||
});
|
||||
|
||||
test('metric summary remains compact and readable', () {
|
||||
expect(
|
||||
healthMetricSummary({
|
||||
HealthReminderMetric.bloodPressure,
|
||||
HealthReminderMetric.heartRate,
|
||||
}),
|
||||
'血压、心率',
|
||||
);
|
||||
expect(healthMetricSummary(HealthReminderMetric.values.toSet()), '全部指标');
|
||||
expect(healthMetricSummary({}), '至少选择一项');
|
||||
});
|
||||
|
||||
test('metric payload sends all five fields in one request', () {
|
||||
final payload = healthMetricUpdatePayload({
|
||||
HealthReminderMetric.bloodPressure,
|
||||
HealthReminderMetric.spo2,
|
||||
});
|
||||
|
||||
expect(payload.length, 5);
|
||||
expect(payload['healthRecordReminderBloodPressure'], true);
|
||||
expect(payload['healthRecordReminderHeartRate'], false);
|
||||
expect(payload['healthRecordReminderSpO2'], true);
|
||||
});
|
||||
|
||||
test('notification settings uses a batch metric bottom sheet', () {
|
||||
final source = File(
|
||||
'lib/pages/settings/notification_prefs_page.dart',
|
||||
).readAsStringSync();
|
||||
|
||||
expect(source, contains('showModalBottomSheet'));
|
||||
expect(source, contains('saveHealthMetrics'));
|
||||
expect(source, contains('提醒指标'));
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:health_app/models/ble_device.dart';
|
||||
import 'package:health_app/pages/chart/trend_page.dart';
|
||||
@@ -11,6 +13,61 @@ void main() {
|
||||
expect(normalizeTrendMetricType('spo2'), 'spo2');
|
||||
});
|
||||
|
||||
test('trend period includes today and the requested preceding days', () {
|
||||
final now = DateTime(2026, 7, 13, 18);
|
||||
final records = [
|
||||
{'date': DateTime(2026, 7, 6, 23), 'value': 70},
|
||||
{'date': DateTime(2026, 7, 7, 0), 'value': 71},
|
||||
{'date': DateTime(2026, 7, 13, 8), 'value': 72},
|
||||
];
|
||||
|
||||
final result = filterTrendRecordsForPeriod(records, 7, now);
|
||||
|
||||
expect(result.map((record) => record['value']), [71, 72]);
|
||||
});
|
||||
|
||||
test('trend values do not show an unnecessary decimal', () {
|
||||
expect(formatTrendNumber(88.0), '88');
|
||||
expect(formatTrendNumber(88.5), '88.5');
|
||||
expect(formatTrendNumber(null), '--');
|
||||
});
|
||||
|
||||
test('blood pressure abnormal label identifies the affected value', () {
|
||||
expect(
|
||||
trendStatusLabel({
|
||||
'systolic': 113,
|
||||
'diastolic': 97,
|
||||
'isAbnormal': true,
|
||||
}, 'blood_pressure'),
|
||||
'舒张压偏高',
|
||||
);
|
||||
expect(
|
||||
trendStatusLabel({
|
||||
'systolic': 122,
|
||||
'diastolic': 89,
|
||||
'isAbnormal': false,
|
||||
}, 'blood_pressure'),
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
test('calendar month switch keeps the selected day when possible', () {
|
||||
expect(
|
||||
calendarDateForMonth(DateTime(2026, 6), DateTime(2026, 7, 13)),
|
||||
DateTime(2026, 6, 13),
|
||||
);
|
||||
expect(
|
||||
calendarDateForMonth(DateTime(2026, 2), DateTime(2026, 1, 31)),
|
||||
DateTime(2026, 2, 28),
|
||||
);
|
||||
});
|
||||
|
||||
test('health archive list input is normalized without conflicting none', () {
|
||||
expect(normalizeArchiveItems('青霉素,海鲜\n花粉'), ['青霉素', '海鲜', '花粉']);
|
||||
expect(normalizeArchiveItems('无'), ['无']);
|
||||
expect(normalizeArchiveItems('无、青霉素'), ['青霉素']);
|
||||
});
|
||||
|
||||
test('static compliance pages include collection and sdk lists', () {
|
||||
expect(staticTextTitle('personalInfoList'), '个人信息收集清单');
|
||||
expect(staticTextContent('personalInfoList'), contains('健康数据'));
|
||||
@@ -19,6 +76,19 @@ void main() {
|
||||
expect(staticTextContent('thirdPartySdkList'), contains('AI'));
|
||||
});
|
||||
|
||||
test('all in-app compliance documents contain readable Chinese', () {
|
||||
final source = File('lib/pages/remaining_pages.dart').readAsStringSync();
|
||||
final start = source.indexOf(
|
||||
'const Map<String, String> _extraStaticTextTitles',
|
||||
);
|
||||
expect(start, isNonNegative);
|
||||
final complianceSection = source.substring(start);
|
||||
|
||||
expect(complianceSection, isNot(contains('锛')));
|
||||
expect(complianceSection, isNot(contains('銆')));
|
||||
expect(complianceSection, isNot(contains('€')));
|
||||
});
|
||||
|
||||
test('ble sync is only implemented for blood pressure devices', () {
|
||||
expect(
|
||||
HealthBleService.isSyncImplemented(BleDeviceType.bloodPressure),
|
||||
|
||||
71
health_app/test/profile_device_drawer_guardrails_test.dart
Normal file
71
health_app/test/profile_device_drawer_guardrails_test.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:health_app/models/ble_device.dart';
|
||||
import 'package:health_app/pages/device/device_sync_ui_logic.dart';
|
||||
|
||||
void main() {
|
||||
test('unsupported bound devices are labelled without pretending to sync', () {
|
||||
expect(deviceSyncAvailabilityLabel(BleDeviceType.glucose), '暂不支持自动同步');
|
||||
expect(deviceSyncAvailabilityLabel(BleDeviceType.bloodPressure), isNull);
|
||||
});
|
||||
|
||||
test('device sync errors keep connection and upload failures distinct', () {
|
||||
expect(
|
||||
deviceSyncErrorMessage(
|
||||
const DeviceSyncFailure(DeviceSyncFailureStage.permission),
|
||||
),
|
||||
contains('蓝牙权限'),
|
||||
);
|
||||
expect(
|
||||
deviceSyncErrorMessage(
|
||||
const DeviceSyncFailure(DeviceSyncFailureStage.upload),
|
||||
),
|
||||
contains('上传'),
|
||||
);
|
||||
});
|
||||
|
||||
test('bound device page keeps the automatic scan indicator', () {
|
||||
final page = File(
|
||||
'lib/pages/device/device_management_page.dart',
|
||||
).readAsStringSync();
|
||||
|
||||
expect(page, contains('AnimationController('));
|
||||
expect(page, contains('child: _ScanIndicator('));
|
||||
expect(page, contains('scanning: _scanning'));
|
||||
});
|
||||
|
||||
test('profile exposes a real edit route and keeps phone read only', () {
|
||||
final profile = File(
|
||||
'lib/pages/profile/profile_page.dart',
|
||||
).readAsStringSync();
|
||||
final editor = File(
|
||||
'lib/pages/profile/profile_edit_page.dart',
|
||||
).readAsStringSync();
|
||||
final router = File('lib/core/app_router.dart').readAsStringSync();
|
||||
|
||||
expect(profile, contains("pushRoute(ref, 'profileEdit')"));
|
||||
expect(editor, contains('手机号不可修改'));
|
||||
expect(router, contains("case 'profileEdit':"));
|
||||
});
|
||||
|
||||
test(
|
||||
'drawer does not refetch history when only current conversation changes',
|
||||
() {
|
||||
final provider = File(
|
||||
'lib/providers/conversation_history_provider.dart',
|
||||
).readAsStringSync();
|
||||
final drawer = File('lib/widgets/health_drawer.dart').readAsStringSync();
|
||||
|
||||
expect(
|
||||
provider,
|
||||
isNot(
|
||||
contains(
|
||||
'ref.watch(chatProvider.select((state) => state.conversationId))',
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(drawer, contains('currentConversationId'));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -8,4 +8,15 @@ void main() {
|
||||
expect(reportAnalysisPollDelay(6), const Duration(seconds: 12));
|
||||
expect(reportAnalysisPollDelay(16), isNull);
|
||||
});
|
||||
|
||||
test('report state starts in an explicit loading phase', () {
|
||||
expect(ReportState().isLoadingReports, isTrue);
|
||||
expect(ReportState().reportsError, isNull);
|
||||
});
|
||||
|
||||
test('original report type prefers metadata and falls back to the url', () {
|
||||
expect(isPdfReport('Pdf', '/uploads/report.bin'), isTrue);
|
||||
expect(isPdfReport('Image', '/uploads/report.pdf'), isTrue);
|
||||
expect(isPdfReport('Image', '/uploads/report.jpg'), isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
212
health_app/test/secondary_page_visuals_test.dart
Normal file
212
health_app/test/secondary_page_visuals_test.dart
Normal file
@@ -0,0 +1,212 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:health_app/core/app_colors.dart';
|
||||
import 'package:health_app/core/app_design_tokens.dart';
|
||||
import 'package:health_app/core/app_theme.dart';
|
||||
import 'package:health_app/widgets/common_widgets.dart';
|
||||
import 'package:health_app/widgets/app_empty_state.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('secondary page separates neutral canvas from white app bar', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.lightTheme,
|
||||
home: GradientScaffold(
|
||||
appBar: AppBar(title: const Text('用药管理')),
|
||||
body: const SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final scaffold = tester.widget<Scaffold>(find.byType(Scaffold));
|
||||
expect(scaffold.backgroundColor, AppColors.background);
|
||||
expect(AppTheme.lightTheme.appBarTheme.backgroundColor, Colors.white);
|
||||
});
|
||||
|
||||
testWidgets('create fab uses one white gradient-outline visual', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
floatingActionButton: AppCreateFab(tooltip: '添加用药', onPressed: () {}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final border = tester.widget<DecoratedBox>(
|
||||
find.byKey(const ValueKey('app-create-fab-border')),
|
||||
);
|
||||
final decoration = border.decoration as BoxDecoration;
|
||||
expect(decoration.gradient, AppColors.actionOutlineGradient);
|
||||
expect(decoration.borderRadius, AppRadius.pillBorder);
|
||||
expect(find.byIcon(Icons.add_rounded), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('empty state uses a flat restrained icon surface', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: AppEmptyState(
|
||||
icon: Icons.event_available_outlined,
|
||||
title: '今日没有安排',
|
||||
iconColor: AppColors.success,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final surface = tester.widget<Container>(
|
||||
find.byKey(const ValueKey('empty-state-icon-surface')),
|
||||
);
|
||||
final decoration = surface.decoration as BoxDecoration;
|
||||
expect(decoration.gradient, isNull);
|
||||
expect(decoration.boxShadow, isNull);
|
||||
});
|
||||
|
||||
test('health metrics and notification preferences use lucide icons', () {
|
||||
final trend = File('lib/pages/chart/trend_page.dart').readAsStringSync();
|
||||
final notifications = File(
|
||||
'lib/pages/settings/notification_prefs_page.dart',
|
||||
).readAsStringSync();
|
||||
|
||||
expect(trend, contains('LucideIcons.gauge'));
|
||||
expect(trend, contains('LucideIcons.heartPulse'));
|
||||
expect(notifications, contains('AppModuleVisuals.medication.icon'));
|
||||
expect(notifications, contains('LucideIcons.slidersHorizontal'));
|
||||
});
|
||||
|
||||
test(
|
||||
'login and registration keep the image background with neutral forms',
|
||||
() {
|
||||
final login = File('lib/pages/auth/login_page.dart').readAsStringSync();
|
||||
|
||||
expect(login, contains("Image.asset(\n _loginBg"));
|
||||
expect(login, contains('Colors.white.withValues(alpha: 0.88)'));
|
||||
expect(login, contains('fillColor: AppColors.cardInner'));
|
||||
expect(login, contains('showDragHandle: true'));
|
||||
},
|
||||
);
|
||||
|
||||
test('today health special states do not use a filled warning row', () {
|
||||
final homeCard = File(
|
||||
'lib/pages/home/widgets/chat_messages_view.dart',
|
||||
).readAsStringSync();
|
||||
|
||||
expect(homeCard, contains("'overdue': LucideIcons.circleAlert"));
|
||||
expect(homeCard, contains('color: Colors.transparent'));
|
||||
expect(homeCard, isNot(contains('isOverdue ? const Color(0xFFFFEBEE)')));
|
||||
expect(homeCard, contains('EdgeInsets.fromLTRB(14, 5, 14, 7)'));
|
||||
expect(homeCard, contains('EdgeInsets.symmetric(vertical: 1)'));
|
||||
});
|
||||
|
||||
test('active create and report states follow the shared visual system', () {
|
||||
final remaining = File('lib/pages/remaining_pages.dart').readAsStringSync();
|
||||
final reports = File(
|
||||
'lib/pages/report/report_pages.dart',
|
||||
).readAsStringSync();
|
||||
|
||||
expect(remaining, contains("AppGradientOutlineButton(label: '保存计划'"));
|
||||
expect(reports, contains('icon: LucideIcons.camera'));
|
||||
expect(reports, contains('icon: LucideIcons.images'));
|
||||
final pdfState = reports.substring(
|
||||
reports.indexOf('Widget _buildPdf'),
|
||||
reports.indexOf('String _absoluteUrl'),
|
||||
);
|
||||
expect(pdfState, contains('return AppEmptyState('));
|
||||
expect(pdfState, contains('icon: AppModuleVisuals.report.icon'));
|
||||
});
|
||||
|
||||
test('secondary module colors do not keep the old mixed palettes', () {
|
||||
final diet = File(
|
||||
'lib/pages/diet/diet_nutrition_widgets.dart',
|
||||
).readAsStringSync();
|
||||
final remaining = File('lib/pages/remaining_pages.dart').readAsStringSync();
|
||||
final reports = File(
|
||||
'lib/pages/report/report_pages.dart',
|
||||
).readAsStringSync();
|
||||
final medication = File(
|
||||
'lib/pages/medication/medication_list_page.dart',
|
||||
).readAsStringSync();
|
||||
final checkin = File(
|
||||
'lib/pages/medication/medication_checkin_page.dart',
|
||||
).readAsStringSync();
|
||||
final exercise = File(
|
||||
'lib/pages/exercise/exercise_plan_page.dart',
|
||||
).readAsStringSync();
|
||||
|
||||
expect(diet, contains('static const primary = Color(0xFF6476E8)'));
|
||||
expect(remaining, isNot(contains('AppModuleVisuals.diet.color')));
|
||||
expect(reports, contains('const _reportPageSoft = Color(0xFFF0F0FF)'));
|
||||
expect(reports, isNot(contains('Color(0xFF60A5FA)')));
|
||||
expect(medication, contains('AppColors.blueMeasure'));
|
||||
expect(checkin, contains('AppColors.blueMeasure'));
|
||||
expect(exercise, contains('AppColors.blueMeasure'));
|
||||
});
|
||||
|
||||
test('trend colors and status text colors stay visually distinct', () {
|
||||
final colors = File('lib/core/app_colors.dart').readAsStringSync();
|
||||
final trend = File('lib/pages/chart/trend_page.dart').readAsStringSync();
|
||||
|
||||
expect(trend, contains('class _TrendColors'));
|
||||
expect(trend, contains("'color': _TrendColors.bloodPressure"));
|
||||
expect(trend, contains("'color': _TrendColors.heartRate"));
|
||||
expect(trend, isNot(contains("'color': Color(0xFFEF4444)")));
|
||||
expect(trend, isNot(contains("'color': Color(0xFFF59E0B)")));
|
||||
expect(colors, contains('successText = Color(0xFF15803D)'));
|
||||
expect(colors, contains('errorText = Color(0xFFDC2626)'));
|
||||
expect(colors, contains('warningText = Color(0xFFB45309)'));
|
||||
});
|
||||
|
||||
test('exercise rows use the shared leading icon list pattern', () {
|
||||
final exercise = File(
|
||||
'lib/pages/exercise/exercise_plan_page.dart',
|
||||
).readAsStringSync();
|
||||
final row = exercise.substring(
|
||||
exercise.indexOf('class _ExercisePlanRow'),
|
||||
exercise.indexOf('class _CompactActionButton'),
|
||||
);
|
||||
|
||||
expect(row, contains('AppModuleVisuals.exercise.icon'));
|
||||
expect(row, contains('color: AppColors.exerciseLight'));
|
||||
expect(row, contains('left: 68'));
|
||||
expect(row, isNot(contains('width: 16')));
|
||||
final modules = File('lib/core/app_module_visuals.dart').readAsStringSync();
|
||||
expect(modules, contains('icon: Icons.directions_run_outlined'));
|
||||
});
|
||||
|
||||
test(
|
||||
'report utility action and notification rows stay neutral and compact',
|
||||
() {
|
||||
final analysis = File(
|
||||
'lib/pages/report/ai_analysis_page.dart',
|
||||
).readAsStringSync();
|
||||
final notifications = File(
|
||||
'lib/pages/notifications/notification_center_page.dart',
|
||||
).readAsStringSync();
|
||||
|
||||
final originalReportAction = analysis.substring(
|
||||
analysis.indexOf("label: const Text('查看原始报告')"),
|
||||
analysis.indexOf("label: const Text('查看原始报告')") + 500,
|
||||
);
|
||||
expect(
|
||||
originalReportAction,
|
||||
contains('foregroundColor: AppColors.textPrimary'),
|
||||
);
|
||||
expect(originalReportAction, contains('color: AppColors.border'));
|
||||
expect(notifications, contains('BoxConstraints(minHeight: 82)'));
|
||||
expect(notifications, contains('width: 68'));
|
||||
expect(notifications, contains("const TextSpan(text: ' 条未读消息')"));
|
||||
expect(
|
||||
notifications,
|
||||
isNot(contains('color: AppColors.notificationLight')),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
82
health_app/test/swipe_delete_tile_test.dart
Normal file
82
health_app/test/swipe_delete_tile_test.dart
Normal file
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:health_app/widgets/common_widgets.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('swipe row paints an opaque white content surface', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SwipeDeleteTile(
|
||||
onDelete: () {},
|
||||
child: const SizedBox(height: 72, child: Text('用药一')),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final surface = tester.widget<ColoredBox>(
|
||||
find.byKey(const ValueKey('swipe-delete-content-surface')),
|
||||
);
|
||||
expect(surface.color, Colors.white);
|
||||
});
|
||||
|
||||
testWidgets('swipe reveals a dedicated delete action', (tester) async {
|
||||
var deleteCount = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SwipeDeleteTile(
|
||||
onDelete: () => deleteCount++,
|
||||
child: const SizedBox(
|
||||
height: 72,
|
||||
width: double.infinity,
|
||||
child: Text('报告一'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byKey(const ValueKey('swipe-delete-action')), findsNothing);
|
||||
await tester.drag(find.text('报告一'), const Offset(-100, 0));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const ValueKey('swipe-delete-action')), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('swipe-delete-action')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(deleteCount, 1);
|
||||
});
|
||||
|
||||
testWidgets('tapping an opened row closes it without deleting', (
|
||||
tester,
|
||||
) async {
|
||||
var deleteCount = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SwipeDeleteTile(
|
||||
onDelete: () => deleteCount++,
|
||||
child: const SizedBox(
|
||||
height: 72,
|
||||
width: double.infinity,
|
||||
child: Text('用药一'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.drag(find.text('用药一'), const Offset(-100, 0));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tapAt(const Offset(100, 40));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(deleteCount, 0);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user