diff --git a/lib/src/geo_actor.dart b/lib/src/geo_actor.dart new file mode 100644 index 0000000..37c5d8d --- /dev/null +++ b/lib/src/geo_actor.dart @@ -0,0 +1,69 @@ +import 'package:actors/actors.dart'; +import 'package:dart_geohash/dart_geohash.dart'; + +sealed class GeoMessage { + const GeoMessage(); +} + +final class EncodeLocation extends GeoMessage { + final double latitude; + final double longitude; + final int precision; + const EncodeLocation({ + required this.latitude, + required this.longitude, + this.precision = 9, + }); +} + +final class DecodeGeohash extends GeoMessage { + final String geohash; + const DecodeGeohash(this.geohash); +} + +final class NeighborsOf extends GeoMessage { + final String geohash; + const NeighborsOf(this.geohash); +} + +sealed class GeoResult { + const GeoResult(); +} + +final class GeohashEncoded extends GeoResult { + final String geohash; + const GeohashEncoded(this.geohash); +} + +final class LocationDecoded extends GeoResult { + final double latitude; + final double longitude; + const LocationDecoded({required this.latitude, required this.longitude}); +} + +// Keys: NORTH, NORTHEAST, EAST, SOUTHEAST, SOUTH, SOUTHWEST, WEST, NORTHWEST, CENTRAL +final class NeighborsResult extends GeoResult { + final Map neighbors; + const NeighborsResult(this.neighbors); +} + +class GeoActor with Handler { + final _hasher = GeoHasher(); + + @override + Future handle(GeoMessage message) => switch (message) { + EncodeLocation(:final latitude, :final longitude, :final precision) => + Future.value( + GeohashEncoded( + _hasher.encode(longitude, latitude, precision: precision)), + ), + DecodeGeohash(:final geohash) => Future.value(_decode(geohash)), + NeighborsOf(:final geohash) => + Future.value(NeighborsResult(_hasher.neighbors(geohash))), + }; + + LocationDecoded _decode(String geohash) { + final coords = _hasher.decode(geohash); // [longitude, latitude] + return LocationDecoded(latitude: coords[1], longitude: coords[0]); + } +} diff --git a/lib/swarm.dart b/lib/swarm.dart index ce2b396..dde7e38 100644 --- a/lib/swarm.dart +++ b/lib/swarm.dart @@ -3,3 +3,4 @@ export 'src/relay_connection.dart'; export 'src/nostr_publish_actor.dart'; export 'src/nostr_fetch_actor.dart'; export 'src/nostr_signer_actor.dart'; +export 'src/geo_actor.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index c776331..4068f1f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,6 +10,7 @@ dependencies: actors: ^0.11.0 bip340: ^0.3.1 crypto: ^3.0.7 + dart_geohash: ^2.1.0 retry: ^3.1.2 web_socket_channel: ^3.0.3 diff --git a/test/geo_actor_test.dart b/test/geo_actor_test.dart new file mode 100644 index 0000000..919e06d --- /dev/null +++ b/test/geo_actor_test.dart @@ -0,0 +1,122 @@ +import 'package:swarm/swarm.dart'; +import 'package:test/test.dart'; + +void main() { + late GeoActor actor; + + setUp(() => actor = GeoActor()); + + group('GeoActor', () { + group('EncodeLocation', () { + test('returns GeohashEncoded with correct length', () async { + final result = await actor.handle( + const EncodeLocation( + latitude: 48.8566, longitude: 2.3522, precision: 6), + ); + expect(result, isA()); + expect((result as GeohashEncoded).geohash, hasLength(6)); + }); + + test('default precision is 9', () async { + final result = await actor.handle( + const EncodeLocation(latitude: 51.5074, longitude: -0.1278), + ); + expect((result as GeohashEncoded).geohash, hasLength(9)); + }); + + test( + 'higher precision produces longer hash with lower precision as prefix', + () async { + const lat = 52.52, lon = 13.405; + final r6 = await actor.handle( + const EncodeLocation(latitude: lat, longitude: lon, precision: 6), + ); + final r9 = await actor.handle( + const EncodeLocation(latitude: lat, longitude: lon, precision: 9), + ); + final h6 = (r6 as GeohashEncoded).geohash; + final h9 = (r9 as GeohashEncoded).geohash; + expect(h9, startsWith(h6)); + }); + }); + + group('DecodeGeohash', () { + test('returns LocationDecoded', () async { + final result = await actor.handle(const DecodeGeohash('u33dbf')); + expect(result, isA()); + }); + + test('decoded coordinates are within cell bounds for precision 6', + () async { + // precision-6 cell error: ±0.0034° lat, ±0.0017° lon + final result = await actor.handle(const DecodeGeohash('u33dbf')); + final loc = result as LocationDecoded; + expect(loc.latitude, inInclusiveRange(52.0, 53.0)); + expect(loc.longitude, inInclusiveRange(13.0, 14.0)); + }); + + test('roundtrip encode→decode recovers coordinates within cell error', + () async { + const lat = 48.8566, lon = 2.3522; + final encoded = await actor.handle( + const EncodeLocation(latitude: lat, longitude: lon, precision: 9), + ); + final decoded = await actor.handle( + DecodeGeohash((encoded as GeohashEncoded).geohash), + ); + final loc = decoded as LocationDecoded; + // precision-9 cell error: ±0.000021° lat, ±0.000021° lon + expect(loc.latitude, closeTo(lat, 0.001)); + expect(loc.longitude, closeTo(lon, 0.001)); + }); + }); + + group('NeighborsOf', () { + const directions = [ + 'NORTH', + 'NORTHEAST', + 'EAST', + 'SOUTHEAST', + 'SOUTH', + 'SOUTHWEST', + 'WEST', + 'NORTHWEST', + 'CENTRAL', + ]; + + test('returns NeighborsResult with 9 entries', () async { + final result = await actor.handle(const NeighborsOf('u33dbf')); + expect(result, isA()); + expect((result as NeighborsResult).neighbors, hasLength(9)); + }); + + test('result contains all 8 compass directions plus CENTRAL', () async { + final result = await actor.handle(const NeighborsOf('u33dbf')); + expect( + (result as NeighborsResult).neighbors.keys, + containsAll(directions), + ); + }); + + test('CENTRAL equals the input geohash', () async { + const hash = 'u33dbf'; + final result = await actor.handle(const NeighborsOf(hash)); + expect((result as NeighborsResult).neighbors['CENTRAL'], equals(hash)); + }); + + test('all neighbor hashes have the same precision as input', () async { + const hash = 'u33dbf'; + final result = await actor.handle(const NeighborsOf(hash)); + for (final neighbor in (result as NeighborsResult).neighbors.values) { + expect(neighbor, hasLength(hash.length)); + } + }); + + test('neighbor hashes are all distinct', () async { + final result = await actor.handle(const NeighborsOf('u33dbf')); + final values = (result as NeighborsResult).neighbors.values.toList(); + expect(values.toSet(), hasLength(values.length)); + }); + }); + }); +}