Add feedback service, reminder diagnostics and smooth week transitions
This commit is contained in:
@@ -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'),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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