test.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user