This commit is contained in:
Thomas Güttler
2026-04-16 15:14:18 +02:00
parent c6be26623d
commit 99a46e1589
16 changed files with 1047 additions and 16 deletions
+3
View File
@@ -31,6 +31,9 @@ jobs:
- name: Unit tests
run: flutter test test/unit/ --coverage
- name: Widget tests
run: flutter test test/widget/
integration:
name: Integration tests (Stalwart)
runs-on: ubuntu-latest
+9
View File
@@ -0,0 +1,9 @@
repos:
- repo: local
hooks:
- id: task-check
name: task check (analyze + unit + widget + integration)
language: system
entry: task check
pass_filenames: false
always_run: true
+16 -2
View File
@@ -74,8 +74,9 @@ task check
```bash
task analyze # flutter analyze (uses analysis_options.yaml)
task test # pure-Dart unit tests — fast, no device
task test-flutter # Flutter widget tests
task test # pure-Dart unit tests + coverage gate (≥70%)
task test-widget # widget tests — headless, no device needed
task test-flutter # full Flutter test suite (unit + widget + integration)
task integration # IMAP/SMTP integration tests via local Stalwart server
task run # flutter run -d linux
task analyze-fix # dart fix --apply
@@ -83,6 +84,18 @@ task analyze-fix # dart fix --apply
`task check` runs `analyze` + `test` in parallel — use it before every commit.
### Widget tests
`test/widget/` contains [Flutter widget tests](https://docs.flutter.dev/testing/overview#widget-tests) for every screen. They run headlessly — no display server, no device, no database, no network. Each test pumps the screen into a virtual render canvas and uses in-memory fakes for the Riverpod repository providers.
Run them locally:
```bash
task test-widget # or: flutter test test/widget/
```
They also run in CI on every push (see the **Widget tests** step in `.github/workflows/ci.yml`).
### After changing the DB schema
Edit `lib/data/db/database.dart`, then:
@@ -132,6 +145,7 @@ packages/
stalwart-dev/ — local mail server config + start/test scripts
test/
unit/ — pure-Dart unit tests (no device)
widget/ — Flutter widget tests (headless, no device)
integration/ — IMAP/SMTP tests against local Stalwart
```
+12 -8
View File
@@ -3,7 +3,7 @@ silent: true
tasks:
default:
desc: Run all checks (analyze + unit tests + integration, in parallel)
desc: Run all checks (analyze + unit tests + widget tests + integration, in parallel)
deps: [check]
_nix-check:
@@ -14,11 +14,9 @@ tasks:
msg: "Not in nix dev shell. Run: nix develop"
install-hooks:
desc: Install git hooks from hooks/ into .git/hooks/
desc: Install pre-commit hooks (requires pre-commit; see .pre-commit-config.yaml)
cmds:
- cp hooks/pre-commit .git/hooks/pre-commit
- chmod +x .git/hooks/pre-commit
- echo "Installed hooks/pre-commit → .git/hooks/pre-commit"
- pre-commit install
codegen:
desc: Generate Drift DB code (run after any schema change)
@@ -44,8 +42,14 @@ tasks:
cmds:
- scripts/run_unit_tests.sh
test-widget:
desc: Widget tests — headless, no display or network required
deps: [_nix-check]
cmds:
- flutter test test/widget/
test-flutter:
desc: Flutter widget tests
desc: Full Flutter test suite (unit + widget + integration)
deps: [_nix-check]
cmds:
- flutter test
@@ -69,5 +73,5 @@ tasks:
- flutter run -d linux
check:
desc: All fast checks — analyze + unit tests + integration in parallel
deps: [analyze, test, integration]
desc: All fast checks — analyze + unit tests + widget tests + integration in parallel
deps: [analyze, test, test-widget, integration]
+1 -1
View File
@@ -49,7 +49,7 @@ class Emails extends Table {
DateTimeColumn get receivedAt => dateTime()();
// JSON-encoded List<{name,email}>
TextColumn get fromJson => text().withDefault(const Constant('[]'))();
TextColumn get toJson => text().withDefault(const Constant('[]'))();
TextColumn get toAddresses => text().withDefault(const Constant('[]'))();
TextColumn get ccJson => text().withDefault(const Constant('[]'))();
TextColumn get preview => text().nullable()();
BoolColumn get isSeen => boolean().withDefault(const Constant(false))();
@@ -125,7 +125,7 @@ class EmailRepositoryImpl implements EmailRepository {
sentAt: Value(envelope.date),
receivedAt: envelope.date ?? DateTime.now(),
fromJson: Value(_encodeAddresses(envelope.from)),
toJson: Value(_encodeAddresses(envelope.to)),
toAddresses: Value(_encodeAddresses(envelope.to)),
ccJson: Value(_encodeAddresses(envelope.cc)),
isSeen: Value(msg.flags?.contains(r'\Seen') ?? false),
isFlagged: Value(msg.flags?.contains(r'\Flagged') ?? false),
@@ -327,7 +327,7 @@ class EmailRepositoryImpl implements EmailRepository {
sentAt: row.sentAt,
receivedAt: row.receivedAt,
from: parseAddresses(row.fromJson),
to: parseAddresses(row.toJson),
to: parseAddresses(row.toAddresses),
cc: parseAddresses(row.ccJson),
preview: row.preview,
isSeen: row.isSeen,
+3 -1
View File
@@ -79,7 +79,9 @@ void main() {
currentSf = line.substring(3);
} else if (line.startsWith('DA:') &&
currentSf != null &&
!_excluded.contains(currentSf)) {
!_excluded.contains(currentSf) &&
!_noCode.contains(currentSf) &&
!currentSf.endsWith('.g.dart')) {
final count = int.parse(line.substring(3).split(',')[1]);
total++;
if (count > 0) hits++;
+3 -2
View File
@@ -155,8 +155,9 @@ void main() {
group('EmailAttachment', () {
test('runtime construction stores all fields', () {
// Non-const construction so the constructor is instrumented.
final filename = 'report.pdf';
// Non-const construction so the constructor is instrumented for coverage.
const filename = 'report.pdf';
// ignore: prefer_const_constructors
final att = EmailAttachment(
filename: filename,
contentType: 'application/pdf',
+106
View File
@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/di.dart';
import 'helpers.dart';
void main() {
group('AccountListScreen', () {
testWidgets('shows "No accounts yet." when repository is empty',
(tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository()),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider
.overrideWithValue(FakeEmailRepository()),
],
));
await tester.pumpAndSettle();
expect(find.text('No accounts yet.'), findsOneWidget);
expect(find.text('Add account'), findsOneWidget);
});
testWidgets('shows account tile when repository has an account',
(tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository([kTestAccount])),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider
.overrideWithValue(FakeEmailRepository()),
],
));
await tester.pumpAndSettle();
expect(find.text('Alice'), findsOneWidget);
expect(find.text('alice@example.com'), findsOneWidget);
});
testWidgets('app bar shows "SharedInbox" title', (tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository()),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider
.overrideWithValue(FakeEmailRepository()),
],
));
await tester.pumpAndSettle();
expect(find.text('SharedInbox'), findsOneWidget);
});
testWidgets('tapping settings icon navigates to /settings',
(tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository()),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider
.overrideWithValue(FakeEmailRepository()),
],
));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.settings));
await tester.pumpAndSettle();
expect(find.text('Settings'), findsOneWidget);
});
testWidgets('tapping FAB navigates to add-account screen', (tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository()),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider
.overrideWithValue(FakeEmailRepository()),
],
));
await tester.pumpAndSettle();
await tester.tap(find.byType(FloatingActionButton));
await tester.pumpAndSettle();
expect(find.text('Add account'), findsOneWidget);
});
});
}
+116
View File
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/add_account_screen.dart';
import 'helpers.dart';
// Wrap AddAccountScreen in a minimal GoRouter so context.pop() doesn't throw
// when the save succeeds in other tests.
Widget _buildScreen(List<Override> overrides) {
final router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (ctx, state) => const AddAccountScreen(),
),
],
);
return ProviderScope(
overrides: overrides,
child: MaterialApp.router(
routerConfig: router,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
),
);
}
List<Override> get _overrides => [
accountRepositoryProvider.overrideWithValue(FakeAccountRepository()),
mailboxRepositoryProvider.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
];
/// Drags the form's ListView so the bottom of the form (Save button) is
/// visible. The drag distance is empirically large enough to clear all
/// fields in the default 800×600 test viewport.
Future<void> _scrollToBottom(WidgetTester tester) async {
await tester.drag(find.byType(ListView), const Offset(0, -2000));
await tester.pumpAndSettle();
}
void main() {
group('AddAccountScreen', () {
testWidgets('renders fields visible at the top of the form',
(tester) async {
await tester.pumpWidget(_buildScreen(_overrides));
await tester.pumpAndSettle();
// These fields are near the top and visible without scrolling.
expect(find.text('Add account'), findsOneWidget); // app-bar title
expect(find.text('Display name'), findsOneWidget);
expect(find.text('Email address'), findsOneWidget);
expect(find.text('Password'), findsOneWidget);
});
testWidgets('Save button is reachable by scrolling', (tester) async {
await tester.pumpWidget(_buildScreen(_overrides));
await tester.pumpAndSettle();
await _scrollToBottom(tester);
expect(find.text('Save'), findsOneWidget);
});
testWidgets('shows "Required" validation errors when submitted empty',
(tester) async {
await tester.pumpWidget(_buildScreen(_overrides));
await tester.pumpAndSettle();
// Scroll to the Save button and tap it.
await _scrollToBottom(tester);
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
// Scroll back to the top to see the first validation errors.
await tester.drag(find.byType(ListView), const Offset(0, 2000));
await tester.pumpAndSettle();
expect(find.text('Required'), findsWidgets);
});
testWidgets('IMAP SSL toggle is on by default', (tester) async {
await tester.pumpWidget(_buildScreen(_overrides));
await tester.pumpAndSettle();
// The IMAP SSL tile is near the top and visible without scrolling.
final imapTile = tester.widget<SwitchListTile>(find.ancestor(
of: find.text('SSL/TLS').first,
matching: find.byType(SwitchListTile),
));
expect(imapTile.value, isTrue);
});
testWidgets('SMTP SSL toggle is off by default', (tester) async {
await tester.pumpWidget(_buildScreen(_overrides));
await tester.pumpAndSettle();
// Scroll to reveal the SMTP section.
await _scrollToBottom(tester);
// After scrolling, there are two SwitchListTile widgets in the tree;
// the last one is the SMTP SSL toggle.
final tiles = tester
.widgetList<SwitchListTile>(find.byType(SwitchListTile))
.toList();
expect(tiles.last.value, isFalse, reason: 'SMTP SSL off by default');
});
});
}
+128
View File
@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/compose_screen.dart';
import 'helpers.dart';
void main() {
group('ComposeScreen', () {
testWidgets('renders To, Cc, Subject and Body fields', (tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/compose',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository([kTestAccount])),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider
.overrideWithValue(FakeEmailRepository()),
],
));
await tester.pumpAndSettle();
expect(find.text('To'), findsOneWidget);
expect(find.text('Cc'), findsOneWidget);
expect(find.text('Subject'), findsOneWidget);
expect(find.text('Body'), findsOneWidget);
});
testWidgets('prefills To and Subject when provided as constructor params',
(tester) async {
await tester.pumpWidget(_buildDirect(
screen: const ComposeScreen(
prefillTo: 'bob@example.com',
prefillSubject: 'Re: Hello',
),
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository([kTestAccount])),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider
.overrideWithValue(FakeEmailRepository()),
],
));
await tester.pumpAndSettle();
expect(find.widgetWithText(TextFormField, 'bob@example.com'),
findsOneWidget);
expect(find.widgetWithText(TextFormField, 'Re: Hello'), findsOneWidget);
});
testWidgets('shows static From field when one account is loaded',
(tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/compose',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository([kTestAccount])),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider
.overrideWithValue(FakeEmailRepository()),
],
));
await tester.pumpAndSettle();
expect(find.text('Alice <alice@example.com>'), findsOneWidget);
});
testWidgets('shows From dropdown when multiple accounts are loaded',
(tester) async {
const second = Account(
id: 'acc-2',
displayName: 'Bob',
email: 'bob@example.com',
imapHost: 'imap.example.com',
imapPort: 993,
imapSsl: true,
smtpHost: 'smtp.example.com',
smtpPort: 587,
smtpSsl: false,
);
await tester.pumpWidget(buildApp(
initialLocation: '/compose',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount, second])),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider
.overrideWithValue(FakeEmailRepository()),
],
));
await tester.pumpAndSettle();
expect(find.byType(DropdownButtonFormField<String>), findsOneWidget);
});
});
}
/// Builds [screen] inside a minimal GoRouter so [context.pop()] works, without
/// going through [buildApp]'s full route tree.
Widget _buildDirect({
required Widget screen,
required List<Override> overrides,
}) {
final router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(path: '/', builder: (ctx, state) => screen),
],
);
return ProviderScope(
overrides: overrides,
child: MaterialApp.router(
routerConfig: router,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
),
);
}
+128
View File
@@ -0,0 +1,128 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/di.dart';
import 'helpers.dart';
void main() {
group('EmailDetailScreen', () {
testWidgets('shows loading spinner before data arrives', (tester) async {
// Use a Completer-backed repo so data never arrives during this test.
final neverRepo = _NeverEmailRepository();
await tester.pumpWidget(buildApp(
initialLocation:
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository([kTestAccount])),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider.overrideWithValue(neverRepo),
],
));
// One pump to build the widget tree; future not resolved yet.
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('shows subject in app bar after data loads', (tester) async {
final email = testEmail(subject: 'Project update');
const body = EmailBody(
emailId: 'acc-1:42',
textBody: 'See attached slides.',
attachments: [],
);
await tester.pumpWidget(buildApp(
initialLocation:
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository([kTestAccount])),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emailDetail: email, emailBody: body),
),
],
));
await tester.pumpAndSettle();
// Subject appears in both the app bar and the email header section.
expect(find.text('Project update'), findsAtLeastNWidgets(1));
expect(find.text('See attached slides.'), findsOneWidget);
});
testWidgets('shows from-address in header', (tester) async {
final email = testEmail();
const body =
EmailBody(emailId: 'acc-1:42', textBody: 'Hi', attachments: []);
await tester.pumpWidget(buildApp(
initialLocation:
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository([kTestAccount])),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emailDetail: email, emailBody: body),
),
],
));
await tester.pumpAndSettle();
expect(find.textContaining('bob@example.com'), findsOneWidget);
});
testWidgets('shows attachment section when email has attachments',
(tester) async {
final email = testEmail(hasAttachment: true);
const body = EmailBody(
emailId: 'acc-1:42',
textBody: 'Please review.',
attachments: [
EmailAttachment(
filename: 'report.pdf',
contentType: 'application/pdf',
size: 204800,
),
],
);
await tester.pumpWidget(buildApp(
initialLocation:
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository([kTestAccount])),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emailDetail: email, emailBody: body),
),
],
));
await tester.pumpAndSettle();
expect(find.text('Attachments'), findsOneWidget);
expect(find.text('report.pdf'), findsOneWidget);
});
});
}
/// Email repository whose [getEmail] and [getEmailBody] futures never resolve,
/// used to test the loading state.
class _NeverEmailRepository extends FakeEmailRepository {
_NeverEmailRepository() : super();
@override
Future<Email?> getEmail(String emailId) => Completer<Email?>().future;
@override
Future<EmailBody> getEmailBody(String emailId) =>
Completer<EmailBody>().future;
}
+109
View File
@@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/di.dart';
import 'helpers.dart';
void main() {
group('EmailListScreen', () {
testWidgets('shows "No emails" when list is empty', (tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository([kTestAccount])),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider
.overrideWithValue(FakeEmailRepository()),
],
));
await tester.pumpAndSettle();
expect(find.text('No emails'), findsOneWidget);
});
testWidgets('shows email sender and subject', (tester) async {
final email = testEmail(subject: 'Meeting agenda');
await tester.pumpWidget(buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository([kTestAccount])),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider
.overrideWithValue(FakeEmailRepository(emails: [email])),
],
));
await tester.pumpAndSettle();
expect(find.text('Bob'), findsOneWidget);
expect(find.text('Meeting agenda'), findsOneWidget);
});
testWidgets('shows flag icon for flagged email', (tester) async {
final email = testEmail(isFlagged: true);
await tester.pumpWidget(buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository([kTestAccount])),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider
.overrideWithValue(FakeEmailRepository(emails: [email])),
],
));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.star), findsOneWidget);
});
testWidgets('tapping search icon shows search bar', (tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository([kTestAccount])),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider
.overrideWithValue(FakeEmailRepository()),
],
));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.search));
await tester.pumpAndSettle();
expect(find.byType(TextField), findsOneWidget);
expect(find.text('Search…'), findsOneWidget);
});
testWidgets('tapping back arrow in search bar closes it', (tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository([kTestAccount])),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider
.overrideWithValue(FakeEmailRepository()),
],
));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.search));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.arrow_back));
await tester.pumpAndSettle();
expect(find.text('Search…'), findsNothing);
expect(find.text('INBOX'), findsOneWidget);
});
});
}
+247
View File
@@ -0,0 +1,247 @@
// Shared helpers for widget tests.
//
// Each test pumps [buildApp] which wires up a fresh GoRouter (same route tree
// as the real app) inside a ProviderScope whose repository providers are
// replaced with lightweight in-memory fakes. No database or network is used.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
import 'package:sharedinbox/ui/screens/add_account_screen.dart';
import 'package:sharedinbox/ui/screens/compose_screen.dart';
import 'package:sharedinbox/ui/screens/email_detail_screen.dart';
import 'package:sharedinbox/ui/screens/email_list_screen.dart';
import 'package:sharedinbox/ui/screens/mailbox_list_screen.dart';
import 'package:sharedinbox/ui/screens/settings_screen.dart';
// ---------------------------------------------------------------------------
// Fake repositories
// ---------------------------------------------------------------------------
class FakeAccountRepository implements AccountRepository {
final List<Account> _accounts;
FakeAccountRepository([List<Account>? accounts])
: _accounts = List.of(accounts ?? []);
@override
Stream<List<Account>> observeAccounts() => Stream.value(List.of(_accounts));
@override
Future<Account?> getAccount(String id) async {
for (final a in _accounts) {
if (a.id == id) return a;
}
return null;
}
@override
Future<void> addAccount(Account account, String password) async =>
_accounts.add(account);
@override
Future<void> removeAccount(String id) async =>
_accounts.removeWhere((a) => a.id == id);
@override
Future<String> getPassword(String accountId) async => 'test-password';
}
class FakeMailboxRepository implements MailboxRepository {
final List<Mailbox> _mailboxes;
FakeMailboxRepository([List<Mailbox>? mailboxes])
: _mailboxes = mailboxes ?? [];
@override
Stream<List<Mailbox>> observeMailboxes(String accountId) =>
Stream.value(List.of(_mailboxes));
@override
Future<void> syncMailboxes(String accountId) async {}
}
class FakeEmailRepository implements EmailRepository {
final List<Email> _emails;
final Email? _emailDetail;
final EmailBody _emailBody;
FakeEmailRepository({
List<Email>? emails,
Email? emailDetail,
EmailBody? emailBody,
}) : _emails = emails ?? [],
_emailDetail = emailDetail,
_emailBody =
emailBody ?? const EmailBody(emailId: '', attachments: []);
@override
Stream<List<Email>> observeEmails(String accountId, String mailboxPath) =>
Stream.value(List.of(_emails));
@override
Future<Email?> getEmail(String emailId) async => _emailDetail;
@override
Future<EmailBody> getEmailBody(String emailId) async => _emailBody;
@override
Future<void> syncEmails(String accountId, String mailboxPath) async {}
@override
Future<void> setFlag(String emailId, {bool? seen, bool? flagged}) async {}
@override
Future<void> moveEmail(String emailId, String destMailboxPath) async {}
@override
Future<void> deleteEmail(String emailId) async {}
@override
Future<void> sendEmail(String accountId, EmailDraft draft) async {}
@override
Future<List<Email>> searchEmails(
String accountId,
String mailboxPath,
String query,
) async =>
[];
}
// ---------------------------------------------------------------------------
// App builder
// ---------------------------------------------------------------------------
/// Builds a fully wired test app that starts at [initialLocation].
///
/// Providers are replaced with [overrides], so no database or network is used.
/// A fresh [GoRouter] is created for every call so tests are independent.
Widget buildApp({
required String initialLocation,
required List<Override> overrides,
}) {
final testRouter = GoRouter(
initialLocation: initialLocation,
routes: [
GoRoute(
path: '/accounts',
builder: (ctx, state) => const AccountListScreen(),
routes: [
GoRoute(
path: 'add',
builder: (ctx, state) => const AddAccountScreen(),
),
GoRoute(
path: ':accountId/mailboxes',
builder: (ctx, state) => MailboxListScreen(
accountId: state.pathParameters['accountId']!,
),
routes: [
GoRoute(
path: ':mailboxPath/emails',
builder: (ctx, state) => EmailListScreen(
accountId: state.pathParameters['accountId']!,
mailboxPath: state.pathParameters['mailboxPath']!,
),
routes: [
GoRoute(
path: ':emailId',
builder: (ctx, state) => EmailDetailScreen(
emailId: state.pathParameters['emailId']!,
),
),
],
),
],
),
],
),
GoRoute(
path: '/compose',
builder: (ctx, state) {
final extra = state.extra as Map<String, dynamic>?;
return ComposeScreen(
accountId: extra?['accountId'] as String?,
replyToEmailId: extra?['replyToEmailId'] as String?,
prefillTo: extra?['prefillTo'] as String?,
prefillCc: extra?['prefillCc'] as String?,
prefillSubject: extra?['prefillSubject'] as String?,
prefillBody: extra?['prefillBody'] as String?,
);
},
),
GoRoute(
path: '/settings',
builder: (ctx, state) => const SettingsScreen(),
),
],
);
return ProviderScope(
overrides: overrides,
child: MaterialApp.router(
routerConfig: testRouter,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
),
);
}
// ---------------------------------------------------------------------------
// Common test fixtures
// ---------------------------------------------------------------------------
const kTestAccount = Account(
id: 'acc-1',
displayName: 'Alice',
email: 'alice@example.com',
imapHost: 'imap.example.com',
imapPort: 993,
imapSsl: true,
smtpHost: 'smtp.example.com',
smtpPort: 587,
smtpSsl: false,
);
const kTestMailbox = Mailbox(
id: 'acc-1:INBOX',
accountId: 'acc-1',
path: 'INBOX',
name: 'INBOX',
unreadCount: 3,
totalCount: 10,
);
Email testEmail({
String id = 'acc-1:42',
String subject = 'Hello world',
bool isSeen = false,
bool isFlagged = false,
bool hasAttachment = false,
}) =>
Email(
id: id,
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 42,
subject: subject,
receivedAt: DateTime(2024, 6),
sentAt: DateTime(2024, 6),
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
to: const [EmailAddress(email: 'alice@example.com')],
cc: const [],
isSeen: isSeen,
isFlagged: isFlagged,
hasAttachment: hasAttachment,
);
+72
View File
@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/di.dart';
import 'helpers.dart';
void main() {
group('MailboxListScreen', () {
testWidgets('shows mailbox name', (tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts/acc-1/mailboxes',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository([kTestAccount])),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository([kTestMailbox])),
emailRepositoryProvider
.overrideWithValue(FakeEmailRepository()),
],
));
await tester.pumpAndSettle();
expect(find.text('INBOX'), findsWidgets);
});
testWidgets('shows unread badge when unreadCount > 0', (tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/accounts/acc-1/mailboxes',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository([kTestAccount])),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository([kTestMailbox])),
emailRepositoryProvider
.overrideWithValue(FakeEmailRepository()),
],
));
await tester.pumpAndSettle();
// kTestMailbox has unreadCount = 3
expect(find.text('3'), findsOneWidget);
});
testWidgets('shows no badge when unreadCount is zero', (tester) async {
const emptyMailbox = Mailbox(
id: 'acc-1:Sent',
accountId: 'acc-1',
path: 'Sent',
name: 'Sent',
unreadCount: 0,
totalCount: 5,
);
await tester.pumpWidget(buildApp(
initialLocation: '/accounts/acc-1/mailboxes',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository([kTestAccount])),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository([emptyMailbox])),
emailRepositoryProvider
.overrideWithValue(FakeEmailRepository()),
],
));
await tester.pumpAndSettle();
expect(find.text('Sent'), findsOneWidget);
expect(find.byType(Badge), findsNothing);
});
});
}
+92
View File
@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/di.dart';
import 'helpers.dart';
void main() {
group('SettingsScreen', () {
testWidgets('shows "Accounts" section header', (tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/settings',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository()),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider
.overrideWithValue(FakeEmailRepository()),
],
));
await tester.pumpAndSettle();
expect(find.text('Accounts'), findsOneWidget);
});
testWidgets('shows account tile when an account exists', (tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/settings',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository([kTestAccount])),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider
.overrideWithValue(FakeEmailRepository()),
],
));
await tester.pumpAndSettle();
expect(find.text('Alice'), findsOneWidget);
expect(find.text('alice@example.com'), findsOneWidget);
});
testWidgets('tapping delete icon shows confirmation dialog',
(tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/settings',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository([kTestAccount])),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider
.overrideWithValue(FakeEmailRepository()),
],
));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.delete));
await tester.pumpAndSettle();
expect(find.text('Remove account?'), findsOneWidget);
expect(find.text('Cancel'), findsOneWidget);
expect(find.text('Remove'), findsOneWidget);
});
testWidgets('tapping Cancel in the confirmation dialog dismisses it',
(tester) async {
await tester.pumpWidget(buildApp(
initialLocation: '/settings',
overrides: [
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository([kTestAccount])),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider
.overrideWithValue(FakeEmailRepository()),
],
));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.delete));
await tester.pumpAndSettle();
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(find.text('Remove account?'), findsNothing);
expect(find.text('Alice'), findsOneWidget); // account still visible
});
});
}