import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:nuvolari/core/region/geo.dart'; import 'package:nuvolari/core/region/region_config.dart'; import 'package:nuvolari/core/region/region_repository.dart'; import 'package:nuvolari/features/places/places_repository.dart'; import 'package:nuvolari/features/places/saved_place.dart'; import 'package:nuvolari/features/places/saved_places.dart'; /// Inside the Piedmont bounding box. const torino = GeoPoint(7.686, 45.070); const cuneo = GeoPoint(7.549, 44.393); /// Well outside it. const roma = GeoPoint(12.496, 41.902); RegionConfig loadPiemonte() => RegionConfig.parse(File('assets/regions/piemonte.json').readAsStringSync()); ({ProviderContainer container, InMemoryPlacesStore store}) harness( RegionConfig region, { List? initial, }) { final store = InMemoryPlacesStore(initial); final container = ProviderContainer( overrides: [ regionConfigProvider.overrideWith((ref) async => region), placesStoreProvider.overrideWithValue(store), ], ); addTearDown(container.dispose); return (container: container, store: store); } Future> settled(ProviderContainer container) => container.read(savedPlacesProvider.future); void main() { late RegionConfig region; setUpAll(() { region = loadPiemonte(); }); group('SavedPlace serialisation', () { test('round-trips through JSON', () { final place = SavedPlace( id: 'p1', name: 'Casa', point: torino, createdAt: DateTime.utc(2026, 3, 4, 10), ); expect(SavedPlace.fromJson(place.toJson()), place); }); test('rejects a malformed document', () { expect( () => SavedPlace.fromJson({'id': 'p1'}), throwsFormatException, ); expect( () => SavedPlace.fromJson({ 'id': '', 'name': 'Casa', 'lat': 45.0, 'lng': 7.0, 'createdAt': 0, }), throwsFormatException, ); }); }); group('adding a place', () { test('saves it and puts it first', () async { final h = harness(region); await settled(h.container); final notifier = h.container.read(savedPlacesProvider.notifier); await notifier.add(name: 'Casa', point: torino); await notifier.add(name: 'Lavoro', point: cuneo); final places = h.container.read(savedPlacesProvider).value!; expect(places.map((p) => p.name), orderedEquals(['Lavoro', 'Casa'])); }); test('persists to the store', () async { final h = harness(region); await settled(h.container); await h.container .read(savedPlacesProvider.notifier) .add(name: 'Casa', point: torino); expect(await h.store.load(), hasLength(1)); expect((await h.store.load()).single.name, 'Casa'); }); test('trims the name', () async { final h = harness(region); await settled(h.container); final place = await h.container .read(savedPlacesProvider.notifier) .add(name: ' Casa ', point: torino); expect(place.name, 'Casa'); }); test('rejects a blank name', () async { final h = harness(region); await settled(h.container); await expectLater( h.container .read(savedPlacesProvider.notifier) .add(name: ' ', point: torino), throwsA( isA().having( (e) => e.rejection, 'rejection', SavedPlaceRejection.emptyName, ), ), ); }); test('rejects a name past the limit', () async { final h = harness(region); await settled(h.container); await expectLater( h.container .read(savedPlacesProvider.notifier) .add(name: 'x' * (SavedPlace.maxNameLength + 1), point: torino), throwsA( isA().having( (e) => e.rejection, 'rejection', SavedPlaceRejection.nameTooLong, ), ), ); }); test('rejects a duplicate name regardless of case', () async { final h = harness(region); await settled(h.container); final notifier = h.container.read(savedPlacesProvider.notifier); await notifier.add(name: 'Casa', point: torino); await expectLater( notifier.add(name: 'casa', point: cuneo), throwsA( isA().having( (e) => e.rejection, 'rejection', SavedPlaceRejection.duplicateName, ), ), ); }); // Saving a place the app has no data for would look like it worked and then // show nothing forever. Better to refuse while the user still knows what // they were trying to do. test('rejects a point outside the region', () async { final h = harness(region); await settled(h.container); await expectLater( h.container .read(savedPlacesProvider.notifier) .add(name: 'Roma', point: roma), throwsA( isA().having( (e) => e.rejection, 'rejection', SavedPlaceRejection.outsideRegion, ), ), ); expect(h.container.read(savedPlacesProvider).value, isEmpty); }); test('stops at the limit', () async { final h = harness(region); await settled(h.container); final notifier = h.container.read(savedPlacesProvider.notifier); for (var i = 0; i < SavedPlaces.maxPlaces + 5; i++) { await notifier.add(name: 'Posto $i', point: torino); } expect( h.container.read(savedPlacesProvider).value, hasLength(SavedPlaces.maxPlaces), ); }); }); group('identity', () { // Ids used to come straight from the microsecond clock, so two places saved // in the same microsecond collided and rename, remove and the duplicate // check all acted on the wrong place. test('rapid adds get distinct ids', () async { final h = harness(region); await settled(h.container); final notifier = h.container.read(savedPlacesProvider.notifier); for (var i = 0; i < 10; i++) { await notifier.add(name: 'Posto $i', point: torino); } final ids = h.container .read(savedPlacesProvider) .value! .map((place) => place.id) .toSet(); expect(ids, hasLength(10)); }); }); group('renaming and removing', () { test('rename changes only the name', () async { final h = harness(region); await settled(h.container); final notifier = h.container.read(savedPlacesProvider.notifier); final place = await notifier.add(name: 'Casa', point: torino); await notifier.rename(place.id, 'Casa nuova'); final updated = h.container.read(savedPlacesProvider).value!.single; expect(updated.name, 'Casa nuova'); expect(updated.id, place.id); expect(updated.point, torino); expect(updated.createdAt, place.createdAt); }); test('rename rejects a name another place already uses', () async { final h = harness(region); await settled(h.container); final notifier = h.container.read(savedPlacesProvider.notifier); await notifier.add(name: 'Casa', point: torino); final second = await notifier.add(name: 'Lavoro', point: cuneo); await expectLater( notifier.rename(second.id, 'Casa'), throwsA(isA()), ); }); test('rename to its own name is allowed', () async { final h = harness(region); await settled(h.container); final notifier = h.container.read(savedPlacesProvider.notifier); final place = await notifier.add(name: 'Casa', point: torino); await notifier.rename(place.id, 'Casa'); expect(h.container.read(savedPlacesProvider).value!.single.name, 'Casa'); }); test('remove deletes it from state and from the store', () async { final h = harness(region); await settled(h.container); final notifier = h.container.read(savedPlacesProvider.notifier); final place = await notifier.add(name: 'Casa', point: torino); await notifier.remove(place.id); expect(h.container.read(savedPlacesProvider).value, isEmpty); expect(await h.store.load(), isEmpty); }); test('removing an unknown id changes nothing', () async { final h = harness(region); await settled(h.container); final notifier = h.container.read(savedPlacesProvider.notifier); await notifier.add(name: 'Casa', point: torino); await notifier.remove('does-not-exist'); expect(h.container.read(savedPlacesProvider).value, hasLength(1)); }); }); group('loading', () { test('reads what the store already held', () async { final existing = SavedPlace( id: 'p1', name: 'Casa', point: torino, createdAt: DateTime.utc(2026), ); final h = harness(region, initial: [existing]); expect(await settled(h.container), [existing]); }); }); group('byDistanceFrom', () { test('orders nearest first', () { final near = SavedPlace( id: 'a', name: 'Vicino', point: torino, createdAt: DateTime.utc(2026), ); final far = SavedPlace( id: 'b', name: 'Lontano', point: cuneo, createdAt: DateTime.utc(2026), ); final ordered = byDistanceFrom(torino, [far, near]); expect(ordered.map((p) => p.id), orderedEquals(['a', 'b'])); }); }); }