feat(social): add SavedSearch model with shared offer matcher

Extract the market's query/type/category/organic filter chain into
SavedSearch.matchesFilters so live results and saved-search alerts can
never disagree. OffersState.visibleOffers now delegates to it.
This commit is contained in:
vjrj 2026-07-17 12:35:56 +02:00
parent dd9c97a57a
commit 5b1066a224
4 changed files with 251 additions and 9 deletions

View file

@ -0,0 +1,116 @@
import 'package:commons_core/commons_core.dart';
import 'package:test/test.dart';
void main() {
Offer offer({
String summary = 'Heirloom tomato seeds',
OfferType type = OfferType.gift,
String? category = 'Solanaceae',
bool isOrganic = false,
}) =>
Offer(
id: 'o1',
authorPubkeyHex: 'author1',
summary: summary,
type: type,
approxGeohash: 'u09',
category: category,
isOrganic: isOrganic,
);
SavedSearch search({
String query = '',
Set<OfferType> types = const {},
Set<String> categories = const {},
bool organicOnly = false,
}) =>
SavedSearch(
id: 's1',
label: 'test',
query: query,
types: types,
categories: categories,
organicOnly: organicOnly,
);
group('matches', () {
test('empty search matches any active offer', () {
expect(search().matches(offer()), isTrue);
});
test('text query matches case-insensitively over the summary', () {
expect(search(query: 'TOMATO').matches(offer()), isTrue);
expect(search(query: ' tomato ').matches(offer()), isTrue);
expect(search(query: 'pepper').matches(offer()), isFalse);
});
test('type facet filters by reciprocity mode', () {
expect(search(types: {OfferType.gift}).matches(offer()), isTrue);
expect(search(types: {OfferType.sale}).matches(offer()), isFalse);
expect(
search(types: {OfferType.gift, OfferType.exchange}).matches(offer()),
isTrue,
);
});
test('category facet requires a matching category', () {
expect(search(categories: {'Solanaceae'}).matches(offer()), isTrue);
expect(search(categories: {'Fabaceae'}).matches(offer()), isFalse);
expect(
search(categories: {'Solanaceae'}).matches(offer(category: null)),
isFalse,
);
});
test('organicOnly requires a self-declared organic offer', () {
expect(search(organicOnly: true).matches(offer(isOrganic: true)), isTrue);
expect(search(organicOnly: true).matches(offer(isOrganic: false)), isFalse);
expect(search().matches(offer(isOrganic: false)), isTrue);
});
test('facets are ANDed together', () {
final s = search(
query: 'tomato',
types: {OfferType.gift},
categories: {'Solanaceae'},
organicOnly: true,
);
expect(s.matches(offer(isOrganic: true)), isTrue);
// One facet off is enough to reject.
expect(s.matches(offer(isOrganic: false)), isFalse);
expect(s.matches(offer(type: OfferType.sale, isOrganic: true)), isFalse);
});
test('matchesFilters agrees with the instance matcher', () {
expect(
SavedSearch.matchesFilters(offer(), query: 'tomato'),
search(query: 'tomato').matches(offer()),
);
});
});
group('json', () {
test('round-trips all fields', () {
final s = SavedSearch(
id: 's1',
label: 'Tomates cerca',
query: 'tomate',
types: {OfferType.gift, OfferType.exchange},
categories: {'Solanaceae', 'Fabaceae'},
organicOnly: true,
createdAt: 1720000000000,
);
expect(SavedSearch.fromJson(s.toJson()), s);
});
test('round-trips a minimal search (empty facets)', () {
final s = SavedSearch(id: 's2', label: 'all', createdAt: 42);
final decoded = SavedSearch.fromJson(s.toJson());
expect(decoded, s);
expect(decoded.query, isEmpty);
expect(decoded.types, isEmpty);
expect(decoded.categories, isEmpty);
expect(decoded.organicOnly, isFalse);
});
});
}