tests.
This commit is contained in:
+13
-45
@@ -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]
|
||||
|
||||
@@ -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)',
|
||||
);
|
||||
}
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
START=$(date +%s)
|
||||
flutter analyze
|
||||
END=$(date +%s)
|
||||
echo "analyze: $((END - START))s"
|
||||
Executable
+7
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user