Compare commits

...
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 6a18784a76 test: add golden tests for key EmailListScreen states (T5)
Five golden tests covering: empty inbox, populated list (unread/flagged),
selection mode, search results, and error banner. Goldens are generated
on the same Linux environment as CI so rasterisation is deterministic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:26:03 +02:00
Bot of Thomas Güttler a723380560 perf: defer HTML-to-plain conversion off the UI thread (P3) (#49) 2026-05-14 11:14:23 +02:00
7 changed files with 188 additions and 11 deletions
+30 -11
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_html/flutter_html.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -60,20 +61,27 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
tooltip: 'Reply', tooltip: 'Reply',
onPressed: header == null onPressed: header == null
? null ? null
: () => _reply(context, header, body, replyAll: false), : () {
unawaited(_reply(context, header, body, replyAll: false));
},
), ),
IconButton( IconButton(
icon: const Icon(Icons.reply_all), icon: const Icon(Icons.reply_all),
tooltip: 'Reply all', tooltip: 'Reply all',
onPressed: header == null onPressed: header == null
? null ? null
: () => _reply(context, header, body, replyAll: true), : () {
unawaited(_reply(context, header, body, replyAll: true));
},
), ),
IconButton( IconButton(
icon: const Icon(Icons.forward), icon: const Icon(Icons.forward),
tooltip: 'Forward', tooltip: 'Forward',
onPressed: onPressed: header == null
header == null ? null : () => _forward(context, header, body), ? null
: () {
unawaited(_forward(context, header, body));
},
), ),
IconButton( IconButton(
icon: const Icon(Icons.mark_email_unread_outlined), icon: const Icon(Icons.mark_email_unread_outlined),
@@ -263,26 +271,31 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
); );
} }
String _quotedBody(Email header, EmailBody? body) { Future<String> _quotedBody(Email header, EmailBody? body) async {
final date = header.sentAt != null ? _dateFmt.format(header.sentAt!) : ''; final date = header.sentAt != null ? _dateFmt.format(header.sentAt!) : '';
final from = final from =
header.from.isNotEmpty ? header.from.first.toString() : '(unknown)'; header.from.isNotEmpty ? header.from.first.toString() : '(unknown)';
final text = body?.textBody ?? htmlToPlain(body?.htmlBody ?? ''); final rawText = body?.textBody;
final text = (rawText != null && rawText.isNotEmpty)
? rawText
: await compute(htmlToPlain, body?.htmlBody ?? '');
final quoted = text.trim().split('\n').map((l) => '> $l').join('\n'); final quoted = text.trim().split('\n').map((l) => '> $l').join('\n');
return '\n\n— On $date, $from wrote:\n$quoted'; return '\n\n— On $date, $from wrote:\n$quoted';
} }
void _reply( Future<void> _reply(
BuildContext context, BuildContext context,
Email header, Email header,
EmailBody? body, { EmailBody? body, {
required bool replyAll, required bool replyAll,
}) { }) async {
final to = header.from.isNotEmpty ? header.from.first.email : ''; final to = header.from.isNotEmpty ? header.from.first.email : '';
final subject = (header.subject?.startsWith('Re:') ?? false) final subject = (header.subject?.startsWith('Re:') ?? false)
? header.subject! ? header.subject!
: 'Re: ${header.subject ?? ''}'; : 'Re: ${header.subject ?? ''}';
final cc = replyAll ? header.to.map((a) => a.email).join(', ') : ''; final cc = replyAll ? header.to.map((a) => a.email).join(', ') : '';
final quoted = await _quotedBody(header, body);
if (!context.mounted) return;
unawaited( unawaited(
context.push( context.push(
'/compose', '/compose',
@@ -290,23 +303,29 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
'replyToEmailId': widget.emailId, 'replyToEmailId': widget.emailId,
'prefillTo': to, 'prefillTo': to,
'prefillSubject': subject, 'prefillSubject': subject,
'prefillBody': _quotedBody(header, body), 'prefillBody': quoted,
if (cc.isNotEmpty) 'prefillCc': cc, if (cc.isNotEmpty) 'prefillCc': cc,
}, },
), ),
); );
} }
void _forward(BuildContext context, Email header, EmailBody? body) { Future<void> _forward(
BuildContext context,
Email header,
EmailBody? body,
) async {
final subject = (header.subject?.startsWith('Fwd:') ?? false) final subject = (header.subject?.startsWith('Fwd:') ?? false)
? header.subject! ? header.subject!
: 'Fwd: ${header.subject ?? ''}'; : 'Fwd: ${header.subject ?? ''}';
final quoted = await _quotedBody(header, body);
if (!context.mounted) return;
unawaited( unawaited(
context.push( context.push(
'/compose', '/compose',
extra: { extra: {
'prefillSubject': subject, 'prefillSubject': subject,
'prefillBody': _quotedBody(header, body), 'prefillBody': quoted,
}, },
), ),
); );
@@ -0,0 +1,158 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/di.dart';
import 'helpers.dart';
// Fixed-date emails so golden files don't change day to day.
final _kDate = DateTime(2024, 6);
Email _email({
String id = 'acc-1:1',
String subject = 'Hello world',
bool isSeen = true,
bool isFlagged = false,
}) =>
Email(
id: id,
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: int.parse(id.split(':').last),
subject: subject,
receivedAt: _kDate,
sentAt: _kDate,
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
to: const [EmailAddress(email: 'alice@example.com')],
cc: const [],
isSeen: isSeen,
isFlagged: isFlagged,
hasAttachment: false,
);
List<Override> _overrides({
List<Email> emails = const [],
List<Email> searchResults = const [],
String? syncError,
}) =>
[
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository([kTestMailbox]),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: emails, searchResults: searchResults),
),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
syncLastErrorProvider.overrideWith(
(ref, _) => Stream.value(syncError),
),
];
void main() {
group('EmailListScreen goldens', () {
testWidgets('golden: empty state', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _overrides(),
),
);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('goldens/email_list_empty.png'),
);
});
testWidgets('golden: list with emails', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _overrides(
emails: [
_email(subject: 'Team standup notes', isSeen: false),
_email(id: 'acc-1:2', subject: 'Q3 review', isFlagged: true),
_email(id: 'acc-1:3', subject: 'Welcome to the project'),
],
),
),
);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('goldens/email_list_with_emails.png'),
);
});
testWidgets('golden: selection mode', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _overrides(
emails: [
_email(subject: 'Team standup notes', isSeen: false),
_email(id: 'acc-1:2', subject: 'Q3 review'),
],
),
),
);
await tester.pumpAndSettle();
await tester.longPress(find.text('Team standup notes'));
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('goldens/email_list_selection.png'),
);
});
testWidgets('golden: search with results', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _overrides(
searchResults: [
_email(id: 'acc-1:5', subject: 'Project proposal'),
],
),
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(SearchBar), 'project');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('goldens/email_list_search_results.png'),
);
});
testWidgets('golden: error banner', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _overrides(syncError: 'Connection refused'),
),
);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('goldens/email_list_error_banner.png'),
);
});
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB