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.
This commit is contained in:
vjrj 2026-07-10 16:12:38 +02:00
parent 1fb8e37536
commit 97b9223cb2
16 changed files with 268 additions and 10 deletions

View file

@ -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));
});
}

View file

@ -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 35 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);
});
}

View file

@ -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(

View file

@ -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);