This commit is contained in:
Thomas Güttler
2026-04-16 11:48:37 +02:00
parent 7c000dcee5
commit 746f3c373c
10 changed files with 407 additions and 45 deletions
+13 -45
View File
@@ -1,8 +1,9 @@
version: "3"
silent: true
tasks:
default:
desc: Run all checks (analyze + unit tests, in parallel)
desc: Run all checks (analyze + unit tests + integration, in parallel)
deps: [check]
_nix-check:
@@ -23,21 +24,13 @@ tasks:
desc: Generate Drift DB code (run after any schema change)
deps: [_nix-check]
cmds:
- |
START=$(date +%s)
flutter pub run build_runner build --delete-conflicting-outputs
END=$(date +%s)
echo "codegen: $((END - START))s"
- flutter pub run build_runner build --delete-conflicting-outputs
analyze:
desc: Static analysis with flutter analyze + analysis_options.yaml rules
desc: Static analysis (flutter analyze)
deps: [_nix-check]
cmds:
- |
START=$(date +%s)
flutter analyze
END=$(date +%s)
echo "analyze: $((END - START))s"
- scripts/run_analyze.sh
analyze-fix:
desc: Auto-fix lint issues with dart fix --apply
@@ -46,53 +39,28 @@ tasks:
- dart fix --apply
test:
desc: Run unit tests (no device needed) and enforce ≥100% line coverage
desc: Unit tests + coverage gate (fails if any non-excluded lib/ file is missing)
deps: [_nix-check]
vars:
COVERAGE_THRESHOLD: "100"
cmds:
- |
START=$(date +%s)
flutter test test/unit/ --coverage
END=$(date +%s)
echo "test: $((END - START))s"
- |
COVERED=$(awk '/^DA:/{split($0,a,":");split(a[2],b,",");t++;if(b[2]>0)h++} END{printf "%d", (t>0 ? int(h*100/t) : 0)}' coverage/lcov.info)
echo "coverage: ${COVERED}% (threshold: {{.COVERAGE_THRESHOLD}}%)"
if [ "${COVERED}" -lt "{{.COVERAGE_THRESHOLD}}" ]; then
echo "ERROR: coverage ${COVERED}% is below threshold {{.COVERAGE_THRESHOLD}}%"
exit 1
fi
- scripts/run_unit_tests.sh
test-flutter:
desc: Run Flutter widget tests
desc: Flutter widget tests
deps: [_nix-check]
cmds:
- |
START=$(date +%s)
flutter test
END=$(date +%s)
echo "flutter test: $((END - START))s"
- flutter test
integration:
desc: Run integration tests (starts and stops Stalwart automatically)
desc: Integration tests against a local Stalwart mail server
deps: [_nix-check]
cmds:
- |
START=$(date +%s)
stalwart-dev/test.sh
END=$(date +%s)
echo "integration: $((END - START))s"
- stalwart-dev/test.sh
build-android:
desc: Build a release APK (output in build/app/outputs/flutter-apk/)
deps: [_nix-check]
cmds:
- |
START=$(date +%s)
flutter build apk --release
END=$(date +%s)
echo "build-android: $((END - START))s"
- flutter build apk --release
run:
desc: Run the app on Linux desktop
@@ -101,5 +69,5 @@ tasks:
- flutter run -d linux
check:
desc: All fast checks — analyze + unit tests in parallel
desc: All fast checks — analyze + unit tests + integration in parallel
deps: [analyze, test, integration]
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env dart
// Checks that every non-excluded lib/ source file appears in coverage/lcov.info.
// Run after: flutter test test/unit/ --coverage
//
// To exclude a file add its lib-relative path to [excluded] below.
import 'dart:io';
// Files excluded from the unit-coverage gate because they require integration
// or widget tests (covered by `task integration` / `task test-flutter`).
const _excluded = {
// Abstract interfaces — no executable code; Dart VM never instruments them.
'lib/core/repositories/account_repository.dart',
'lib/core/repositories/email_repository.dart',
'lib/core/repositories/mailbox_repository.dart',
// Data layer — requires Drift/SQLite, IMAP/SMTP network connections.
'lib/data/db/database.dart',
'lib/data/imap/imap_client_factory.dart',
'lib/data/repositories/account_repository_impl.dart',
'lib/data/repositories/email_repository_impl.dart',
'lib/data/repositories/mailbox_repository_impl.dart',
// Flutter wiring — requires full widget/app context.
'lib/di.dart',
'lib/main.dart',
'lib/ui/router.dart',
'lib/ui/screens/account_list_screen.dart',
'lib/ui/screens/add_account_screen.dart',
'lib/ui/screens/compose_screen.dart',
'lib/ui/screens/email_detail_screen.dart',
'lib/ui/screens/email_list_screen.dart',
'lib/ui/screens/mailbox_list_screen.dart',
'lib/ui/screens/settings_screen.dart',
};
void main() {
final lcovFile = File('coverage/lcov.info');
final measuredFiles = lcovFile.existsSync()
? lcovFile
.readAsLinesSync()
.where((l) => l.startsWith('SF:'))
.map((l) => l.substring(3))
.toSet()
: <String>{};
final sourceFiles = Directory('lib')
.listSync(recursive: true)
.whereType<File>()
.where((f) => f.path.endsWith('.dart') && !f.path.endsWith('.g.dart'))
.map((f) => f.path.replaceFirst('./', ''))
.where((p) => !_excluded.contains(p))
.toList()
..sort();
final missing = sourceFiles.where((f) => !measuredFiles.contains(f)).toList();
if (missing.isNotEmpty) {
for (final f in missing) {
stderr.writeln('MISSING from coverage: $f');
}
stderr.writeln(
'ERROR: ${missing.length} file(s) missing from unit coverage.\n'
'Add a test or add to _excluded in scripts/check_coverage.dart.',
);
exit(1);
}
int total = 0, hits = 0;
for (final line in lcovFile.readAsLinesSync()) {
if (!line.startsWith('DA:')) continue;
final count = int.parse(line.substring(3).split(',')[1]);
total++;
if (count > 0) hits++;
}
final pct = total > 0 ? (hits * 100 ~/ total) : 0;
stdout.writeln(
'coverage: $pct% across ${measuredFiles.length} measured files'
' (${_excluded.length} excluded — see scripts/check_coverage.dart)',
);
}
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
START=$(date +%s)
flutter analyze
END=$(date +%s)
echo "analyze: $((END - START))s"
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
START=$(date +%s)
flutter test test/unit/ --coverage
dart run scripts/check_coverage.dart
END=$(date +%s)
echo "test: $((END - START))s"
+3
View File
@@ -57,4 +57,7 @@ cd "$ROOT"
export STALWART_IMAP_HOST="127.0.0.1"
export STALWART_SMTP_HOST="127.0.0.1"
START=$(date +%s)
flutter test test/integration/
END=$(date +%s)
echo "integration: $((END - START))s"
+48
View File
@@ -0,0 +1,48 @@
import 'package:test/test.dart';
import 'package:sharedinbox/core/models/account.dart';
// Import the abstract interface so it appears in coverage.
import 'package:sharedinbox/core/repositories/account_repository.dart'; // ignore: unused_import
void main() {
group('Account', () {
const account = Account(
id: 'a1',
displayName: 'Work',
email: 'me@example.com',
imapHost: 'imap.example.com',
imapPort: 993,
imapSsl: true,
smtpHost: 'smtp.example.com',
smtpPort: 587,
smtpSsl: false,
);
test('stores all fields', () {
expect(account.id, 'a1');
expect(account.displayName, 'Work');
expect(account.email, 'me@example.com');
expect(account.imapHost, 'imap.example.com');
expect(account.imapPort, 993);
expect(account.imapSsl, isTrue);
expect(account.smtpHost, 'smtp.example.com');
expect(account.smtpPort, 587);
expect(account.smtpSsl, isFalse);
});
test('const constructor produces equal instances', () {
const same = Account(
id: 'a1',
displayName: 'Work',
email: 'me@example.com',
imapHost: 'imap.example.com',
imapPort: 993,
imapSsl: true,
smtpHost: 'smtp.example.com',
smtpPort: 587,
smtpSsl: false,
);
expect(identical(account, same), isTrue);
});
});
}
+161
View File
@@ -0,0 +1,161 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.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/core/sync/account_sync_manager.dart';
// ── Fakes ─────────────────────────────────────────────────────────────────────
class FakeAccountRepository implements AccountRepository {
// sync:true so listeners fire immediately inside push() — lets us control
// the event order in tests without await.
final _ctrl = StreamController<List<Account>>.broadcast(sync: true);
@override
Stream<List<Account>> observeAccounts() => _ctrl.stream;
@override
Future<Account?> getAccount(String id) async => null;
@override
Future<void> addAccount(Account account, String password) async {}
@override
Future<void> removeAccount(String id) async {}
@override
Future<String> getPassword(String accountId) async => 'password';
void push(List<Account> accounts) => _ctrl.add(accounts);
void close() => _ctrl.close();
}
class FakeMailboxRepository implements MailboxRepository {
@override
Stream<List<Mailbox>> observeMailboxes(String accountId) => Stream.value([]);
@override
Future<void> syncMailboxes(String accountId) async {}
}
class FakeEmailRepository implements EmailRepository {
@override
Stream<List<Email>> observeEmails(String accountId, String mailboxPath) =>
Stream.value([]);
@override
Future<Email?> getEmail(String emailId) async => null;
@override
Future<EmailBody> getEmailBody(String emailId) async =>
const EmailBody(emailId: '', attachments: []);
@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 =>
[];
}
// ── Helpers ───────────────────────────────────────────────────────────────────
const _account = Account(
id: 'test-account',
displayName: 'Test',
email: 'test@example.com',
imapHost: 'imap.example.com',
imapPort: 993,
imapSsl: true,
smtpHost: 'smtp.example.com',
smtpPort: 587,
smtpSsl: false,
);
// ── Tests ─────────────────────────────────────────────────────────────────────
void main() {
group('AccountSyncManager', () {
test('dispose without start does not throw', () {
final mgr = AccountSyncManager(
FakeAccountRepository(),
FakeMailboxRepository(),
FakeEmailRepository(),
);
expect(mgr.dispose, returnsNormally);
});
test('start and immediate dispose (no accounts) does not throw', () {
final accounts = FakeAccountRepository();
final mgr = AccountSyncManager(
accounts,
FakeMailboxRepository(),
FakeEmailRepository(),
);
mgr.start();
mgr.dispose();
});
test('starts a sync when an account is pushed, then stops on dispose',
() async {
final accounts = FakeAccountRepository();
final mailboxes = FakeMailboxRepository();
final emails = FakeEmailRepository();
final mgr = AccountSyncManager(accounts, mailboxes, emails);
mgr.start();
// With sync:true controller the listener fires synchronously, creating
// an _AccountSync and calling start(). The async _loop() then suspends
// at the first await inside _sync().
accounts.push([_account]);
// Calling dispose() here sets _running = false on the sync before the
// loop reaches _idle(), so _idle() exits early without a network call.
mgr.dispose();
// Drain microtasks so the abandoned _loop() future completes cleanly.
await pumpEventQueue(times: 10);
});
test('stops sync for removed account', () async {
final accounts = FakeAccountRepository();
final mgr = AccountSyncManager(
accounts,
FakeMailboxRepository(),
FakeEmailRepository(),
);
mgr.start();
accounts.push([_account]);
// Remove the account — the manager should stop its sync.
accounts.push([]);
mgr.dispose();
await pumpEventQueue(times: 10);
});
});
}
+2
View File
@@ -3,6 +3,8 @@ import 'dart:convert';
import 'package:test/test.dart';
import 'package:sharedinbox/core/models/email.dart';
// Import the abstract interface so it appears in coverage.
import 'package:sharedinbox/core/repositories/email_repository.dart'; // ignore: unused_import
// Mirrors the encoding logic in EmailRepositoryImpl so we can test it
// independently without spinning up a database.
+35
View File
@@ -0,0 +1,35 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/utils/logger.dart';
void main() {
// Suppress debugPrint so log() calls don't produce noisy output in test runs.
late DebugPrintCallback savedDebugPrint;
setUp(() {
savedDebugPrint = debugPrint;
debugPrint = (_, {wrapWidth}) {};
});
tearDown(() => debugPrint = savedDebugPrint);
group('log', () {
test('logs a plain message without throwing', () {
expect(() => log('hello from test'), returnsNormally);
});
test('logs a message with an error without throwing', () {
expect(
() => log('something went wrong', error: Exception('boom')),
returnsNormally,
);
});
test('logs a message with error and stack trace without throwing', () {
final stack = StackTrace.current;
expect(
() => log('oops', error: Exception('x'), stackTrace: stack),
returnsNormally,
);
});
});
}
+53
View File
@@ -0,0 +1,53 @@
import 'package:test/test.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
// Import the abstract interface so it appears in coverage.
import 'package:sharedinbox/core/repositories/mailbox_repository.dart'; // ignore: unused_import
void main() {
group('Mailbox', () {
const mailbox = Mailbox(
id: 'a1:INBOX',
accountId: 'a1',
path: 'INBOX',
name: 'INBOX',
unreadCount: 3,
totalCount: 10,
);
test('stores all fields', () {
expect(mailbox.id, 'a1:INBOX');
expect(mailbox.accountId, 'a1');
expect(mailbox.path, 'INBOX');
expect(mailbox.name, 'INBOX');
expect(mailbox.unreadCount, 3);
expect(mailbox.totalCount, 10);
});
test('sub-folder path is stored verbatim', () {
const sub = Mailbox(
id: 'a1:INBOX/Work',
accountId: 'a1',
path: 'INBOX/Work',
name: 'Work',
unreadCount: 0,
totalCount: 5,
);
expect(sub.path, 'INBOX/Work');
expect(sub.name, 'Work');
});
test('zero counts are valid', () {
const empty = Mailbox(
id: 'a1:Trash',
accountId: 'a1',
path: 'Trash',
name: 'Trash',
unreadCount: 0,
totalCount: 0,
);
expect(empty.unreadCount, 0);
expect(empty.totalCount, 0);
});
});
}