Add feedback service, reminder diagnostics and smooth week transitions

This commit is contained in:
Codex
2026-10-14 15:53:21 +08:00
parent c5e736b39b
commit 1f06e3741e
40 changed files with 3982 additions and 25 deletions
@@ -0,0 +1,311 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:hepan_everyday/features/feedback/feedback_http_sender.dart';
import 'package:hepan_everyday/features/feedback/feedback_submission.dart';
class FakeFeedbackHttpTransport implements FeedbackHttpTransport {
int calls = 0;
Uri? endpoint;
Map<String, String>? headers;
Uint8List? body;
FeedbackHttpResponse response = FeedbackHttpResponse(
200,
utf8.encode('{"success":true,"submission_id":"same-id","status":"sent"}'),
);
Completer<FeedbackHttpResponse>? pending;
@override
Future<FeedbackHttpResponse> post({
required Uri endpoint,
required Map<String, String> headers,
required Uint8List body,
required Duration timeout,
}) async {
calls++;
this.endpoint = endpoint;
this.headers = headers;
this.body = body;
return pending == null ? response : await pending!.future;
}
}
void main() {
test(
'matching uncertain delivery is distinct and never exposes server detail',
() async {
final transport = FakeFeedbackHttpTransport();
final sender = HttpFeedbackSender(
endpoint: 'https://example.com/v1/feedback',
transport: transport,
);
final submission = FeedbackSubmission.problem(
id: 'same-id',
message: '问题',
);
for (final code in ['delivery_uncertain', 'storage_unavailable']) {
transport.response = FeedbackHttpResponse(
503,
utf8.encode(
jsonEncode({
'success': false,
'error': code,
'detail': 'private SMTP error',
'submission_id': 'same-id',
'status': 'uncertain',
}),
),
);
await expectLater(
sender.send(submission),
throwsA(
isA<FeedbackDeliveryUncertain>()
.having((error) => error.submissionId, 'original ID', 'same-id')
.having(
(error) => error.message,
'safe instructions',
allOf(
contains('请勿修改'),
isNot(contains('private SMTP error')),
),
),
),
);
}
transport.response = FeedbackHttpResponse(
503,
utf8.encode(
jsonEncode({
'success': false,
'error': 'delivery_uncertain',
'submission_id': 'other-id',
'status': 'uncertain',
}),
),
);
await expectLater(
sender.send(submission),
throwsA(
allOf(
isA<FeedbackException>(),
isNot(isA<FeedbackDeliveryUncertain>()),
),
),
);
},
);
test(
'permanent media/validation failures give actionable safe messages',
() async {
final transport = FakeFeedbackHttpTransport();
final sender = HttpFeedbackSender(
endpoint: 'https://example.com/v1/feedback',
transport: transport,
);
final submission = FeedbackSubmission.problem(
id: 'same-id',
message: '问题',
);
for (final rejection in [
(status: 415, code: 'unsupported_media', expected: '移除附件'),
(status: 422, code: 'validation_error', expected: '必填内容'),
]) {
transport.response = FeedbackHttpResponse(
rejection.status,
utf8.encode(
jsonEncode({
'success': false,
'error': rejection.code,
'detail': 'untrusted detail',
}),
),
);
await expectLater(
sender.send(submission),
throwsA(
isA<FeedbackException>().having(
(error) => error.message,
'actionable message',
allOf(
contains(rejection.expected),
isNot(contains('untrusted detail')),
),
),
),
);
}
transport.response = FeedbackHttpResponse(
503,
utf8.encode(
jsonEncode({
'success': false,
'error': 'smtp_unavailable',
'submission_id': 'same-id',
'status': 'retryable',
}),
),
);
await expectLater(
sender.send(submission),
throwsA(
allOf(
isA<FeedbackException>().having(
(error) => error.message,
'safe retry',
contains('原提交'),
),
isNot(isA<FeedbackDeliveryUncertain>()),
),
),
);
},
);
test('requires HTTPS before contacting transport', () async {
final transport = FakeFeedbackHttpTransport();
final submission = FeedbackSubmission.problem(id: 'same-id', message: '问题');
for (final endpoint in [
'',
'http://example.com/v1/feedback',
'https://user:pass@example.com/v1/feedback',
]) {
await expectLater(
HttpFeedbackSender(
endpoint: endpoint,
transport: transport,
).send(submission),
throwsA(isA<FeedbackException>()),
);
}
expect(transport.calls, 0);
});
test(
'problem multipart contains relevant fields and repeated media parts',
() async {
final transport = FakeFeedbackHttpTransport();
final file = await FeedbackAttachment.read(
name: 'x.png',
stream: Stream.value([1, 2, 3]),
);
final sender = HttpFeedbackSender(
endpoint: 'https://example.com/v1/feedback',
transport: transport,
);
await sender.send(
FeedbackSubmission.problem(
id: 'same-id',
message: ' 问题 ',
attachments: [file, file],
),
);
final body = utf8.decode(transport.body!, allowMalformed: true);
expect(body, contains('name="submission_id"\r\n\r\nsame-id'));
expect(body, contains('name="type"\r\n\r\nproblem'));
expect(body, contains('name="message"\r\n\r\n问题'));
expect(body, isNot(contains('name="school"')));
expect(body, isNot(contains('name="contact"')));
expect('name="attachments"'.allMatches(body), hasLength(2));
expect(body, contains('Content-Type: image/png'));
expect(
transport.headers!['Content-Type'],
startsWith('multipart/form-data; boundary='),
);
expect(transport.headers!.keys, isNot(contains('Authorization')));
},
);
test('development multipart sends school and contact without problem/media fields', () async {
final transport = FakeFeedbackHttpTransport();
final sender = HttpFeedbackSender(
endpoint: 'https://example.com/v1/feedback',
transport: transport,
);
await sender.send(
FeedbackSubmission.development(
id: 'same-id',
school: '某大学',
contact: 'QQ 123',
),
);
final body = utf8.decode(transport.body!);
expect(body, contains('name="type"\r\n\r\ndevelopment'));
expect(body, contains('name="school"\r\n\r\n某大学'));
expect(body, contains('name="contact"\r\n\r\nQQ 123'));
expect(body, isNot(contains('name="message"')));
expect(body, isNot(contains('name="attachments"')));
});
test('only matching confirmed sent response is successful', () async {
final transport = FakeFeedbackHttpTransport();
final sender = HttpFeedbackSender(
endpoint: 'https://example.com/v1/feedback',
transport: transport,
);
final submission = FeedbackSubmission.problem(id: 'same-id', message: '问题');
for (final response in [
FeedbackHttpResponse(200, utf8.encode('{"success":true}')),
FeedbackHttpResponse(
200,
utf8.encode('{"success":true,"submission_id":"other","status":"sent"}'),
),
FeedbackHttpResponse(
200,
utf8.encode(
'{"success":true,"submission_id":"same-id","status":"queued"}',
),
),
FeedbackHttpResponse(
200,
utf8.encode(
'{"success":false,"submission_id":"same-id","status":"sent"}',
),
),
FeedbackHttpResponse(
500,
utf8.encode(
'{"success":true,"submission_id":"same-id","status":"sent"}',
),
),
FeedbackHttpResponse(200, utf8.encode('bad JSON')),
FeedbackHttpResponse(200, Uint8List(65537)),
]) {
transport.response = response;
await expectLater(
sender.send(submission),
throwsA(isA<FeedbackException>()),
);
}
});
test(
'timeout exposes safe error and repeated request keeps submission ID',
() async {
final transport = FakeFeedbackHttpTransport()
..pending = Completer<FeedbackHttpResponse>();
final sender = HttpFeedbackSender(
endpoint: 'https://example.com/v1/feedback',
transport: transport,
timeout: const Duration(milliseconds: 1),
);
final submission = FeedbackSubmission.problem(
id: 'same-id',
message: '问题',
);
await expectLater(
sender.send(submission),
throwsA(isA<FeedbackException>()),
);
transport.pending = null;
await sender.send(submission);
expect(transport.calls, 2);
expect(
utf8.decode(transport.body!),
contains('name="submission_id"\r\n\r\nsame-id'),
);
},
);
}
+320
View File
@@ -0,0 +1,320 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hepan_everyday/features/feedback/feedback_page.dart';
import 'package:hepan_everyday/features/feedback/feedback_submission.dart';
class RecordingSender implements FeedbackSender {
final submissions = <FeedbackSubmission>[];
Completer<void>? pending;
bool fail = false;
bool uncertain = false;
@override
Future<void> send(FeedbackSubmission submission) async {
submissions.add(submission);
if (pending != null) await pending!.future;
if (uncertain) throw FeedbackDeliveryUncertain(submission.id);
if (fail) throw const FeedbackException('网络暂时不可用,请重试');
}
}
Future<void> reveal(
WidgetTester tester,
Finder target, {
bool backwards = false,
}) async {
await tester.scrollUntilVisible(
target,
backwards ? -250 : 250,
scrollable: find.byType(Scrollable).first,
);
await tester.pump();
}
void main() {
testWidgets('uncertain delivery retains original payload for status check', (
tester,
) async {
final sender = RecordingSender()..uncertain = true;
await tester.pumpWidget(MaterialApp(home: FeedbackPage(sender: sender)));
await tester.enterText(find.byKey(const Key('feedback-message')), '原来的反馈');
await reveal(tester, find.byKey(const Key('feedback-submit')));
await tester.tap(find.byKey(const Key('feedback-submit')));
await tester.pumpAndSettle();
expect(find.text('提交成功'), findsNothing);
expect(find.text('提交状态待确认'), findsOneWidget);
expect(find.byKey(const Key('feedback-message')), findsNothing);
expect(find.byKey(const Key('feedback-submit')), findsNothing);
final original = sender.submissions.single;
expect(
tester
.widget<SelectableText>(
find.byKey(const Key('feedback-uncertain-id')),
)
.data,
original.id,
);
sender.uncertain = false;
await reveal(tester, find.byKey(const Key('feedback-check-original')));
await tester.tap(find.byKey(const Key('feedback-check-original')));
await tester.pumpAndSettle();
expect(sender.submissions, hasLength(2));
expect(identical(sender.submissions[0], sender.submissions[1]), isTrue);
expect(find.text('提交成功'), findsOneWidget);
});
test('submission trims relevant fields and rejects whitespace', () {
expect(
() => FeedbackSubmission.problem(id: 'a', message: ' '),
throwsA(isA<FeedbackException>()),
);
final problem = FeedbackSubmission.problem(
id: 'a',
message: ' 页面建议 ',
contact: ' ',
);
expect(problem.message, '页面建议');
expect(problem.contact, isNull);
expect(problem.school, isNull);
final application = FeedbackSubmission.development(
id: 'b',
school: ' 某大学 ',
contact: ' 微信 abc ',
);
expect(application.school, '某大学');
expect(application.contact, '微信 abc');
expect(application.message, isNull);
expect(application.attachments, isEmpty);
expect(
() => FeedbackSubmission.development(id: 'b', school: ' ', contact: 'x'),
throwsA(isA<FeedbackException>()),
);
expect(
() => FeedbackSubmission.development(id: 'b', school: 'x', contact: ' '),
throwsA(isA<FeedbackException>()),
);
});
test('attachment read stops at byte limit and sanitizes names', () async {
var reads = 0;
Stream<List<int>> oversized() async* {
reads++;
yield [1, 2, 3];
reads++;
yield [4, 5, 6];
reads++;
yield [7];
}
await expectLater(
FeedbackAttachment.read(name: 'x.png', stream: oversized(), maxBytes: 4),
throwsA(isA<FeedbackException>()),
);
expect(reads, 2);
final attachment = await FeedbackAttachment.read(
name: '../../unsafe\r\nname.PNG',
stream: Stream.value([1, 2]),
);
expect(attachment.filename, 'unsafename.PNG');
expect(attachment.contentType, 'image/png');
await expectLater(
FeedbackAttachment.read(name: 'x.exe', stream: Stream.value([1])),
throwsA(isA<FeedbackException>()),
);
});
test('submission enforces attachment count and total limits', () async {
final file = await FeedbackAttachment.read(
name: 'x.jpg',
stream: Stream.value([1]),
);
expect(
() => FeedbackSubmission.problem(
id: 'a',
message: 'x',
attachments: [file, file, file, file],
),
throwsA(isA<FeedbackException>()),
);
final large = await FeedbackAttachment.read(
name: 'x.mp4',
stream: Stream.value(Uint8List(8 * 1024 * 1024)),
);
expect(
() => FeedbackSubmission.problem(
id: 'a',
message: 'x',
attachments: [large, large, large],
),
throwsA(isA<FeedbackException>()),
);
});
testWidgets('problem requires message; contact is optional', (tester) async {
final sender = RecordingSender();
await tester.pumpWidget(MaterialApp(home: FeedbackPage(sender: sender)));
await reveal(tester, find.byKey(const Key('feedback-submit')));
await tester.tap(find.byKey(const Key('feedback-submit')));
await tester.pumpAndSettle();
expect(find.text('请填写问题或建议'), findsOneWidget);
expect(sender.submissions, isEmpty);
await reveal(
tester,
find.byKey(const Key('feedback-message')),
backwards: true,
);
await tester.enterText(find.byKey(const Key('feedback-message')), ' 建议内容 ');
await reveal(tester, find.byKey(const Key('feedback-submit')));
await tester.tap(find.byKey(const Key('feedback-submit')));
await tester.pumpAndSettle();
expect(sender.submissions.single.message, '建议内容');
expect(sender.submissions.single.contact, isNull);
expect(find.text('提交成功'), findsOneWidget);
});
testWidgets('development requires school/contact and excludes hidden data', (
tester,
) async {
final sender = RecordingSender();
await tester.pumpWidget(
MaterialApp(
home: FeedbackPage(
sender: sender,
pickAttachment: () async => FeedbackAttachment.read(
name: 'hidden.png',
stream: Stream.value([1, 2, 3]),
),
),
),
);
await tester.enterText(find.byKey(const Key('feedback-message')), '旧问题');
await tester.ensureVisible(find.byKey(const Key('feedback-add-media')));
await tester.tap(find.byKey(const Key('feedback-add-media')));
await tester.pumpAndSettle();
await reveal(tester, find.text('我要参与开发'), backwards: true);
await tester.tap(find.text('我要参与开发'));
await tester.pumpAndSettle();
expect(find.byKey(const Key('feedback-message')), findsNothing);
expect(find.byKey(const Key('feedback-add-media')), findsNothing);
await reveal(tester, find.byKey(const Key('feedback-submit')));
await tester.tap(find.byKey(const Key('feedback-submit')));
await tester.pumpAndSettle();
expect(find.text('请填写学校'), findsOneWidget);
expect(find.text('请填写联系方式'), findsOneWidget);
await tester.enterText(find.byKey(const Key('feedback-school')), ' 某大学 ');
await tester.enterText(
find.byKey(const Key('feedback-contact')),
' QQ 123 ',
);
await reveal(tester, find.byKey(const Key('feedback-submit')));
await tester.tap(find.byKey(const Key('feedback-submit')));
await tester.pumpAndSettle();
final submission = sender.submissions.single;
expect(submission.kind, FeedbackKind.development);
expect(submission.school, '某大学');
expect(submission.contact, 'QQ 123');
expect(submission.message, isNull);
expect(submission.attachments, isEmpty);
});
testWidgets(
'pending send prevents duplicates; failure preserves data and retry ID',
(tester) async {
final sender = RecordingSender()
..pending = Completer<void>()
..fail = true;
await tester.pumpWidget(MaterialApp(home: FeedbackPage(sender: sender)));
await tester.enterText(find.byKey(const Key('feedback-message')), '问题');
await reveal(tester, find.byKey(const Key('feedback-submit')));
await tester.tap(find.byKey(const Key('feedback-submit')));
await tester.pump();
await tester.tap(find.byKey(const Key('feedback-submit')));
await tester.pump();
expect(sender.submissions, hasLength(1));
expect(find.text('提交成功'), findsNothing);
sender.pending!.complete();
await tester.pumpAndSettle();
expect(find.text('网络暂时不可用,请重试'), findsOneWidget);
expect(find.text('问题'), findsOneWidget);
sender
..pending = null
..fail = false;
await reveal(tester, find.byKey(const Key('feedback-submit')));
await tester.tap(find.byKey(const Key('feedback-submit')));
await tester.pumpAndSettle();
expect(sender.submissions, hasLength(2));
expect(sender.submissions[0].id, sender.submissions[1].id);
expect(find.text('提交成功'), findsOneWidget);
},
);
testWidgets('editing a failed request starts a new submission ID', (
tester,
) async {
final sender = RecordingSender()..fail = true;
await tester.pumpWidget(MaterialApp(home: FeedbackPage(sender: sender)));
await tester.enterText(find.byKey(const Key('feedback-message')), '原来的问题');
await reveal(tester, find.byKey(const Key('feedback-submit')));
await tester.tap(find.byKey(const Key('feedback-submit')));
await tester.pumpAndSettle();
await reveal(
tester,
find.byKey(const Key('feedback-message')),
backwards: true,
);
await tester.enterText(find.byKey(const Key('feedback-message')), '更新的问题');
sender.fail = false;
await reveal(tester, find.byKey(const Key('feedback-submit')));
await tester.tap(find.byKey(const Key('feedback-submit')));
await tester.pumpAndSettle();
expect(sender.submissions[1].id, isNot(sender.submissions[0].id));
expect(sender.submissions[1].message, '更新的问题');
});
testWidgets('unconfigured service gives honest error and preserves input', (
tester,
) async {
await tester.pumpWidget(const MaterialApp(home: FeedbackPage()));
await tester.enterText(find.byKey(const Key('feedback-message')), '问题');
await reveal(tester, find.byKey(const Key('feedback-submit')));
await tester.tap(find.byKey(const Key('feedback-submit')));
await tester.pumpAndSettle();
expect(find.text(UnconfiguredFeedbackSender.message), findsOneWidget);
expect(find.text('提交成功'), findsNothing);
expect(find.text('问题'), findsOneWidget);
});
testWidgets('selected media can be removed and survives send failure', (
tester,
) async {
final sender = RecordingSender()..fail = true;
await tester.pumpWidget(
MaterialApp(
home: FeedbackPage(
sender: sender,
pickAttachment: () async => FeedbackAttachment.read(
name: 'screen.png',
stream: Stream.value([1, 2, 3]),
),
),
),
);
await tester.enterText(find.byKey(const Key('feedback-message')), '问题');
await tester.ensureVisible(find.byKey(const Key('feedback-add-media')));
await tester.tap(find.byKey(const Key('feedback-add-media')));
await tester.pumpAndSettle();
expect(find.text('screen.png'), findsOneWidget);
await reveal(tester, find.byKey(const Key('feedback-submit')));
await tester.tap(find.byKey(const Key('feedback-submit')));
await tester.pumpAndSettle();
expect(sender.submissions.single.attachments, hasLength(1));
expect(find.text('screen.png'), findsOneWidget);
await tester.ensureVisible(find.byTooltip('移除 screen.png'));
await tester.tap(find.byTooltip('移除 screen.png'));
await tester.pumpAndSettle();
expect(find.text('screen.png'), findsNothing);
});
}
@@ -1,3 +1,4 @@
import 'package:timezone/data/latest.dart' as tzdata;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -6,6 +7,129 @@ import 'package:hepan_everyday/services/local_notification_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test(
'querying future reminders does not depend on active notification access',
() async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
AndroidFlutterLocalNotificationsPlugin.registerWith();
const channel = MethodChannel(
'dexterous.com/flutter/local_notifications',
);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'initialize') return true;
if (call.method == 'getNotificationAppLaunchDetails') {
return {'notificationLaunchedApp': false};
}
if (call.method == 'pendingNotificationRequests') {
return [
{
'id': 123,
'title': '课程',
'body': '教室',
'payload': 'hepan:course/2026-10-14',
},
];
}
if (call.method == 'getActiveNotifications') {
throw PlatformException(code: 'active_query_unavailable');
}
return null;
});
addTearDown(() {
debugDefaultTargetPlatformOverride = null;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
});
expect(await PluginReminderGateway().pendingIds(), {123});
},
);
test('active lookup failure preserves owned future alarms', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
AndroidFlutterLocalNotificationsPlugin.registerWith();
const channel = MethodChannel('dexterous.com/flutter/local_notifications');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'initialize') return true;
if (call.method == 'getNotificationAppLaunchDetails') {
return {'notificationLaunchedApp': false};
}
if (call.method == 'pendingNotificationRequests') {
return [
{'id': 123, 'title': '', 'body': '', 'payload': 'hepan:course'},
{
'id': -1002,
'title': '',
'body': '',
'payload': 'hepan-test:scheduled',
},
];
}
if (call.method == 'getActiveNotifications') {
throw PlatformException(code: 'unavailable');
}
return null;
});
addTearDown(() {
debugDefaultTargetPlatformOverride = null;
messenger.setMockMethodCallHandler(channel, null);
});
expect(await PluginReminderGateway().ownedIds(), {123});
});
test(
'background test uses an exact alarm isolated from course ownership',
() async {
tzdata.initializeTimeZones();
debugDefaultTargetPlatformOverride = TargetPlatform.android;
AndroidFlutterLocalNotificationsPlugin.registerWith();
const channel = MethodChannel(
'dexterous.com/flutter/local_notifications',
);
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
Map<Object?, Object?>? scheduled;
messenger.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'initialize') return true;
if (call.method == 'getNotificationAppLaunchDetails') {
return {'notificationLaunchedApp': false};
}
if (call.method == 'areNotificationsEnabled' ||
call.method == 'canScheduleExactNotifications') {
return true;
}
if (call.method == 'zonedSchedule') {
scheduled = call.arguments as Map<Object?, Object?>;
return null;
}
if (call.method == 'pendingNotificationRequests') {
return [
if (scheduled != null)
{
'id': -1002,
'title': '',
'body': '',
'payload': 'hepan-test:scheduled',
},
];
}
return null;
});
addTearDown(() {
debugDefaultTargetPlatformOverride = null;
messenger.setMockMethodCallHandler(channel, null);
});
final gateway = PluginReminderGateway();
expect(await gateway.sendTestReminder(scheduled: true), contains('10 秒'));
expect(scheduled?['id'], -1002);
expect(scheduled?['payload'], 'hepan-test:scheduled');
expect(
(scheduled?['platformSpecifics'] as Map?)?['scheduleMode'],
'exactAllowWhileIdle',
);
expect(await gateway.pendingIds(), isEmpty);
},
);
test('transient native initialization failure can be retried', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
AndroidFlutterLocalNotificationsPlugin.registerWith();
@@ -66,9 +190,9 @@ void main() {
'playSound': true,
'enableVibration': true,
'enableLights': false,
'bypassDnd': false,
'ledColor': 0,
'audioAttributesUsage': 5,
'bypassDnd': false,
'ledColor': 0,
'audioAttributesUsage': 5,
},
];
}
@@ -12,6 +12,7 @@ class AlarmGateway extends ReminderGateway {
ReminderPermissions permission = const ReminderPermissions(true, true);
final alarms = <int, ReminderRequest>{};
int permissionRequests = 0;
bool dropWrites = false;
@override
Future<ReminderPermissions> permissions({required bool request}) async {
if (request) permissionRequests++;
@@ -22,7 +23,7 @@ class AlarmGateway extends ReminderGateway {
Future<Set<int>> pendingIds() async => alarms.keys.toSet();
@override
Future<void> schedule(ReminderRequest value) async {
alarms[value.id] = value;
if (!dropWrites) alarms[value.id] = value;
}
@override
@@ -47,6 +48,23 @@ void main() {
return AppSnapshot.fromJson(json).settings;
}
test(
'does not report success when native scheduling did not persist',
() async {
gateway.dropWrites = true;
final events = ScheduleEngine.occurrences([course()], term);
await expectLater(
service.reconcile(events, settings, DateTime.utc(2026, 9, 9)),
throwsA(
isA<ReminderSetupException>().having(
(e) => e.code,
'code',
'schedule_not_saved',
),
),
);
},
);
test(
'selected advance times produce independent alarms and survive refresh',
() async {
@@ -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');
});
}
+40
View File
@@ -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);
});
}