Add feedback service, reminder diagnostics and smooth week transitions
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hepan_everyday/app/app_controller.dart';
|
||||
import 'package:hepan_everyday/domain/app_snapshot.dart';
|
||||
import 'package:hepan_everyday/domain/app_settings.dart';
|
||||
import 'package:hepan_everyday/features/profile/reminder_tools.dart';
|
||||
|
||||
import '../support/fakes.dart';
|
||||
|
||||
class _Notifications extends FakeNotifications {
|
||||
Completer<Map<String, Object?>>? diagnosis;
|
||||
int? reconciliationsAtDiagnosis;
|
||||
final tests = <bool>[];
|
||||
|
||||
@override
|
||||
Future<String> sendTestReminder({required bool scheduled}) async {
|
||||
tests.add(scheduled);
|
||||
return scheduled ? '已安排测试提醒' : '已发送测试提醒';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, Object?>> diagnose() async {
|
||||
reconciliationsAtDiagnosis = reconciliations;
|
||||
return diagnosis?.future ??
|
||||
{
|
||||
'notifications': true,
|
||||
'exactAlarm': false,
|
||||
'pendingCount': 2,
|
||||
'channelImportance': 4,
|
||||
'channelSound': true,
|
||||
'system': {
|
||||
'manufacturer': 'TestPhone',
|
||||
'model': 'TestModel',
|
||||
'sdk': 35,
|
||||
'backgroundRestricted': true,
|
||||
'account': 'private@example.com',
|
||||
},
|
||||
'courseNames': ['Private course name'],
|
||||
'account': 'private@example.com',
|
||||
'errorCode': 'none',
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> openSettings({required bool background}) async {}
|
||||
}
|
||||
|
||||
Future<void> _open(WidgetTester tester, _Notifications notifications) async {
|
||||
final controller = AppController(
|
||||
MemoryStore(AppSnapshot(settings: AppSettings())),
|
||||
notifications,
|
||||
FakeCalendar(),
|
||||
() => DateTime.utc(2026, 10, 14),
|
||||
);
|
||||
await controller.initialize();
|
||||
addTearDown(controller.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: ReminderTools(controller: controller),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('test controls distinguish immediate and background reminders', (
|
||||
tester,
|
||||
) async {
|
||||
final notifications = _Notifications();
|
||||
await _open(tester, notifications);
|
||||
await tester.tap(find.text('立即测试提醒'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('已发送测试提醒'), findsOneWidget);
|
||||
await tester.tap(find.text('10 秒后台测试'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(notifications.tests, [false, true]);
|
||||
expect(find.textContaining('Home'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('diagnosis refreshes services and prevents overlapping actions', (
|
||||
tester,
|
||||
) async {
|
||||
final notifications = _Notifications()
|
||||
..diagnosis = Completer<Map<String, Object?>>();
|
||||
await _open(tester, notifications);
|
||||
final previous = notifications.reconciliations;
|
||||
await tester.tap(find.text('检查提醒'));
|
||||
await tester.pump();
|
||||
expect(notifications.reconciliationsAtDiagnosis, previous + 1);
|
||||
for (final button in tester.widgetList<TextButton>(
|
||||
find.byType(TextButton),
|
||||
)) {
|
||||
expect(button.onPressed, isNull);
|
||||
}
|
||||
notifications.diagnosis!.complete({'notifications': false});
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('系统通知已关闭'), findsOneWidget);
|
||||
expect(find.text('复制提醒诊断'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('copied diagnostics omit unapproved fields and private values', (
|
||||
tester,
|
||||
) async {
|
||||
String? copied;
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(call) async {
|
||||
if (call.method == 'Clipboard.setData') {
|
||||
copied = (call.arguments as Map)['text'] as String;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
null,
|
||||
),
|
||||
);
|
||||
await _open(tester, _Notifications());
|
||||
await tester.tap(find.text('检查提醒'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.ensureVisible(find.text('复制提醒诊断'));
|
||||
await tester.tap(find.text('复制提醒诊断'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(copied, isNotNull);
|
||||
expect(copied, isNot(contains('private@example.com')));
|
||||
expect(copied, isNot(contains('Private course name')));
|
||||
final report = jsonDecode(copied!) as Map;
|
||||
expect(report['pendingCount'], 2);
|
||||
expect(report['courseCount'], 0);
|
||||
expect(report['appVersion'], isNotEmpty);
|
||||
expect((report['system'] as Map)['model'], 'TestModel');
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:hepan_everyday/domain/app_snapshot.dart';
|
||||
import 'package:hepan_everyday/domain/app_settings.dart';
|
||||
import 'package:hepan_everyday/domain/schedule_engine.dart';
|
||||
import 'package:hepan_everyday/features/timetable/timetable_page.dart';
|
||||
import 'package:hepan_everyday/widgets/directional_week_transition.dart';
|
||||
|
||||
import '../support/fakes.dart';
|
||||
|
||||
@@ -51,6 +52,45 @@ Future<void> _open(WidgetTester tester, AppController controller) async {
|
||||
|
||||
void main() {
|
||||
setUpAll(tzdata.initializeTimeZones);
|
||||
testWidgets(
|
||||
'week arrows animate the grid and current week reverses direction',
|
||||
(tester) async {
|
||||
await _open(tester, _controller());
|
||||
await tester.tap(find.byIcon(Icons.chevron_right_rounded));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(find.byType(DirectionalWeekTransition), findsOneWidget);
|
||||
expect(find.text('第1周课程'), findsOneWidget);
|
||||
expect(find.text('第2周课程'), findsOneWidget);
|
||||
expect(
|
||||
tester
|
||||
.widget<FractionalTranslation>(
|
||||
find.byKey(const Key('week-transition-incoming')),
|
||||
)
|
||||
.translation
|
||||
.dx,
|
||||
greaterThan(0),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('第1周课程'), findsNothing);
|
||||
await tester.tap(find.text('本周'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(
|
||||
tester
|
||||
.widget<FractionalTranslation>(
|
||||
find.byKey(const Key('week-transition-incoming')),
|
||||
)
|
||||
.translation
|
||||
.dx,
|
||||
lessThan(0),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('第 1 周'), findsOneWidget);
|
||||
expect(find.text('第2周课程'), findsNothing);
|
||||
expect(find.text('周日'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
testWidgets(
|
||||
'horizontal course swipe changes week and course taps still open',
|
||||
(tester) async {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hepan_everyday/widgets/directional_week_transition.dart';
|
||||
|
||||
Widget _week(int week, {bool reduceMotion = false, VoidCallback? onTap}) =>
|
||||
MaterialApp(
|
||||
home: MediaQuery(
|
||||
data: MediaQueryData(disableAnimations: reduceMotion),
|
||||
child: Scaffold(
|
||||
body: DirectionalWeekTransition(
|
||||
week: week,
|
||||
child: SizedBox(
|
||||
width: 320,
|
||||
height: 180,
|
||||
child: Center(
|
||||
child: Semantics(
|
||||
label: 'week-$week-course',
|
||||
button: true,
|
||||
excludeSemantics: true,
|
||||
child: TextButton(
|
||||
onPressed: onTap ?? () {},
|
||||
child: Text('Week $week'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
FractionalTranslation _translation(WidgetTester tester, String layer) =>
|
||||
tester.widget(find.byKey(Key('week-transition-$layer')));
|
||||
|
||||
Finder get _layers => find.descendant(
|
||||
of: find.byType(DirectionalWeekTransition),
|
||||
matching: find.byType(FractionalTranslation),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'next and previous weeks move in opposite directions then settle',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(_week(1));
|
||||
await tester.pumpWidget(_week(2));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(find.text('Week 1'), findsOneWidget);
|
||||
expect(find.text('Week 2'), findsOneWidget);
|
||||
expect(_translation(tester, 'incoming').translation.dx, greaterThan(0));
|
||||
expect(_translation(tester, 'outgoing').translation.dx, lessThan(0));
|
||||
final fade = tester.widget<Opacity>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('week-transition-incoming')),
|
||||
matching: find.byType(Opacity),
|
||||
),
|
||||
);
|
||||
expect(fade.opacity, greaterThan(0));
|
||||
expect(fade.opacity, lessThan(1));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Week 1'), findsNothing);
|
||||
expect(_translation(tester, 'incoming').translation, Offset.zero);
|
||||
|
||||
await tester.pumpWidget(_week(1));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(_translation(tester, 'incoming').translation.dx, lessThan(0));
|
||||
expect(_translation(tester, 'outgoing').translation.dx, greaterThan(0));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Week 2'), findsNothing);
|
||||
expect(_translation(tester, 'incoming').translation, Offset.zero);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('outgoing courses cannot receive taps or duplicate semantics', (
|
||||
tester,
|
||||
) async {
|
||||
final semantics = tester.ensureSemantics();
|
||||
try {
|
||||
var oldTaps = 0;
|
||||
await tester.pumpWidget(_week(1, onTap: () => oldTaps++));
|
||||
await tester.pumpWidget(_week(2));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(find.bySemanticsLabel('week-1-course'), findsNothing);
|
||||
expect(find.bySemanticsLabel('week-2-course'), findsOneWidget);
|
||||
final oldPointer = tester.widget<IgnorePointer>(
|
||||
find
|
||||
.ancestor(
|
||||
of: find.text('Week 1'),
|
||||
matching: find.byType(IgnorePointer),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
expect(oldPointer.ignoring, isTrue);
|
||||
await tester.tap(find.text('Week 1'), warnIfMissed: false);
|
||||
expect(oldTaps, 0);
|
||||
await tester.pumpAndSettle();
|
||||
} finally {
|
||||
semantics.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'rapid requests keep the running tween and coalesce latest week',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(_week(1));
|
||||
await tester.pumpWidget(_week(2));
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
final before = _translation(tester, 'incoming').translation;
|
||||
await tester.pumpWidget(_week(3));
|
||||
expect(_translation(tester, 'incoming').translation, before);
|
||||
await tester.pumpWidget(_week(4));
|
||||
expect(_translation(tester, 'incoming').translation, before);
|
||||
expect(_layers, findsNWidgets(2));
|
||||
expect(find.text('Week 3'), findsNothing);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Week 4'), findsOneWidget);
|
||||
expect(find.text('Week 1'), findsNothing);
|
||||
expect(find.text('Week 2'), findsNothing);
|
||||
expect(_layers, findsOneWidget);
|
||||
expect(_translation(tester, 'incoming').translation, Offset.zero);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('rapid reversal returns to latest week without stale layers', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_week(1));
|
||||
await tester.pumpWidget(_week(2));
|
||||
await tester.pump(const Duration(milliseconds: 70));
|
||||
await tester.pumpWidget(_week(3));
|
||||
await tester.pumpWidget(_week(1));
|
||||
await tester.pump(const Duration(milliseconds: 210));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(_translation(tester, 'incoming').translation.dx, lessThan(0));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Week 1'), findsOneWidget);
|
||||
expect(find.text('Week 2'), findsNothing);
|
||||
expect(find.text('Week 3'), findsNothing);
|
||||
expect(_layers, findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('reduced motion snaps and clears an in-flight transition', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_week(1));
|
||||
await tester.pumpWidget(_week(2));
|
||||
await tester.pump(const Duration(milliseconds: 70));
|
||||
await tester.pumpWidget(_week(3, reduceMotion: true));
|
||||
expect(find.text('Week 1'), findsNothing);
|
||||
expect(find.text('Week 2'), findsNothing);
|
||||
expect(find.text('Week 3'), findsOneWidget);
|
||||
expect(_translation(tester, 'incoming').translation, Offset.zero);
|
||||
expect(find.byKey(const Key('week-transition-outgoing')), findsNothing);
|
||||
await tester.pumpWidget(_week(1, reduceMotion: true));
|
||||
expect(find.text('Week 1'), findsOneWidget);
|
||||
expect(find.text('Week 3'), findsNothing);
|
||||
expect(tester.hasRunningAnimations, isFalse);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user