diff --git a/lib/editor/search/search_ranking.dart b/lib/editor/search/search_ranking.dart new file mode 100644 index 0000000..de4937f --- /dev/null +++ b/lib/editor/search/search_ranking.dart @@ -0,0 +1,80 @@ +// lib/editor/search/search_ranking.dart +// +// Pure ranking + aggregation for full-text search (F8). Given many sources (PDF +// text pages, typed boxes, OCR'd handwriting) keyed by a ref string, score each +// for a query and return the best hits with display snippets. This is the +// search_indexer's ranking core; the FTS pre-filter/index lives in the DB. +// +// Pure (no storage/widgets); builds on search_text (normalize/match) + +// search_snippet (excerpt). Fully unit-tested. + +import 'search_snippet.dart'; +import 'search_text.dart'; + +/// A ranked search result: WHERE it is ([ref]), the display [snippet], and the +/// [score] (higher = better). +class SearchHit { + const SearchHit({required this.ref, required this.snippet, required this.score}); + + final String ref; + final Snippet snippet; + final double score; + + @override + String toString() => 'SearchHit($ref, score=${score.toStringAsFixed(3)})'; +} + +/// Score [source] for [query]: more (normalized) occurrences rank higher, and an +/// earlier first match breaks ties. 0 when there is no match. +double scoreText(String source, String query) { + final q = normalizeForIndex(query); + if (q.isEmpty) return 0; + final s = normalizeForIndex(source); + if (s.isEmpty) return 0; + + var count = 0; + var from = 0; + var firstPos = -1; + while (true) { + final idx = s.indexOf(q, from); + if (idx < 0) break; + if (firstPos < 0) firstPos = idx; + count++; + from = idx + q.length; + } + if (count == 0) return 0; + + // Earliness in (0,1]: a match at position 0 scores 1.0. + final earliness = 1.0 - (firstPos / s.length); + return count + earliness; +} + +/// Build + rank hits across [sources] (ref → raw text) for [query]: drop +/// non-matches, attach a display snippet of the ORIGINAL text, sort best-first. +/// Ties (equal score) keep input order (stable). +List rankHits( + Map sources, + String query, { + int window = 80, +}) { + final indexed = <({int order, SearchHit hit})>[]; + var order = 0; + for (final entry in sources.entries) { + final i = order++; + final score = scoreText(entry.value, query); + if (score <= 0) continue; + final snippet = snippetFor(entry.value, query, window: window); + if (snippet == null) continue; // matched normalized but not raw (rare) + indexed.add(( + order: i, + hit: SearchHit(ref: entry.key, snippet: snippet, score: score), + )); + } + // Descending score; ties keep input order (Dart's sort isn't stable, so the + // input index is an explicit tiebreaker). + indexed.sort((a, b) { + final byScore = b.hit.score.compareTo(a.hit.score); + return byScore != 0 ? byScore : a.order.compareTo(b.order); + }); + return [for (final e in indexed) e.hit]; +} diff --git a/test/search_ranking_test.dart b/test/search_ranking_test.dart new file mode 100644 index 0000000..9c7b2c2 --- /dev/null +++ b/test/search_ranking_test.dart @@ -0,0 +1,63 @@ +// Tests for search ranking + aggregation (F8). + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:badnote/editor/search/search_ranking.dart'; + +void main() { + group('scoreText', () { + test('0 for empty query or no match', () { + expect(scoreText('hello world', ''), 0); + expect(scoreText('hello world', 'zzz'), 0); + expect(scoreText('', 'x'), 0); + }); + + test('more occurrences score higher', () { + final one = scoreText('cat dog', 'cat'); + final three = scoreText('cat cat cat', 'cat'); + expect(three, greaterThan(one)); + }); + + test('earlier first match breaks ties (same count)', () { + final early = scoreText('needle then padding padding', 'needle'); + final late = scoreText('padding padding then needle', 'needle'); + expect(early, greaterThan(late)); + }); + + test('a match at position 0 gives earliness 1 (score = count + 1)', () { + expect(scoreText('cat', 'cat'), closeTo(2.0, 1e-9)); // 1 occ + 1.0 + }); + }); + + group('rankHits', () { + test('drops non-matches and orders best-first', () { + final hits = rankHits({ + 'p1': 'one mention of fox', + 'p2': 'fox fox fox everywhere', // 3 occ → highest + 'p3': 'nothing relevant here', + }, 'fox'); + expect(hits.map((h) => h.ref), ['p2', 'p1']); + expect(hits.first.score, greaterThan(hits.last.score)); + }); + + test('each hit carries a display snippet of the original text', () { + final hits = rankHits({'p1': 'The quick brown fox jumps'}, 'fox'); + expect(hits, hasLength(1)); + expect(hits.first.snippet.match, 'fox'); + expect(hits.first.ref, 'p1'); + }); + + test('equal scores keep input order (explicit tiebreak)', () { + final hits = rankHits({ + 'a': 'match', + 'b': 'match', + }, 'match'); + expect(hits.map((h) => h.ref), ['a', 'b']); + expect(hits[0].score, hits[1].score); + }); + + test('empty query → no hits', () { + expect(rankHits({'a': 'anything'}, ''), isEmpty); + }); + }); +}