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:
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user