Notrufnummern-Datenbasis, Ländererkennung, State-Management und Lokalisierung (AP1-AP7)

Implementiert den kompletten Kern-Flow der Emergency App gemäß docs/plan.md:
- Notrufnummern-Datenbasis für 199 Länder + Modelle/Repository (AP1)
- DialerService für tel:-Notrufe ohne Auto-Dial (AP2)
- Statisches UI mit Panik-Button-Fallback (AP3)
- Priorisierte Ländererkennung: SIM/Netz -> GPS/Geocoding -> Cache (AP4)
- EmergencyProvider verdrahtet Repository/LocationService mit der UI (AP5)
- Manuelle Länderauswahl als Fallback samt eigener Priorität (AP6)
- UI-Lokalisierung (de/en/fr/es), an erkanntes Land gekoppelt (AP7)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
root
2026-07-24 11:29:38 +02:00
co-authored by Claude Sonnet 5
parent 40e95cc495
commit e3aa088bc2
54 changed files with 7550 additions and 145 deletions
+91
View File
@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:emergency/app.dart';
import 'package:emergency/repositories/emergency_number_repository.dart';
import 'package:emergency/services/country_code_sources/country_code_source.dart';
import 'package:emergency/services/location_service.dart';
/// Siehe `test/widget_test.dart` für die Begründung dieser Fakes
/// (deterministische Ländererkennung ohne echte Plattform-Kanäle).
class _FakeSource implements CountryCodeSource {
_FakeSource(this._result);
final String? _result;
@override
Future<String?> resolve() async => _result;
}
class _FakeCache implements CountryCodeCache {
@override
Future<String?> resolve() async => null;
@override
Future<void> save(String isoCode) async {}
}
class _FakeManualSource implements ManualCountryCodeStore {
@override
Future<String?> resolve() async => null;
@override
Future<void> save(String? isoCode) async {}
}
/// AP7: Verifiziert, dass die sichtbare App-Sprache tatsächlich an das
/// erkannte Land gekoppelt ist (`docs/plan.md`, Abschnitt 2 + 8), nicht nur,
/// dass `resolveAppLocale` als reine Funktion korrekt rechnet
/// (`test/core/country_locale_map_test.dart`).
Future<void> _pumpAppWithCountry(WidgetTester tester, String? isoCode) async {
final repository = EmergencyNumberRepository();
await tester.runAsync(() => repository.load());
final locationService = LocationService(
simSource: _FakeSource(isoCode),
gpsSource: _FakeSource(null),
cacheSource: _FakeCache(),
manualSource: _FakeManualSource(),
);
await tester.pumpWidget(App(repository: repository, locationService: locationService));
await tester.pumpAndSettle();
}
/// Findet Text konkret innerhalb der `AppBar`, nicht irgendwo im Baum:
/// Der Notrufnummern-Datensatz mancher Länder (z. B. DE: "Notruf", US:
/// "Emergency") verwendet zufällig denselben Text wie der übersetzte
/// AppBar-Titel, ein ungebundener `find.text(...)` wäre daher mehrdeutig.
Finder _appBarText(String text) =>
find.descendant(of: find.byType(AppBar), matching: find.text(text));
void main() {
testWidgets('DE wird erkannt -> AppBar-Titel erscheint auf Deutsch ("Notruf")', (tester) async {
await _pumpAppWithCountry(tester, 'DE');
expect(_appBarText('Notruf'), findsOneWidget);
expect(_appBarText('Emergency'), findsNothing);
});
testWidgets('US wird erkannt -> AppBar-Titel erscheint auf Englisch ("Emergency")', (
tester,
) async {
await _pumpAppWithCountry(tester, 'US');
expect(_appBarText('Emergency'), findsOneWidget);
expect(_appBarText('Notruf'), findsNothing);
});
testWidgets('FR wird erkannt -> AppBar-Titel erscheint auf Französisch ("Urgence")', (
tester,
) async {
await _pumpAppWithCountry(tester, 'FR');
expect(_appBarText('Urgence'), findsOneWidget);
});
testWidgets('Land nicht ermittelbar -> Fallback auf Englisch ("Emergency")', (tester) async {
await _pumpAppWithCountry(tester, null);
expect(_appBarText('Emergency'), findsOneWidget);
});
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:emergency/core/country_locale_map.dart';
import 'package:emergency/l10n/app_localizations.dart';
void main() {
group('resolveAppLocale', () {
// Realistische Basis: die tatsächlich per ARB generierten Locales
// (docs/plan.md, Abschnitt 8, AP7), statt einer synthetischen Liste.
final supportedLocales = AppLocalizations.supportedLocales;
test('bekanntes Land mit unterstützter Sprache liefert diese Sprache (DE -> de)', () {
expect(resolveAppLocale('DE', supportedLocales), const Locale('de'));
});
test('Ländercode ist case-insensitiv (de -> de)', () {
expect(resolveAppLocale('de', supportedLocales), const Locale('de'));
});
test(
'bekanntes Land mit einer NICHT unterstützten Sprache fällt auf Englisch zurück (JP -> ja, nicht unterstützt)',
() {
expect(countryLocaleMap['JP'], 'ja');
expect(supportedLocales.any((l) => l.languageCode == 'ja'), isFalse);
expect(resolveAppLocale('JP', supportedLocales), const Locale('en'));
},
);
test('unbekannter/nicht gemappter Ländercode fällt auf Englisch zurück', () {
expect(countryLocaleMap.containsKey('ZZ'), isFalse);
expect(resolveAppLocale('ZZ', supportedLocales), const Locale('en'));
});
test('country == null (noch nicht ermittelt) fällt auf Englisch zurück', () {
expect(resolveAppLocale(null, supportedLocales), const Locale('en'));
});
test('weitere unterstützte Sprachen werden ebenfalls korrekt aufgelöst (FR -> fr, ES -> es)', () {
expect(resolveAppLocale('FR', supportedLocales), const Locale('fr'));
expect(resolveAppLocale('ES', supportedLocales), const Locale('es'));
});
});
}
@@ -0,0 +1,146 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:emergency/features/home/country_picker_screen.dart';
import 'package:emergency/l10n/app_localizations.dart';
import 'package:emergency/models/country.dart';
import 'package:emergency/models/emergency_number.dart';
import 'package:emergency/models/emergency_type.dart';
import 'package:emergency/providers/emergency_provider.dart';
import 'package:emergency/repositories/emergency_number_repository.dart';
import 'package:emergency/services/country_code_sources/country_code_source.dart';
import 'package:emergency/services/location_service.dart';
/// Liefert eine feste Länderliste, ohne das echte JSON-Asset laden zu
/// müssen (analog zum Fake-Repository-Muster in `emergency_provider_test.dart`).
class _FakeRepository extends EmergencyNumberRepository {
@override
List<Country> get allCountries => const [
Country(
isoCode: 'DE',
nameDe: 'Deutschland',
nameEn: 'Germany',
flag: '🇩🇪',
numbers: [
EmergencyNumber(type: EmergencyType.general, number: '112', labelDe: 'Notruf', labelEn: 'Emergency'),
],
),
Country(
isoCode: 'FR',
nameDe: 'Frankreich',
nameEn: 'France',
flag: '🇫🇷',
numbers: [
EmergencyNumber(type: EmergencyType.general, number: '112', labelDe: 'Notruf', labelEn: 'Emergency'),
],
),
];
}
class _NoopSource implements CountryCodeSource {
const _NoopSource();
@override
Future<String?> resolve() async => null;
}
/// Fake-Provider: überschreibt `selectCountryManually`, damit der Test ohne
/// echte `LocationService`-Ermittlungskette prüfen kann, mit welchem
/// ISO-Code der Screen die Auswahl auslöst.
class _FakeProvider extends EmergencyProvider {
_FakeProvider({required super.repository})
: super(
locationService: LocationService(
simSource: const _NoopSource(),
gpsSource: const _NoopSource(),
),
);
String? selectedIsoCode;
int selectCallCount = 0;
@override
Future<void> selectCountryManually(String isoCode) async {
selectCallCount++;
selectedIsoCode = isoCode;
}
}
void main() {
Future<void> pumpPickerBehindAButton(WidgetTester tester, EmergencyProvider provider) async {
// CountryPickerScreen ruft `Navigator.pop()` beim Auswählen auf dafür
// muss eine vorherige Route existieren, sonst wäre der Pop ein No-op.
await tester.pumpWidget(
ChangeNotifierProvider<EmergencyProvider>.value(
value: provider,
child: MaterialApp(
// Feste Locale, damit die (unveränderten) deutschen Text-Assertions
// dieser Tests unabhängig von der Test-Umgebungs-/Systemsprache
// funktionieren; das eigentliche Umschalten der App-Sprache wird
// gezielt in `test/core/country_locale_map_test.dart` bzw.
// `test/app_locale_test.dart` (AP7) geprüft.
locale: const Locale('de'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => ElevatedButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const CountryPickerScreen()),
),
child: const Text('Öffnen'),
),
),
),
),
);
await tester.tap(find.text('Öffnen'));
await tester.pumpAndSettle();
}
testWidgets('zeigt die Länderliste an', (tester) async {
final provider = _FakeProvider(repository: _FakeRepository());
await pumpPickerBehindAButton(tester, provider);
expect(find.text('Deutschland'), findsOneWidget);
expect(find.text('Frankreich'), findsOneWidget);
});
testWidgets('Suchfeld filtert die Länderliste', (tester) async {
final provider = _FakeProvider(repository: _FakeRepository());
await pumpPickerBehindAButton(tester, provider);
await tester.enterText(find.byType(TextField), 'Deutsch');
await tester.pump();
expect(find.text('Deutschland'), findsOneWidget);
expect(find.text('Frankreich'), findsNothing);
});
testWidgets('zeigt einen Hinweis, wenn kein Land zum Filter passt', (tester) async {
final provider = _FakeProvider(repository: _FakeRepository());
await pumpPickerBehindAButton(tester, provider);
await tester.enterText(find.byType(TextField), 'Nichtland');
await tester.pump();
expect(find.text('Kein Land gefunden.'), findsOneWidget);
});
testWidgets('Tap auf ein Land löst selectCountryManually aus und schließt den Screen', (
tester,
) async {
final provider = _FakeProvider(repository: _FakeRepository());
await pumpPickerBehindAButton(tester, provider);
await tester.tap(find.text('Deutschland'));
await tester.pumpAndSettle();
expect(provider.selectCallCount, 1);
expect(provider.selectedIsoCode, 'DE');
// Screen wurde geschlossen (zurück zum "Öffnen"-Button).
expect(find.text('Öffnen'), findsOneWidget);
expect(find.text('Deutschland'), findsNothing);
});
}
+57
View File
@@ -0,0 +1,57 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:emergency/models/country.dart';
import 'package:emergency/models/emergency_type.dart';
void main() {
group('Country.fromJson', () {
test('parst ein Land mit mehreren Notrufnummern korrekt', () {
final country = Country.fromJson('DE', {
'name_en': 'Germany',
'name_de': 'Deutschland',
'flag': '🇩🇪',
'numbers': [
{
'type': 'general',
'number': '112',
'label_de': 'Notruf',
'label_en': 'Emergency',
},
{
'type': 'police',
'number': '110',
'label_de': 'Polizei',
'label_en': 'Police',
},
],
});
expect(country.isoCode, 'DE');
expect(country.nameDe, 'Deutschland');
expect(country.nameEn, 'Germany');
expect(country.flag, '🇩🇪');
expect(country.numbers, hasLength(2));
expect(country.numbers[0].type, EmergencyType.general);
expect(country.numbers[1].type, EmergencyType.police);
});
test('parst ein Land mit nur einer Notrufnummer korrekt', () {
final country = Country.fromJson('US', {
'name_en': 'United States',
'name_de': 'Vereinigte Staaten',
'flag': '🇺🇸',
'numbers': [
{
'type': 'general',
'number': '911',
'label_de': 'Notruf',
'label_en': 'Emergency',
},
],
});
expect(country.numbers, hasLength(1));
expect(country.numbers.single.number, '911');
});
});
}
+40
View File
@@ -0,0 +1,40 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:emergency/models/emergency_number.dart';
import 'package:emergency/models/emergency_type.dart';
void main() {
group('EmergencyNumber.fromJson', () {
test('parst gültiges JSON korrekt', () {
final number = EmergencyNumber.fromJson({
'type': 'police',
'number': '110',
'label_de': 'Polizei',
'label_en': 'Police',
});
expect(number.type, EmergencyType.police);
expect(number.number, '110');
expect(number.labelDe, 'Polizei');
expect(number.labelEn, 'Police');
});
test('zwei inhaltsgleiche Instanzen sind gleich', () {
final a = EmergencyNumber.fromJson({
'type': 'general',
'number': '112',
'label_de': 'Notruf',
'label_en': 'Emergency',
});
final b = EmergencyNumber.fromJson({
'type': 'general',
'number': '112',
'label_de': 'Notruf',
'label_en': 'Emergency',
});
expect(a, equals(b));
expect(a.hashCode, equals(b.hashCode));
});
});
}
+26
View File
@@ -0,0 +1,26 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:emergency/models/emergency_type.dart';
void main() {
group('EmergencyType', () {
test('fromJson mappt alle bekannten Werte korrekt', () {
expect(EmergencyType.fromJson('general'), EmergencyType.general);
expect(EmergencyType.fromJson('police'), EmergencyType.police);
expect(EmergencyType.fromJson('fire'), EmergencyType.fire);
expect(EmergencyType.fromJson('ambulance'), EmergencyType.ambulance);
expect(EmergencyType.fromJson('sea_rescue'), EmergencyType.seaRescue);
expect(EmergencyType.fromJson('mountain_rescue'), EmergencyType.mountainRescue);
});
test('fromJson wirft FormatException bei unbekanntem Wert', () {
expect(() => EmergencyType.fromJson('unknown'), throwsFormatException);
});
test('toJson ist invers zu fromJson', () {
for (final type in EmergencyType.values) {
expect(EmergencyType.fromJson(type.toJson()), type);
}
});
});
}
+247
View File
@@ -0,0 +1,247 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:emergency/models/country.dart';
import 'package:emergency/models/emergency_number.dart';
import 'package:emergency/models/emergency_type.dart';
import 'package:emergency/providers/emergency_provider.dart';
import 'package:emergency/providers/emergency_status.dart';
import 'package:emergency/repositories/emergency_number_repository.dart';
import 'package:emergency/services/country_code_sources/country_code_source.dart';
import 'package:emergency/services/location_service.dart';
/// Einfache manuelle Fakes statt Mocking-Package (siehe AP4-Vorgabe: kein
/// neues Mocking-Package einführen). Beide Basisklassen sind nicht `final`/
/// `sealed`, daher genügt es, `load`/`lookup` bzw. `resolveCountryCode` zu
/// überschreiben, statt ein eigenes Interface einzuführen.
class _FakeRepository extends EmergencyNumberRepository {
_FakeRepository({this.loadError, this.countryToReturn});
Object? loadError;
Country? countryToReturn;
int loadCallCount = 0;
@override
Future<void> load() async {
loadCallCount++;
final error = loadError;
if (error != null) throw error;
}
@override
Country? lookup(String isoCode) => countryToReturn;
}
/// Nur zur Erfüllung des `super()`-Konstruktors von [LocationService] nötig
/// (wird nie aufgerufen, da [_FakeLocationService.resolveCountryCode]
/// überschrieben ist). Ohne einen expliziten `gpsSource` würde
/// `LocationService()` intern ein echtes `GpsCountryCodeSource` (und damit
/// `Geocoding()`) konstruieren, was außerhalb eines registrierten
/// Platform-Interface (siehe `gps_country_code_source_test.dart`) sofort wirft.
class _NoopSource implements CountryCodeSource {
const _NoopSource();
@override
Future<String?> resolve() async => null;
}
class _FakeLocationService extends LocationService {
_FakeLocationService(this.isoCode)
: super(simSource: const _NoopSource(), gpsSource: const _NoopSource());
/// Simuliert das Ergebnis der automatischen Kette (SIM → GPS → Cache).
String? isoCode;
/// Simuliert die manuelle Auswahl (AP6): hat, analog zur echten
/// Priorisierung in `LocationService`, Vorrang vor [isoCode].
String? manualIsoCode;
int resolveCallCount = 0;
int setManualCallCount = 0;
String? lastManualCode;
@override
Future<String?> resolveCountryCode() async {
resolveCallCount++;
return manualIsoCode ?? isoCode;
}
@override
Future<void> setManualCountryCode(String? code) async {
setManualCallCount++;
lastManualCode = code;
manualIsoCode = code;
}
}
const _germany = Country(
isoCode: 'DE',
nameDe: 'Deutschland',
nameEn: 'Germany',
flag: '🇩🇪',
numbers: [
EmergencyNumber(type: EmergencyType.general, number: '112', labelDe: 'Notruf', labelEn: 'Emergency'),
],
);
const _france = Country(
isoCode: 'FR',
nameDe: 'Frankreich',
nameEn: 'France',
flag: '🇫🇷',
numbers: [
EmergencyNumber(type: EmergencyType.general, number: '112', labelDe: 'Notruf', labelEn: 'Emergency'),
],
);
void main() {
group('EmergencyProvider.init()', () {
test('erfolgreicher Durchlauf -> status ready mit Land', () async {
final provider = EmergencyProvider(
repository: _FakeRepository(countryToReturn: _germany),
locationService: _FakeLocationService('DE'),
);
await provider.init();
expect(provider.status, EmergencyStatus.ready);
expect(provider.country, _germany);
expect(provider.errorMessage, isNull);
});
test('Ländercode nicht ermittelbar -> status countryUnknown', () async {
final provider = EmergencyProvider(
repository: _FakeRepository(countryToReturn: _germany),
locationService: _FakeLocationService(null),
);
await provider.init();
expect(provider.status, EmergencyStatus.countryUnknown);
expect(provider.country, isNull);
});
test('Land nicht in Datenbasis -> status ready mit country == null', () async {
final provider = EmergencyProvider(
repository: _FakeRepository(countryToReturn: null),
locationService: _FakeLocationService('XX'),
);
await provider.init();
expect(provider.status, EmergencyStatus.ready);
expect(provider.country, isNull);
});
test('repository.load() wirft -> status error mit errorMessage', () async {
final repository = _FakeRepository(loadError: const FormatException('kaputtes Asset'));
final locationService = _FakeLocationService('DE');
final provider = EmergencyProvider(repository: repository, locationService: locationService);
await provider.init();
expect(provider.status, EmergencyStatus.error);
expect(provider.errorMessage, isNotNull);
expect(repository.loadCallCount, 1);
// Ländererkennung darf nach einem Ladefehler nicht mehr angestoßen werden.
expect(locationService.resolveCallCount, 0);
});
test('durchläuft die Status initializing -> locating -> ready', () async {
final provider = EmergencyProvider(
repository: _FakeRepository(countryToReturn: _germany),
locationService: _FakeLocationService('DE'),
);
final observedStatuses = <EmergencyStatus>[];
provider.addListener(() => observedStatuses.add(provider.status));
await provider.init();
expect(observedStatuses, [
EmergencyStatus.initializing,
EmergencyStatus.locating,
EmergencyStatus.ready,
]);
});
});
group('EmergencyProvider.retry()', () {
test('wiederholt init() und kann nach einem Fehler erfolgreich sein', () async {
final repository = _FakeRepository(
loadError: const FormatException('kaputtes Asset'),
countryToReturn: _germany,
);
final locationService = _FakeLocationService('DE');
final provider = EmergencyProvider(repository: repository, locationService: locationService);
await provider.init();
expect(provider.status, EmergencyStatus.error);
expect(repository.loadCallCount, 1);
repository.loadError = null;
await provider.retry();
expect(provider.status, EmergencyStatus.ready);
expect(provider.country, _germany);
expect(provider.errorMessage, isNull);
expect(repository.loadCallCount, 2);
});
});
group('EmergencyProvider.selectCountryManually()', () {
test('persistiert die Auswahl und stößt init() erneut an', () async {
final repository = _FakeRepository(countryToReturn: _germany);
final locationService = _FakeLocationService('DE');
final provider = EmergencyProvider(repository: repository, locationService: locationService);
await provider.init();
expect(provider.country, _germany);
repository.countryToReturn = _france;
await provider.selectCountryManually('FR');
expect(locationService.setManualCallCount, 1);
expect(locationService.lastManualCode, 'FR');
// init() wurde ein zweites Mal komplett durchlaufen.
expect(locationService.resolveCallCount, 2);
expect(provider.status, EmergencyStatus.ready);
expect(provider.country, _france);
});
});
group('EmergencyProvider.clearManualSelection()', () {
test('löscht die manuelle Auswahl und ermittelt danach erneut automatisch', () async {
// isoCode simuliert das Ergebnis der automatischen Kette (z. B. SIM),
// manualIsoCode eine bereits zuvor gesetzte manuelle Auswahl.
final locationService = _FakeLocationService('DE')..manualIsoCode = 'FR';
final repository = _FakeRepository(countryToReturn: _france);
final provider = EmergencyProvider(repository: repository, locationService: locationService);
await provider.init();
expect(provider.country, _france); // manuelle Auswahl hat Vorrang
repository.countryToReturn = _germany;
await provider.clearManualSelection();
expect(locationService.setManualCallCount, 1);
expect(locationService.lastManualCode, isNull);
expect(locationService.manualIsoCode, isNull);
expect(locationService.resolveCallCount, 2);
expect(provider.status, EmergencyStatus.ready);
// Nach dem Löschen greift wieder das automatische Ergebnis (DE).
expect(provider.country, _germany);
});
});
group('EmergencyProvider.repository', () {
test('exponiert das intern verwendete Repository', () {
final repository = _FakeRepository(countryToReturn: _germany);
final provider = EmergencyProvider(
repository: repository,
locationService: _FakeLocationService('DE'),
);
expect(provider.repository, same(repository));
});
});
}
@@ -0,0 +1,97 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:emergency/repositories/emergency_number_repository.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('EmergencyNumberRepository', () {
test('lookup() vor load() liefert null statt Exception', () {
final repository = EmergencyNumberRepository();
expect(repository.isLoaded, isFalse);
expect(repository.lookup('DE'), isNull);
});
test('load() liest das Asset ein und lookup() liefert bekannte Länder', () async {
final repository = EmergencyNumberRepository();
await repository.load();
expect(repository.isLoaded, isTrue);
final germany = repository.lookup('DE');
expect(germany, isNotNull);
expect(germany!.nameEn, 'Germany');
expect(germany.nameDe, 'Deutschland');
expect(germany.numbers.map((e) => e.number), containsAll(['112', '110']));
final us = repository.lookup('US');
expect(us, isNotNull);
expect(us!.nameEn, 'United States');
expect(us.numbers.single.number, '911');
final uk = repository.lookup('GB');
expect(uk, isNotNull);
expect(uk!.nameEn, 'United Kingdom');
expect(uk.numbers.map((e) => e.number), contains('999'));
});
test('lookup() ist case-insensitive', () async {
final repository = EmergencyNumberRepository();
await repository.load();
final lower = repository.lookup('de');
final mixed = repository.lookup('De');
final upper = repository.lookup('DE');
expect(lower, isNotNull);
expect(lower, equals(upper));
expect(mixed, equals(upper));
});
test('lookup() mit unbekanntem Ländercode liefert null', () async {
final repository = EmergencyNumberRepository();
await repository.load();
expect(repository.lookup('ZZ'), isNull);
expect(repository.lookup(''), isNull);
});
test('load() ist idempotent (zweiter Aufruf lädt nicht erneut)', () async {
final repository = EmergencyNumberRepository();
await repository.load();
final firstLookup = repository.lookup('DE');
await repository.load();
final secondLookup = repository.lookup('DE');
expect(firstLookup, equals(secondLookup));
});
});
group('EmergencyNumberRepository.allCountries', () {
test('vor load() liefert eine leere Liste statt Exception', () {
final repository = EmergencyNumberRepository();
expect(repository.allCountries, isEmpty);
});
test('nach load() nicht leer und enthält bekannte Länder (DE/US)', () async {
final repository = EmergencyNumberRepository();
await repository.load();
final countries = repository.allCountries;
expect(countries, isNotEmpty);
expect(countries.map((c) => c.isoCode), containsAll(['DE', 'US']));
});
test('ist alphabetisch nach nameDe sortiert', () async {
final repository = EmergencyNumberRepository();
await repository.load();
final names = repository.allCountries.map((c) => c.nameDe).toList();
final sortedNames = [...names]..sort();
expect(names, equals(sortedNames));
});
});
}
@@ -0,0 +1,48 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:emergency/services/country_code_sources/cached_country_code_source.dart';
/// Im Gegensatz zu den SIM-/GPS-Quellen ist `shared_preferences` über
/// `SharedPreferences.setMockInitialValues` vollständig ohne echten
/// Plattform-Kanal testbar daher hier ein echter Verhaltenstest statt nur
/// eines Struktur-Tests.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('CachedCountryCodeSource', () {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('resolve() liefert null, wenn noch kein Code gespeichert ist', () async {
const source = CachedCountryCodeSource();
expect(await source.resolve(), isNull);
});
test('save() gefolgt von resolve() liefert den gespeicherten, normalisierten Code', () async {
const source = CachedCountryCodeSource();
await source.save('de');
expect(await source.resolve(), 'DE');
});
test('resolve() liefert null, wenn der gespeicherte Wert leer ist', () async {
SharedPreferences.setMockInitialValues({'last_known_country_code': ' '});
const source = CachedCountryCodeSource();
expect(await source.resolve(), isNull);
});
test('save() überschreibt einen zuvor gespeicherten Code', () async {
const source = CachedCountryCodeSource();
await source.save('DE');
await source.save('fr');
expect(await source.resolve(), 'FR');
});
});
}
@@ -0,0 +1,171 @@
import 'package:flutter/widgets.dart' show Locale;
import 'package:flutter_test/flutter_test.dart';
import 'package:geocoding_platform_interface/geocoding_platform_interface.dart' as pi;
import 'package:geolocator_platform_interface/geolocator_platform_interface.dart';
import 'package:emergency/services/country_code_sources/gps_country_code_source.dart';
/// Analog zum `url_launcher`-Test von `DialerService`: `geolocator` und
/// `geocoding` erlauben es, ihre `*Platform.instance` durch eine eigene
/// Implementierung zu ersetzen der offiziell vorgesehene Weg, diese
/// Packages ohne echten Plattform-Kanal zu testen.
class _FakeGeolocatorPlatform extends GeolocatorPlatform {
_FakeGeolocatorPlatform({
required this.permission,
this.position,
this.currentPositionError,
});
LocationPermission permission;
Position? position;
Object? currentPositionError;
@override
Future<LocationPermission> checkPermission() async => permission;
@override
Future<LocationPermission> requestPermission() async => permission;
@override
Future<Position> getCurrentPosition({LocationSettings? locationSettings}) async {
if (currentPositionError != null) throw currentPositionError!;
return position!;
}
}
class _FakeGeocodingPlatform extends pi.Geocoding {
_FakeGeocodingPlatform({this.placemarks = const [], this.error})
: super.implementation(const pi.GeocodingCreationParams());
final List<pi.Placemark> placemarks;
Object? error;
@override
Future<List<pi.Placemark>> placemarkFromCoordinates(
double latitude,
double longitude, {
Locale? locale,
}) async {
if (error != null) throw error!;
return placemarks;
}
}
class _FakeGeocodingPlatformFactory extends pi.GeocodingPlatformFactory {
_FakeGeocodingPlatformFactory(this.geocoding);
final pi.Geocoding geocoding;
@override
pi.Geocoding createGeocoding(pi.GeocodingCreationParams params) => geocoding;
}
Position _somePosition() => Position(
latitude: 52.5,
longitude: 13.4,
timestamp: DateTime.utc(2026, 1, 1),
accuracy: 0,
altitude: 0,
altitudeAccuracy: 0,
heading: 0,
headingAccuracy: 0,
speed: 0,
speedAccuracy: 0,
);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
void setUpPlatforms({
required LocationPermission permission,
Position? position,
Object? currentPositionError,
List<pi.Placemark> placemarks = const [],
Object? geocodingError,
}) {
GeolocatorPlatform.instance = _FakeGeolocatorPlatform(
permission: permission,
position: position,
currentPositionError: currentPositionError,
);
pi.GeocodingPlatformFactory.instance = _FakeGeocodingPlatformFactory(
_FakeGeocodingPlatform(placemarks: placemarks, error: geocodingError),
);
}
group('GpsCountryCodeSource', () {
test('liefert den ISO-Code aus dem ersten Placemark bei erteilter Berechtigung', () async {
setUpPlatforms(
permission: LocationPermission.whileInUse,
position: _somePosition(),
placemarks: const [pi.Placemark(isoCountryCode: 'de')],
);
final source = GpsCountryCodeSource();
expect(await source.resolve(), 'DE');
});
test('fordert die Berechtigung an, wenn sie noch nicht erteilt ist', () async {
setUpPlatforms(
permission: LocationPermission.denied,
position: _somePosition(),
placemarks: const [pi.Placemark(isoCountryCode: 'fr')],
);
final source = GpsCountryCodeSource();
// Der Fake liefert bei checkPermission() und requestPermission() denselben
// Wert; hier reicht es zu prüfen, dass bei "denied" nachgefragt wird und
// trotzdem kein Ergebnis zustande kommt (Fake bleibt bei "denied").
expect(await source.resolve(), isNull);
});
test('liefert null, wenn die Berechtigung dauerhaft verweigert ist', () async {
setUpPlatforms(
permission: LocationPermission.deniedForever,
position: _somePosition(),
placemarks: const [pi.Placemark(isoCountryCode: 'de')],
);
final source = GpsCountryCodeSource();
expect(await source.resolve(), isNull);
});
test('liefert null, wenn getCurrentPosition wirft (z. B. GPS aus)', () async {
setUpPlatforms(
permission: LocationPermission.whileInUse,
currentPositionError: const LocationServiceDisabledException(),
);
final source = GpsCountryCodeSource();
expect(await source.resolve(), isNull);
});
test('liefert null, wenn das Reverse-Geocoding wirft (z. B. kein Netz)', () async {
setUpPlatforms(
permission: LocationPermission.whileInUse,
position: _somePosition(),
geocodingError: Exception('network error'),
);
final source = GpsCountryCodeSource();
expect(await source.resolve(), isNull);
});
test('liefert null, wenn keine Placemarks gefunden werden', () async {
setUpPlatforms(
permission: LocationPermission.whileInUse,
position: _somePosition(),
placemarks: const [],
);
final source = GpsCountryCodeSource();
expect(await source.resolve(), isNull);
});
});
}
@@ -0,0 +1,50 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:emergency/services/country_code_sources/manual_country_code_source.dart';
/// Analog zu `cached_country_code_source_test.dart`: `shared_preferences`
/// ist über `SharedPreferences.setMockInitialValues` vollständig ohne echten
/// Plattform-Kanal testbar.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('ManualCountryCodeSource', () {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('resolve() liefert null, wenn noch keine manuelle Auswahl gespeichert ist', () async {
const source = ManualCountryCodeSource();
expect(await source.resolve(), isNull);
});
test('save() gefolgt von resolve() liefert den gespeicherten, normalisierten Code', () async {
const source = ManualCountryCodeSource();
await source.save('fr');
expect(await source.resolve(), 'FR');
});
test('save(null) löscht eine zuvor gesetzte manuelle Auswahl wieder', () async {
const source = ManualCountryCodeSource();
await source.save('FR');
expect(await source.resolve(), 'FR');
await source.save(null);
expect(await source.resolve(), isNull);
});
test('verwendet einen eigenen Key, unabhängig vom automatischen Cache', () async {
SharedPreferences.setMockInitialValues({'last_known_country_code': 'US'});
const source = ManualCountryCodeSource();
// Der automatische Cache-Wert darf die manuelle Auswahl nicht
// beeinflussen, solange keine manuelle Auswahl gesetzt wurde.
expect(await source.resolve(), isNull);
});
});
}
@@ -0,0 +1,163 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:emergency/services/country_code_sources/sim_country_code_source.dart';
/// `carrier_info` spricht direkt (ohne Platform-Interface-Package) den
/// MethodChannel `plugins.chizi.tech/carrier_info` an. Flutter erlaubt es,
/// diesen Kanal in Tests mit einem Mock-Handler zu belegen dadurch lässt
/// sich die eigentliche Priorisierungs-/Normalisierungslogik von
/// [SimCountryCodeSource] (anders als bei GPS, siehe dortigen Struktur-Test)
/// echt testen, ganz ohne reales Gerät/Emulator.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('plugins.chizi.tech/carrier_info');
void mockHandler(Future<Object?> Function(MethodCall call) handler) {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, handler);
}
Map<String, dynamic> androidInfo({
List<Map<String, dynamic>> telephonyInfo = const [],
List<Map<String, dynamic>> subscriptionsInfo = const [],
}) {
return {
'isVoiceCapable': true,
'isDataEnabled': true,
'subscriptionsInfo': subscriptionsInfo,
'isDataCapable': true,
'isMultiSimSupported': 'MULTISIM_NOT_SUPPORTED_BY_HARDWARE',
'isSmsCapable': true,
'telephonyInfo': telephonyInfo,
};
}
Map<String, dynamic> iosInfo({
List<Map<String, dynamic>> carrierData = const [],
}) {
return {
'carrierData': carrierData,
'supportsEmbeddedSIM': false,
'carrierRadioAccessTechnologyTypeList': <String>['LTE'],
'isSIMInserted': true,
};
}
tearDown(() {
debugDefaultTargetPlatformOverride = null;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
});
group('SimCountryCodeSource auf Android', () {
setUp(() {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
});
test('bevorzugt networkCountryIso (aktuelles Netz, roaming-fest) vor allem anderen', () async {
mockHandler((call) async {
expect(call.method, 'getAndroidInfo');
return androidInfo(
telephonyInfo: [
{'networkCountryIso': 'fr', 'isoCountryCode': 'de'},
],
subscriptionsInfo: [
{'countryIso': 'de'},
],
);
});
const source = SimCountryCodeSource();
expect(await source.resolve(), 'FR');
});
test('fällt auf isoCountryCode zurück, wenn networkCountryIso leer ist', () async {
mockHandler((call) async {
return androidInfo(
telephonyInfo: [
{'networkCountryIso': '', 'isoCountryCode': 'de'},
],
);
});
const source = SimCountryCodeSource();
expect(await source.resolve(), 'DE');
});
test('fällt auf subscriptionsInfo.countryIso zurück, wenn telephonyInfo leer ist', () async {
mockHandler((call) async {
return androidInfo(
subscriptionsInfo: [
{'countryIso': 'us'},
],
);
});
const source = SimCountryCodeSource();
expect(await source.resolve(), 'US');
});
test('liefert null, wenn alle Quellen leer sind (z. B. kein SIM-Slot belegt)', () async {
mockHandler((call) async => androidInfo());
const source = SimCountryCodeSource();
expect(await source.resolve(), isNull);
});
test('liefert null statt zu werfen, wenn das Plugin eine PlatformException wirft', () async {
mockHandler((call) async {
throw PlatformException(code: 'UNAVAILABLE', message: 'no telephony service');
});
const source = SimCountryCodeSource();
expect(await source.resolve(), isNull);
});
});
group('SimCountryCodeSource auf iOS', () {
setUp(() {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
});
test('liefert isoCountryCode auf iOS < 16 (CTCarrier noch verfügbar)', () async {
mockHandler((call) async {
expect(call.method, 'getIosInfo');
return iosInfo(
carrierData: [
{'isoCountryCode': 'ng', 'carrierName': 'Airtel'},
],
);
});
const source = SimCountryCodeSource();
expect(await source.resolve(), 'NG');
});
test(
'liefert null auf iOS 16+ (CTCarrier deprecated, isoCountryCode ist nil) '
'bekanntes, akzeptiertes Risiko laut docs/plan.md Abschnitt 9',
() async {
mockHandler((call) async {
return iosInfo(
carrierData: [
{'isoCountryCode': null, 'carrierName': null},
],
);
});
const source = SimCountryCodeSource();
expect(await source.resolve(), isNull);
},
);
});
}
+62
View File
@@ -0,0 +1,62 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:url_launcher_platform_interface/link.dart';
import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';
import 'package:emergency/services/dialer_service.dart';
/// Fake-Implementierung der `url_launcher`-Plattformschnittstelle.
///
/// Vermeidet Mocking auf `MethodChannel`-Ebene: `url_launcher` erlaubt es,
/// [UrlLauncherPlatform.instance] durch eine eigene Implementierung zu
/// ersetzen das ist der offiziell vorgesehene Weg, das Package in reinen
/// Unit-Tests ohne echte Plattform-Kanäle zu testen.
class _FakeUrlLauncherPlatform extends UrlLauncherPlatform {
_FakeUrlLauncherPlatform({required this.canLaunchResult});
final bool canLaunchResult;
String? launchedUrl;
int launchCallCount = 0;
@override
LinkDelegate? get linkDelegate => null;
@override
Future<bool> canLaunch(String url) async => canLaunchResult;
@override
Future<bool> launchUrl(String url, LaunchOptions options) async {
launchCallCount++;
launchedUrl = url;
return true;
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('DialerService', () {
const dialerService = DialerService();
test('call() öffnet den Wähler mit tel:-URI und liefert true, wenn canLaunch true ist', () async {
final fakePlatform = _FakeUrlLauncherPlatform(canLaunchResult: true);
UrlLauncherPlatform.instance = fakePlatform;
final result = await dialerService.call('112');
expect(result, isTrue);
expect(fakePlatform.launchCallCount, 1);
expect(fakePlatform.launchedUrl, 'tel:112');
});
test('call() liefert false ohne launch(), wenn canLaunch false ist', () async {
final fakePlatform = _FakeUrlLauncherPlatform(canLaunchResult: false);
UrlLauncherPlatform.instance = fakePlatform;
final result = await dialerService.call('911');
expect(result, isFalse);
expect(fakePlatform.launchCallCount, 0);
});
});
}
+214
View File
@@ -0,0 +1,214 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:emergency/services/country_code_sources/country_code_source.dart';
import 'package:emergency/services/location_service.dart';
/// Einfache manuelle Fake-Quelle statt Mocking-Package (siehe AP4-Vorgabe:
/// kein neues Mocking-Package einführen).
class _FakeSource implements CountryCodeSource {
_FakeSource(this._result);
final String? _result;
int callCount = 0;
@override
Future<String?> resolve() async {
callCount++;
return _result;
}
}
class _FakeCache implements CountryCodeCache {
_FakeCache({String? initial}) : _stored = initial;
String? _stored;
int resolveCallCount = 0;
final List<String> savedCodes = [];
@override
Future<String?> resolve() async {
resolveCallCount++;
return _stored;
}
@override
Future<void> save(String isoCode) async {
savedCodes.add(isoCode);
_stored = isoCode;
}
}
/// Fake für die manuelle Auswahl (AP6). Ohne explizite Injektion würde
/// `LocationService()` intern ein echtes `ManualCountryCodeSource` (und damit
/// `shared_preferences`) ansprechen, was in diesen reinen Unit-Tests (kein
/// `TestWidgetsFlutterBinding.ensureInitialized()`/Mock-Setup) nicht
/// notwendig sein soll die bestehenden SIM/GPS/Cache-Tests sollen von der
/// manuellen Auswahl unbeeinflusst bleiben.
class _FakeManualSource implements ManualCountryCodeStore {
_FakeManualSource([this._result]);
final String? _result;
int resolveCallCount = 0;
final List<String?> savedCodes = [];
@override
Future<String?> resolve() async {
resolveCallCount++;
return _result;
}
@override
Future<void> save(String? isoCode) async {
savedCodes.add(isoCode);
}
}
void main() {
group('LocationService.resolveCountryCode()', () {
test(
'SIM liefert Ergebnis -> wird verwendet, GPS/Cache werden nicht abgefragt, '
'Ergebnis wird gecacht',
() async {
final sim = _FakeSource('DE');
final gps = _FakeSource('FR');
final cache = _FakeCache(initial: 'US');
final service = LocationService(
simSource: sim,
gpsSource: gps,
cacheSource: cache,
manualSource: _FakeManualSource(),
);
final result = await service.resolveCountryCode();
expect(result, 'DE');
expect(sim.callCount, 1);
expect(gps.callCount, 0);
expect(cache.resolveCallCount, 0);
expect(cache.savedCodes, ['DE']);
},
);
test(
'SIM liefert null, GPS liefert Ergebnis -> wird verwendet, Cache wird nicht '
'gelesen, aber beschrieben',
() async {
final sim = _FakeSource(null);
final gps = _FakeSource('FR');
final cache = _FakeCache(initial: 'US');
final service = LocationService(
simSource: sim,
gpsSource: gps,
cacheSource: cache,
manualSource: _FakeManualSource(),
);
final result = await service.resolveCountryCode();
expect(result, 'FR');
expect(sim.callCount, 1);
expect(gps.callCount, 1);
expect(cache.resolveCallCount, 0);
expect(cache.savedCodes, ['FR']);
},
);
test('SIM und GPS liefern null, Cache liefert Ergebnis -> wird verwendet', () async {
final sim = _FakeSource(null);
final gps = _FakeSource(null);
final cache = _FakeCache(initial: 'US');
final service = LocationService(
simSource: sim,
gpsSource: gps,
cacheSource: cache,
manualSource: _FakeManualSource(),
);
final result = await service.resolveCountryCode();
expect(result, 'US');
expect(cache.resolveCallCount, 1);
// Kein erneutes Schreiben, da das Ergebnis ja bereits aus dem Cache kam.
expect(cache.savedCodes, isEmpty);
});
test('Alle drei automatischen Quellen liefern null -> Ergebnis ist null', () async {
final sim = _FakeSource(null);
final gps = _FakeSource(null);
final cache = _FakeCache();
final service = LocationService(
simSource: sim,
gpsSource: gps,
cacheSource: cache,
manualSource: _FakeManualSource(),
);
final result = await service.resolveCountryCode();
expect(result, isNull);
expect(cache.savedCodes, isEmpty);
});
test(
'manuelle Auswahl gesetzt -> hat Vorrang vor SIM/GPS/Cache, die anderen '
'Quellen werden gar nicht erst abgefragt',
() async {
final sim = _FakeSource('DE');
final gps = _FakeSource('FR');
final cache = _FakeCache(initial: 'US');
final manual = _FakeManualSource('IT');
final service = LocationService(
simSource: sim,
gpsSource: gps,
cacheSource: cache,
manualSource: manual,
);
final result = await service.resolveCountryCode();
expect(result, 'IT');
expect(sim.callCount, 0);
expect(gps.callCount, 0);
expect(cache.resolveCallCount, 0);
// Die manuelle Auswahl wird bewusst nicht in den automatischen Cache
// geschrieben (getrennte Mechanismen, siehe Doc-Kommentar).
expect(cache.savedCodes, isEmpty);
},
);
test('keine manuelle Auswahl gesetzt -> Kette fällt normal auf SIM zurück', () async {
final sim = _FakeSource('DE');
final gps = _FakeSource('FR');
final cache = _FakeCache(initial: 'US');
final manual = _FakeManualSource(null);
final service = LocationService(
simSource: sim,
gpsSource: gps,
cacheSource: cache,
manualSource: manual,
);
final result = await service.resolveCountryCode();
expect(result, 'DE');
expect(manual.resolveCallCount, 1);
});
});
group('LocationService.setManualCountryCode()', () {
test('delegiert an die manuelle Quelle', () async {
final manual = _FakeManualSource();
final service = LocationService(
simSource: _FakeSource(null),
gpsSource: _FakeSource(null),
cacheSource: _FakeCache(),
manualSource: manual,
);
await service.setManualCountryCode('IT');
await service.setManualCountryCode(null);
expect(manual.savedCodes, ['IT', null]);
});
});
}
+95 -21
View File
@@ -1,30 +1,104 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:emergency/main.dart';
import 'package:emergency/app.dart';
import 'package:emergency/features/home/widgets/emergency_button.dart';
import 'package:emergency/repositories/emergency_number_repository.dart';
import 'package:emergency/services/country_code_sources/country_code_source.dart';
import 'package:emergency/services/location_service.dart';
/// Einfache manuelle Fake-Quelle statt Mocking-Package (siehe AP4-Vorgabe:
/// kein neues Mocking-Package einführen), analog zu
/// `test/services/location_service_test.dart`.
class _FakeSource implements CountryCodeSource {
_FakeSource(this._result);
final String? _result;
@override
Future<String?> resolve() async => _result;
}
/// Fake-Cache: Verhindert, dass `LocationService` beim Cachen eines
/// SIM/GPS-Treffers den echten `shared_preferences`-Plugin-Kanal anspricht
/// (der ohne Mock-Setup in reinen Widget-Tests nicht antwortet).
class _FakeCache implements CountryCodeCache {
@override
Future<String?> resolve() async => null;
@override
Future<void> save(String isoCode) async {}
}
/// Analog zu `_FakeCache`, aber für die manuelle Auswahl (AP6): verhindert
/// den echten `shared_preferences`-Zugriff, den `LocationService` seit AP6
/// als vorgeschaltete Prioritätsstufe abfragt.
class _FakeManualSource implements ManualCountryCodeStore {
@override
Future<String?> resolve() async => null;
@override
Future<void> save(String? isoCode) async {}
}
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
testWidgets('HomeScreen zeigt nach dem Laden mindestens einen EmergencyButton für DE', (
WidgetTester tester,
) async {
// Asset-Laden über rootBundle ist echte I/O und läuft nicht innerhalb des
// FakeAsync-Zeitrahmens von pump()/pumpAndSettle() durch. Deshalb wird
// die Datenbasis hier vorab über runAsync geladen (Repository.load() ist
// danach idempotent, s. Repository). Die Ländererkennung selbst wird
// über eine gefakte SIM-Quelle deterministisch auf "DE" gesetzt, damit
// der Test nicht von echten Plattform-Kanälen (SIM/GPS/Cache) abhängt.
final repository = EmergencyNumberRepository();
await tester.runAsync(() => repository.load());
// gpsSource muss explizit gesetzt werden: Ohne eigenen Wert würde
// LocationService() intern ein echtes GpsCountryCodeSource (und damit
// Geocoding()) konstruieren, was ohne registriertes Platform-Interface
// sofort wirft (siehe gps_country_code_source_test.dart). cacheSource
// wird ebenfalls gefaked, siehe _FakeCache.
final locationService = LocationService(
simSource: _FakeSource('DE'),
gpsSource: _FakeSource(null),
cacheSource: _FakeCache(),
manualSource: _FakeManualSource(),
);
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
await tester.pumpWidget(App(repository: repository, locationService: locationService));
await tester.pumpAndSettle();
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
expect(find.byType(EmergencyButton), findsWidgets);
// DE führt "112" bereits als general-Nummer, daher darf der globale
// Panik-Button hier nicht zusätzlich dieselbe Nummer anzeigen (Dedup).
expect(find.text('112'), findsOneWidget);
expect(find.byKey(const Key('panicButton')), findsNothing);
});
testWidgets(
'HomeScreen zeigt den globalen Panik-Button bereits während des Ladens der Ländererkennung',
(WidgetTester tester) async {
// Bewusst kein runAsync/Vorladen des Repositories: Der Ladezustand
// (EmergencyStatus.initializing) soll direkt nach pumpWidget
// beobachtbar sein, ohne auf das Ende des echten Asset-Ladens zu warten.
// Die Ländererkennung wird ohnehin nie erreicht (blockiert beim
// Repository-Laden), locationService wird trotzdem explizit gefaked,
// siehe Kommentar im Test oben.
await tester.pumpWidget(
App(
repository: EmergencyNumberRepository(),
locationService: LocationService(
simSource: _FakeSource(null),
gpsSource: _FakeSource(null),
cacheSource: _FakeCache(),
manualSource: _FakeManualSource(),
),
),
);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(find.byKey(const Key('panicButton')), findsOneWidget);
expect(find.text('112'), findsOneWidget);
},
);
}