From 97b9223cb2431250e561af4152a7d2cb51b4e730 Mon Sep 17 00:00:00 2001 From: vjrj Date: Fri, 10 Jul 2026 16:12:38 +0200 Subject: [PATCH] fix(market): search your zone by a coarse prefix, not the exact cell Discovery searched the full 5-char geohash (~2.4 km) with an exact Nostr tag match, so two devices in adjacent cells never found each other even though publish emits the full NIP-52 prefix ladder. Search now coarsens the area to a configurable precision (SocialSettings.searchPrecision, default 4 ~20 km) via searchPrefix(), with a human-worded 'how far' selector (Very close / Around here / My region -> 5/4/3) in the market setup sheet. Also: surface a clear 'couldn't reach the servers' message when no relay accepts a share (instead of 'shared 0'), and add wss://relay.comunes.org as the first default relay (reliable community home; public relays are backup). Tests: settings precision (default/clamp/roundtrip), searchPrefix helper, neighbour-cell discovery regression (offers_cubit + commons_core nostr transports), and the range-selector widget test. --- apps/app_seeds/lib/i18n/ast.i18n.json | 7 +++- apps/app_seeds/lib/i18n/en.i18n.json | 7 +++- apps/app_seeds/lib/i18n/es.i18n.json | 7 +++- apps/app_seeds/lib/i18n/pt.i18n.json | 7 +++- apps/app_seeds/lib/i18n/strings.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_en.g.dart | 20 ++++++++++ apps/app_seeds/lib/i18n/strings_es.g.dart | 10 +++++ apps/app_seeds/lib/i18n/strings_pt.g.dart | 10 +++++ .../lib/services/discovery_area.dart | 11 +++++ .../lib/services/social_settings.dart | 32 ++++++++++++++- apps/app_seeds/lib/ui/market_screen.dart | 40 +++++++++++++++++-- .../test/services/discovery_area_test.dart | 22 ++++++++++ .../test/services/social_settings_test.dart | 24 +++++++++++ .../test/state/offers_cubit_test.dart | 26 ++++++++++++ .../app_seeds/test/ui/market_screen_test.dart | 22 ++++++++++ .../test/social/nostr_transports_test.dart | 29 ++++++++++++++ 16 files changed, 268 insertions(+), 10 deletions(-) create mode 100644 apps/app_seeds/lib/services/discovery_area.dart create mode 100644 apps/app_seeds/test/services/discovery_area_test.dart diff --git a/apps/app_seeds/lib/i18n/ast.i18n.json b/apps/app_seeds/lib/i18n/ast.i18n.json index 16928a7..107d18d 100644 --- a/apps/app_seeds/lib/i18n/ast.i18n.json +++ b/apps/app_seeds/lib/i18n/ast.i18n.json @@ -370,7 +370,12 @@ "nothingToShare": "Marca primero dalguna simiente pa regalar, trocar o vender", "useLocation": "Usar el mio allugamientu averáu", "locationFailed": "Nun se pudo consiguir el to allugamientu — comprueba que l'allugamientu ta activáu y el permisu concedíu", - "queued": "Guardáu — compartirémosles cuando tengas conexón" + "queued": "Guardáu — compartirémosles cuando tengas conexón", + "shareFailed": "Nun se pudo coneutar colos sirvidores — les tos granes nun se compartieron. Vuelvi a intentalo nun momentu.", + "rangeLabel": "Hasta ónde guetar", + "rangeNear": "Bien cerca", + "rangeArea": "Pela mio zona", + "rangeRegion": "La mio rexón" }, "profile": { "title": "El to perfil", diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index e9dbfda..c0bd30b 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -373,7 +373,12 @@ "nothingToShare": "Mark some seeds to give, swap or sell first", "useLocation": "Use my approximate location", "locationFailed": "Couldn't get your location — check that location is on and the permission is granted", - "queued": "Saved — we'll share these when you're connected" + "queued": "Saved — we'll share these when you're connected", + "shareFailed": "Couldn't reach the community servers — your seeds weren't shared. Try again in a moment.", + "rangeLabel": "How far to look", + "rangeNear": "Very close", + "rangeArea": "Around here", + "rangeRegion": "My region" }, "profile": { "title": "Your profile", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index bfac317..37c64cd 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -372,7 +372,12 @@ "nothingToShare": "Marca antes algunas semillas para regalar, cambiar o vender", "useLocation": "Usar mi ubicación aproximada", "locationFailed": "No se pudo obtener tu ubicación — comprueba que la ubicación está activada y el permiso concedido", - "queued": "Guardado — las compartiremos cuando tengas conexión" + "queued": "Guardado — las compartiremos cuando tengas conexión", + "shareFailed": "No se pudo conectar con los servidores — tus semillas no se compartieron. Inténtalo de nuevo en un momento.", + "rangeLabel": "Hasta dónde buscar", + "rangeNear": "Muy cerca", + "rangeArea": "Por mi zona", + "rangeRegion": "Mi región" }, "profile": { "title": "Tu perfil", diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index f3f8cb5..149e92a 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -369,7 +369,12 @@ "nothingToShare": "Marca primeiro algumas sementes para dar, trocar ou vender", "useLocation": "Usar a minha localização aproximada", "locationFailed": "Não foi possível obter a tua localização — verifica se a localização está ativa e a permissão concedida", - "queued": "Guardado — vamos partilhá-las quando tiveres ligação" + "queued": "Guardado — vamos partilhá-las quando tiveres ligação", + "shareFailed": "Não foi possível contactar os servidores — as tuas sementes não foram partilhadas. Tenta de novo daqui a pouco.", + "rangeLabel": "Até onde procurar", + "rangeNear": "Muito perto", + "rangeArea": "Pela minha zona", + "rangeRegion": "A minha região" }, "profile": { "title": "O teu perfil", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 990740b..058a3c4 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 4 -/// Strings: 1444 (361 per locale) +/// Strings: 1464 (366 per locale) /// -/// Built on 2026-07-10 at 13:48 UTC +/// Built on 2026-07-10 at 13:52 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index af2c0e6..adb1071 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -1184,6 +1184,21 @@ class Translations$market$en { /// en: 'Saved — we'll share these when you're connected' String get queued => 'Saved — we\'ll share these when you\'re connected'; + + /// en: 'Couldn't reach the community servers — your seeds weren't shared. Try again in a moment.' + String get shareFailed => 'Couldn\'t reach the community servers — your seeds weren\'t shared. Try again in a moment.'; + + /// en: 'How far to look' + String get rangeLabel => 'How far to look'; + + /// en: 'Very close' + String get rangeNear => 'Very close'; + + /// en: 'Around here' + String get rangeArea => 'Around here'; + + /// en: 'My region' + String get rangeRegion => 'My region'; } // Path: profile @@ -2096,6 +2111,11 @@ extension on Translations { 'market.useLocation' => 'Use my approximate location', 'market.locationFailed' => 'Couldn\'t get your location — check that location is on and the permission is granted', 'market.queued' => 'Saved — we\'ll share these when you\'re connected', + 'market.shareFailed' => 'Couldn\'t reach the community servers — your seeds weren\'t shared. Try again in a moment.', + 'market.rangeLabel' => 'How far to look', + 'market.rangeNear' => 'Very close', + 'market.rangeArea' => 'Around here', + 'market.rangeRegion' => 'My region', 'profile.title' => 'Your profile', 'profile.name' => 'Display name', 'profile.nameHint' => 'How others see you', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 7695e63..e01e6bd 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -647,6 +647,11 @@ class _Translations$market$es extends Translations$market$en { @override String get useLocation => 'Usar mi ubicación aproximada'; @override String get locationFailed => 'No se pudo obtener tu ubicación — comprueba que la ubicación está activada y el permiso concedido'; @override String get queued => 'Guardado — las compartiremos cuando tengas conexión'; + @override String get shareFailed => 'No se pudo conectar con los servidores — tus semillas no se compartieron. Inténtalo de nuevo en un momento.'; + @override String get rangeLabel => 'Hasta dónde buscar'; + @override String get rangeNear => 'Muy cerca'; + @override String get rangeArea => 'Por mi zona'; + @override String get rangeRegion => 'Mi región'; } // Path: profile @@ -1388,6 +1393,11 @@ extension on TranslationsEs { 'market.useLocation' => 'Usar mi ubicación aproximada', 'market.locationFailed' => 'No se pudo obtener tu ubicación — comprueba que la ubicación está activada y el permiso concedido', 'market.queued' => 'Guardado — las compartiremos cuando tengas conexión', + 'market.shareFailed' => 'No se pudo conectar con los servidores — tus semillas no se compartieron. Inténtalo de nuevo en un momento.', + 'market.rangeLabel' => 'Hasta dónde buscar', + 'market.rangeNear' => 'Muy cerca', + 'market.rangeArea' => 'Por mi zona', + 'market.rangeRegion' => 'Mi región', 'profile.title' => 'Tu perfil', 'profile.name' => 'Nombre', 'profile.nameHint' => 'Cómo te ven los demás', diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index d872cc2..772857f 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -644,6 +644,11 @@ class _Translations$market$pt extends Translations$market$en { @override String get useLocation => 'Usar a minha localização aproximada'; @override String get locationFailed => 'Não foi possível obter a tua localização — verifica se a localização está ativa e a permissão concedida'; @override String get queued => 'Guardado — vamos partilhá-las quando tiveres ligação'; + @override String get shareFailed => 'Não foi possível contactar os servidores — as tuas sementes não foram partilhadas. Tenta de novo daqui a pouco.'; + @override String get rangeLabel => 'Até onde procurar'; + @override String get rangeNear => 'Muito perto'; + @override String get rangeArea => 'Pela minha zona'; + @override String get rangeRegion => 'A minha região'; } // Path: profile @@ -1382,6 +1387,11 @@ extension on TranslationsPt { 'market.useLocation' => 'Usar a minha localização aproximada', 'market.locationFailed' => 'Não foi possível obter a tua localização — verifica se a localização está ativa e a permissão concedida', 'market.queued' => 'Guardado — vamos partilhá-las quando tiveres ligação', + 'market.shareFailed' => 'Não foi possível contactar os servidores — as tuas sementes não foram partilhadas. Tenta de novo daqui a pouco.', + 'market.rangeLabel' => 'Até onde procurar', + 'market.rangeNear' => 'Muito perto', + 'market.rangeArea' => 'Pela minha zona', + 'market.rangeRegion' => 'A minha região', 'profile.title' => 'O teu perfil', 'profile.name' => 'Nome', 'profile.nameHint' => 'Como os outros te veem', diff --git a/apps/app_seeds/lib/services/discovery_area.dart b/apps/app_seeds/lib/services/discovery_area.dart new file mode 100644 index 0000000..bb8d246 --- /dev/null +++ b/apps/app_seeds/lib/services/discovery_area.dart @@ -0,0 +1,11 @@ +/// The geohash prefix to search offers by, given the user's coarse [area] and +/// the chosen [precision] (a prefix length; see +/// `SocialSettings.searchPrecision`). +/// +/// Publishing emits a NIP-52 prefix ladder from the (already coarse) area, so a +/// SHORTER search prefix still matches finer offers. Searching at the full area +/// length only matches the exact ±2.4 km cell — which is why two neighbours in +/// adjacent cells used to find nothing. We recorten the area to [precision] (but +/// never beyond its own length) so "your zone" is genuinely a zone, not a point. +String searchPrefix(String area, int precision) => + area.substring(0, area.length < precision ? area.length : precision); diff --git a/apps/app_seeds/lib/services/social_settings.dart b/apps/app_seeds/lib/services/social_settings.dart index 293c81b..6cdcb7a 100644 --- a/apps/app_seeds/lib/services/social_settings.dart +++ b/apps/app_seeds/lib/services/social_settings.dart @@ -16,11 +16,24 @@ class SocialSettings { static const _areaKey = 'tane.social.area_geohash'; static const _relaysKey = 'tane.social.relays'; + static const _searchPrecisionKey = 'tane.social.search_precision'; + + /// How wide "your zone" searches, as a geohash prefix length: 5 ≈ ±2.4 km + /// ("very close"), 4 ≈ ±20 km ("around here"), 3 ≈ ±78 km ("my region"). + /// Publishing always emits the full prefix ladder, so a coarser search still + /// matches finer offers — the default is deliberately wide so a still-sparse + /// network shows something. + static const int minSearchPrecision = 3; + static const int maxSearchPrecision = 5; + static const int defaultSearchPrecision = 4; /// Community servers used automatically so sharing works from the first /// launch. The relay pool skips any that are unreachable, so a dead one never - /// breaks the market; the user never has to know these exist. + /// breaks the market; the user never has to know these exist. The Comunes + /// relay comes first as the reliable, non-commercial home; the public ones + /// are backup. static const List defaultRelays = [ + 'wss://relay.comunes.org', 'wss://nos.lol', 'wss://relay.damus.io', 'wss://relay.primal.net', @@ -47,6 +60,23 @@ class SocialSettings { urls.map((u) => u.trim()).where((u) => u.isNotEmpty).join('\n'), ); + /// How wide to search — a geohash prefix length in [minSearchPrecision, + /// maxSearchPrecision]. Defaults (and falls back on any garbage) to + /// [defaultSearchPrecision]. + Future searchPrecision() async { + final raw = await _store.read(_searchPrecisionKey); + final value = int.tryParse(raw ?? ''); + if (value == null) return defaultSearchPrecision; + return _clampPrecision(value); + } + + Future setSearchPrecision(int precision) => + _store.write(_searchPrecisionKey, '${_clampPrecision(precision)}'); + + int _clampPrecision(int p) => p < minSearchPrecision + ? minSearchPrecision + : (p > maxSearchPrecision ? maxSearchPrecision : p); + /// Whether the social layer has enough config to attempt going online. Future get isConfigured async => (await relayUrls()).isNotEmpty && (await areaGeohash()).isNotEmpty; diff --git a/apps/app_seeds/lib/ui/market_screen.dart b/apps/app_seeds/lib/ui/market_screen.dart index 9fdb886..74a534a 100644 --- a/apps/app_seeds/lib/ui/market_screen.dart +++ b/apps/app_seeds/lib/ui/market_screen.dart @@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart'; import '../data/variety_repository.dart'; import '../i18n/strings.g.dart'; import '../services/coarse_location.dart'; +import '../services/discovery_area.dart'; import '../services/offer_outbox.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; @@ -81,7 +82,10 @@ class _MarketScreenState extends State { areaGeohash: area, ); } - if (mounted) await cubit.discover(area); + if (mounted) { + final precision = await widget.settings.searchPrecision(); + if (mounted) await cubit.discover(searchPrefix(area, precision)); + } } } @@ -132,8 +136,14 @@ class _MarketScreenState extends State { ); if (!mounted) return; await widget.outbox?.remove(ids); // published now, so unpark any duplicates - messenger.showSnackBar(SnackBar(content: Text(t.market.sharedCount(n: count)))); - await cubit.discover(area); // refresh — now including the just-shared lots + // count == 0 with lots to share means every relay refused — say so plainly + // rather than "shared 0", which reads like success. + messenger.showSnackBar(SnackBar( + content: Text(count == 0 ? t.market.shareFailed : t.market.sharedCount(n: count)), + )); + final precision = await widget.settings.searchPrecision(); + if (!mounted) return; + await cubit.discover(searchPrefix(area, precision)); // refresh incl. just-shared } @override @@ -527,6 +537,7 @@ class _ConfigSheetState extends State<_ConfigSheet> { bool _loading = true; bool _locationBusy = false; String? _locationError; + int _precision = SocialSettings.defaultSearchPrecision; @override void initState() { @@ -537,12 +548,14 @@ class _ConfigSheetState extends State<_ConfigSheet> { Future _load() async { _area.text = await widget.settings.areaGeohash(); _servers.text = (await widget.settings.relayUrls()).join('\n'); + _precision = await widget.settings.searchPrecision(); if (mounted) setState(() => _loading = false); } Future _save() async { await widget.settings.setAreaGeohash(_area.text); await widget.settings.setRelayUrls(_servers.text.split('\n')); + await widget.settings.setSearchPrecision(_precision); if (mounted) Navigator.of(context).pop(true); } @@ -654,6 +667,27 @@ class _ConfigSheetState extends State<_ConfigSheet> { ), ], ), + const SizedBox(height: 16), + Align( + alignment: AlignmentDirectional.centerStart, + child: Text( + t.market.rangeLabel, + style: const TextStyle(fontSize: 13, color: seedMuted), + ), + ), + const SizedBox(height: 8), + SegmentedButton( + key: const Key('market.range'), + segments: [ + ButtonSegment(value: 5, label: Text(t.market.rangeNear)), + ButtonSegment(value: 4, label: Text(t.market.rangeArea)), + ButtonSegment(value: 3, label: Text(t.market.rangeRegion)), + ], + selected: {_precision}, + showSelectedIcon: false, + onSelectionChanged: (s) => + setState(() => _precision = s.first), + ), const SizedBox(height: 8), Theme( data: Theme.of(context) diff --git a/apps/app_seeds/test/services/discovery_area_test.dart b/apps/app_seeds/test/services/discovery_area_test.dart new file mode 100644 index 0000000..710f066 --- /dev/null +++ b/apps/app_seeds/test/services/discovery_area_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/discovery_area.dart'; + +void main() { + test('coarsens the area to the search precision', () { + expect(searchPrefix('sp3e9', 4), 'sp3e'); + expect(searchPrefix('sp3e9', 3), 'sp3'); + expect(searchPrefix('sp3e9', 5), 'sp3e9'); + }); + + test('never asks for more than the area actually has', () { + expect(searchPrefix('sp', 4), 'sp'); + expect(searchPrefix('', 4), ''); + }); + + test('two neighbours in adjacent 5-char cells share a 4-char prefix', () { + // The crux of the bug: sp3e9 and sp3e8 are different ±2.4 km cells, but + // searching at precision 4 collapses both to 'sp3e', so they find each + // other. Publishing emits the ladder down to 'sp3e', so this matches. + expect(searchPrefix('sp3e9', 4), searchPrefix('sp3e8', 4)); + }); +} diff --git a/apps/app_seeds/test/services/social_settings_test.dart b/apps/app_seeds/test/services/social_settings_test.dart index 1ca7faa..e8c8673 100644 --- a/apps/app_seeds/test/services/social_settings_test.dart +++ b/apps/app_seeds/test/services/social_settings_test.dart @@ -47,4 +47,28 @@ void main() { await settings.setAreaGeohash('sp3e9'); expect(await settings.isConfigured, isTrue); }); + + test('search precision defaults to 4 (a wide "around here")', () async { + expect(await settings.searchPrecision(), 4); + }); + + test('stores and reads back the search precision', () async { + await settings.setSearchPrecision(3); + expect(await settings.searchPrecision(), 3); + await settings.setSearchPrecision(5); + expect(await settings.searchPrecision(), 5); + }); + + test('search precision is clamped to the 3–5 range', () async { + await settings.setSearchPrecision(1); + expect(await settings.searchPrecision(), 3); + await settings.setSearchPrecision(9); + expect(await settings.searchPrecision(), 5); + }); + + test('a corrupt stored precision falls back to the default', () async { + final store = FakeSecretStore(); + await store.write('tane.social.search_precision', 'oops'); + expect(await SocialSettings(store).searchPrecision(), 4); + }); } diff --git a/apps/app_seeds/test/state/offers_cubit_test.dart b/apps/app_seeds/test/state/offers_cubit_test.dart index e1d3f05..1eb4565 100644 --- a/apps/app_seeds/test/state/offers_cubit_test.dart +++ b/apps/app_seeds/test/state/offers_cubit_test.dart @@ -4,6 +4,7 @@ import 'package:commons_core/commons_core.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/data/variety_repository.dart'; import 'package:tane/db/enums.dart'; +import 'package:tane/services/discovery_area.dart'; import 'package:tane/services/offer_mapper.dart'; import 'package:tane/services/offer_outbox.dart'; import 'package:tane/state/offers_cubit.dart'; @@ -152,6 +153,31 @@ void main() { await cubit.close(); }); + test('a neighbour in an adjacent cell is found via the coarsened prefix', + () async { + // Regression: A publishes at 'sp3e9', B lives one ±2.4 km cell over at + // 'sp3e8'. Searching B's full 5-char area misses it; searching the + // precision-4 prefix ('sp3e') finds it, because publish emits the ladder. + final transport = FakeOfferTransport(); + await transport.publish(OfferMapper.fromSharedLot( + lotId: 'lot-1', + authorPubkeyHex: 'ab' * 32, + summary: 'Tomate rosa', + sharing: OfferStatus.shared, + areaGeohash: 'sp3e9', + )); + final cubit = OffersCubit(transport); + + await cubit.discover(searchPrefix('sp3e8', 4)); // 'sp3e' + await pumpEventQueue(); + expect(cubit.state.offers, hasLength(1)); + + await cubit.discover('sp3e8'); // full precision → the old, broken behaviour + await pumpEventQueue(); + expect(cubit.state.offers, isEmpty); + await cubit.close(); + }); + test('a different area finds nothing', () async { final transport = FakeOfferTransport(); await transport.publish(OfferMapper.fromSharedLot( diff --git a/apps/app_seeds/test/ui/market_screen_test.dart b/apps/app_seeds/test/ui/market_screen_test.dart index 03824d7..93dfddb 100644 --- a/apps/app_seeds/test/ui/market_screen_test.dart +++ b/apps/app_seeds/test/ui/market_screen_test.dart @@ -91,6 +91,28 @@ void main() { expect(find.text('Retry'), findsOneWidget); // can't-reach state offers retry }); + testWidgets('the range selector persists the chosen search precision', + (tester) async { + final social = await SocialService.fromRootSeedHex('00' * 32); + final settings = SocialSettings(InMemorySecretStore()); + await settings.setRelayUrls(const []); // offline: don't hit the network + + await tester.pumpWidget(_wrapMarket(social, settings)); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('market.config'))); + await tester.pumpAndSettle(); + + // Defaults to the wide "Around here" (precision 4); narrow to "My region". + expect(await settings.searchPrecision(), 4); + await tester.tap(find.text('My region')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('market.save'))); + await tester.pumpAndSettle(); + + expect(await settings.searchPrecision(), 3); + }); + testWidgets('"use my location" fills the area with a coarse geohash', (tester) async { final social = await SocialService.fromRootSeedHex('00' * 32); diff --git a/packages/commons_core/test/social/nostr_transports_test.dart b/packages/commons_core/test/social/nostr_transports_test.dart index 0780461..2f6f978 100644 --- a/packages/commons_core/test/social/nostr_transports_test.dart +++ b/packages/commons_core/test/social/nostr_transports_test.dart @@ -48,6 +48,35 @@ void main() { await bobT.close(); }); + test('a coarser search prefix matches, an adjacent full cell does not', + () async { + // Alice publishes at 'sp3e9'. A neighbour searching the precision-4 + // prefix 'sp3e' finds her (the publish ladder emits 'sp3e'); searching + // the adjacent full cell 'sp3e8' finds nothing (exact tag match). + final alice = await idFor(1); + final bob = await idFor(2); + final aliceT = NostrOfferTransport(await connFor(alice)); + final bobT = NostrOfferTransport(await connFor(bob)); + await aliceT.publish(Offer( + id: 'tomate-1', + authorPubkeyHex: alice.publicKeyHex, + summary: 'Tomate rosa', + type: OfferType.gift, + approxGeohash: 'sp3e9', + )); + + final wide = await bobT + .discoverUntilEose(const DiscoveryQuery(geohashPrefix: 'sp3e')); + expect(wide, hasLength(1)); + + final adjacent = await bobT + .discoverUntilEose(const DiscoveryQuery(geohashPrefix: 'sp3e8')); + expect(adjacent, isEmpty); + + await aliceT.close(); + await bobT.close(); + }); + test('exact geohash never leaves the device (coarsened on the wire)', () async { final alice = await idFor(1);