diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index e7fe3a8..c2d3258 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -8,6 +8,15 @@ _(nothing active — pick the next batch from below)_ ## Deferred / future refinements +- [ ] **Rediscover — betweenness centrality.** P13e-2 ranks by **PageRank** (cheap, deterministic). Betweenness + (bridge-ness) is O(V·E) — too costly for a query-time strip; explore it as an alternative/secondary signal, + likely alongside e-3's path/bridge work. *(From P13e-2.)* +- [ ] **Shared entity item-graph builder.** `community_clustering.dart` (e-1, unweighted adjacency + label + propagation) and `centrality.dart` (e-2, weighted adjacency + PageRank) both rebuild an item graph from the + same membership + co-download pulls. Extract a shared builder once a third consumer appears. *(From P13e-2.)* +- [ ] **Rediscover — tuning + "See all".** `freshWindow`/`stalenessCapDays`/`limit` are fixed defaults and the + scores recompute query-time (like Suggested/Discovered); consider per-tier tuning, a full "See all" + Rediscover screen, and caching. *(From P13e-2.)* - [ ] **Community albums — semantic-similarity + device-tier enhancements.** P13e-1 detects communities over the **entity** graph only (every-device). Fold **semantic-similarity edges** (looser-threshold vector kNN) into the same label-propagation graph on capable tiers for richer thematic grouping, gated like the diff --git a/docs/GRAPH-SPEC.md b/docs/GRAPH-SPEC.md index 4f2c3fb..2d27e74 100644 --- a/docs/GRAPH-SPEC.md +++ b/docs/GRAPH-SPEC.md @@ -261,7 +261,7 @@ gracefully when `GraphStore.isAvailable` is false. | **Tag suggestions** (P10c-c-2, **live**) | `GraphQueryService.coOccurringTags`: tags on items sharing a deterministic signal with this one (`postedBy`/`inPlaylist`/`taggedWith`/`coDownloadedWith`), minus the item's own tags, ranked in Dart (`cooccurrence_ranking.dart`) by distinct supporting items. Surfaced as tappable chips in the metadata editor. Pure Datalog — every device. | | **Interactive graph viz** (P10c-e/f) | **e (render, live):** `GraphQueryService.neighborhood(id)` (an item's direct entity + duplicate/co-download edges) → a force-directed `graphview` render with pan/zoom + a type legend, reached via item-detail "View in graph". Deterministic edges — no embedder. **f (live):** tap a media node → its item, tap an entity node → expand its media (and long-press → open hub), edge-type **legend filters**, expansion capped (`:limit`). | | **Graph-clustered auto-albums** (P13) | **community detection / label propagation** over the similarity + entity graph. **Realized P13e-1** over the **entity** graph (shared uploader/playlist/tag + co-download) via pure deterministic label propagation (`community_clustering.dart`) — every-device, no embedder; surfaced as the **"Discovered"** album section. Folding in semantic-similarity edges is deferred (BACKLOG). | -| **Centrality "Rediscover"** (P13) | `PageRank` / betweenness × `lastAccessedAt` to resurface central-but-stale items. | +| **Centrality "Rediscover"** (P13) | `PageRank` / betweenness × `lastAccessedAt` to resurface central-but-stale items. **Realized P13e-2** via pure-Dart **PageRank** over the entity item-graph (`centrality.dart`) × staleness (`rankRediscover`) — every-device, no embedder; surfaced as the **"Rediscover"** Dashboard/Library strip. Betweenness deferred (BACKLOG / e-3). | | **Path / bridge discovery** (P13) | shortest-path / connectivity between two items or entities. | | **Local GraphRAG "Ask your library"** (P13) | hybrid retrieval (vector + graph re-rank) feeds the on-device LLM (see `AI-SPEC.md`). | diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index 0714321..9c784ce 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -1033,6 +1033,16 @@ entries, or verify after P11c lands.)* - [ ] Tapping a discovered album opens its items; **Save** creates a normal collection containing them. - [ ] The Discovered section is **absent** when the graph index is unavailable (e.g. unsupported ABI). +### P13e-2 — Centrality "Rediscover" strip *(install `app-arm64-v8a-debug.apk`; needs an established library)* +- [ ] On a library with cross-links (shared channels/playlists/tags or co-downloaded batches), a **"Rediscover"** + strip appears on the **Dashboard** (below "Recently opened") **and** atop the **Library**, surfacing items + that are well-connected but that you **haven't opened in a while**. +- [ ] Items opened in the **last ~2 weeks do not** appear (no overlap with "Recently opened"); tapping a tile + opens the item. +- [ ] The strip is **absent** on a small/new library, while searching/filtering/selecting in the Library, and + when the graph index is unavailable. +- [ ] Works on a **low-end device** (entity graph only — no embedder/LLM). + ### P13 (later subphases) - [ ] **Transcription / summarization / translation / OCR** each work (capability-gated) and write results back to the item. diff --git a/docs/design/P13-PLAN.md b/docs/design/P13-PLAN.md index 0357b61..04b8b47 100644 --- a/docs/design/P13-PLAN.md +++ b/docs/design/P13-PLAN.md @@ -311,11 +311,22 @@ The richer graph payoff beyond P10's Duplicates + Suggested-similarity albums (G - **Exit / review:** clusters are coherent on a real library; degrades to nothing when the graph is unavailable; saving a cluster creates a normal collection. ✓ (CI parts) · APK owed -#### `[ ]` P13e-2 — Centrality "Rediscover" *(graph; APK)* -- **PageRank / betweenness × `lastAccessedAt`** to resurface central-but-stale items; surfaced as a - "Rediscover" strip (Dashboard/Library). -- **Exit / review:** Rediscover surfaces genuinely central, not-recently-opened items; empty/ graceful when - the graph is unavailable. +#### `[~]` P13e-2 — Centrality "Rediscover" *(graph; APK)* +- **PageRank × staleness** to resurface central-but-stale items; surfaced as a "Rediscover" strip on the + **Dashboard and Library**. +- **Status:** implemented (CI-green; APK spot-check owed). **PageRank** chosen (deterministic power iteration; + betweenness is O(V·E) — too costly for a strip, deferred to BACKLOG/e-3). Runs over the **entity item-graph** + (shared uploader/playlist/tag + co-download), reusing e-1's `entityMembershipScript()`/`coDownloadPairsScript()` + pulls (decode shared via a private `_entityGraph()` helper) — **every-device, no embedder**. New + `lib/core/graph/centrality.dart` (pure `buildItemGraph` weighted adjacency + `pageRank`); + `GraphQueryService.itemCentrality()`; pure `rediscover.dart` `rankRediscover` (`score = centrality × + staleness`; staleness = days since `lastAccessedAt ?? createdAt`, capped at 30d; **excludes** items touched + within 14d); `rediscoverProvider` + a `RediscoverRow` strip (mirrors `RecentMediaRow`, auto-hides when empty) + on the Dashboard and atop the Library (only when not searching/filtering/selecting). **No schema, no deps.** + Tests: centrality (weight accumulation, bucket pruning, hub > leaf, determinism, dangling), `itemCentrality` + decode, `rankRediscover` (fresh-exclusion, staleness weighting, cap, empty), the provider, and the row. +- **Exit / review:** Rediscover surfaces genuinely central, not-recently-opened items; empty/graceful when + the graph is unavailable. ✓ (CI parts) · APK owed #### `[ ]` P13e-3 — Path/bridge discovery + graph-view polish *(graph; APK)* - **Shortest-path / connectivity** between two items or entities ("how are these related?"), plus graph-view diff --git a/lib/core/graph/centrality.dart b/lib/core/graph/centrality.dart new file mode 100644 index 0000000..88ad904 --- /dev/null +++ b/lib/core/graph/centrality.dart @@ -0,0 +1,90 @@ +/// Pure centrality scoring for the "Rediscover" strip (P13e-2). No Flutter, +/// engine, or AI imports — `GraphQueryService` feeds it the decoded entity +/// memberships + co-download pairs and it returns per-item PageRank, so the +/// whole thing is unit-testable. Sits beside `community_clustering.dart` (the +/// same entity item-graph, scored for importance rather than grouped). +library; + +/// A **weighted, undirected** item↔item adjacency built from the entity graph: +/// items sharing an entity bucket ([memberships]; `group` is a type-prefixed key +/// like `u:` / `p:` / `t:`) gain +1 edge weight per shared bucket, +/// and each direct [pairs] edge (co-download) adds +1 more. Buckets larger than +/// [maxGroupSize] are dropped as **too generic** (the "discard blobs" rule from +/// the clusterer). Returns `id → {neighbor → weight}` (both directions). +Map> buildItemGraph({ + required List<({String item, String group})> memberships, + required List<({String a, String b})> pairs, + int maxGroupSize = 50, +}) { + final byGroup = >{}; + for (final m in memberships) { + (byGroup[m.group] ??= []).add(m.item); + } + + final adj = >{}; + void link(String a, String b) { + if (a == b) return; + final ma = adj[a] ??= {}; + ma[b] = (ma[b] ?? 0) + 1; + final mb = adj[b] ??= {}; + mb[a] = (mb[a] ?? 0) + 1; + } + + for (final members in byGroup.values) { + if (members.length < 2 || members.length > maxGroupSize) continue; + for (var i = 0; i < members.length; i++) { + for (var j = i + 1; j < members.length; j++) { + link(members[i], members[j]); + } + } + } + for (final p in pairs) { + link(p.a, p.b); + } + return adj; +} + +/// Weighted **PageRank** over [adjacency] (from [buildItemGraph]) — each item's +/// score is its standing in the library's web, so hub items (shared with many +/// others through many signals) rank highest. Deterministic power iteration with +/// dangling-mass redistribution; returns `id → score` (scores sum to ~1). +Map pageRank( + Map> adjacency, { + int iterations = 40, + double damping = 0.85, +}) { + final nodes = adjacency.keys.toList(); + final n = nodes.length; + if (n == 0) return const {}; + + final outWeight = { + for (final node in nodes) + node: adjacency[node]!.values.fold(0, (s, w) => s + w), + }; + final base = (1 - damping) / n; + var rank = {for (final node in nodes) node: 1.0 / n}; + + for (var iter = 0; iter < iterations; iter++) { + final next = {for (final node in nodes) node: base}; + var danglingMass = 0.0; + for (final node in nodes) { + final ow = outWeight[node]!; + if (ow == 0) { + danglingMass += rank[node]!; + continue; + } + final share = damping * rank[node]! / ow; + adjacency[node]!.forEach((nb, w) { + next[nb] = next[nb]! + share * w; + }); + } + if (danglingMass > 0) { + final spread = damping * danglingMass / n; + for (final node in nodes) { + next[node] = next[node]! + spread; + } + } + rank = next; + } + return rank; +} diff --git a/lib/core/graph/graph_query_service.dart b/lib/core/graph/graph_query_service.dart index 9858dd4..ea7481a 100644 --- a/lib/core/graph/graph_query_service.dart +++ b/lib/core/graph/graph_query_service.dart @@ -1,3 +1,4 @@ +import 'package:grabbit/core/graph/centrality.dart'; import 'package:grabbit/core/graph/community_clustering.dart'; import 'package:grabbit/core/graph/cooccurrence_ranking.dart'; import 'package:grabbit/core/graph/cozo_query.dart'; @@ -173,6 +174,45 @@ class GraphQueryService { int maxGroupSize = 50, }) async { if (!_store.isAvailable) return const []; + final (:memberships, :pairs) = await _entityGraph(); + if (memberships.isEmpty) return const []; + return detectCommunities( + memberships: memberships, + pairs: pairs, + minSize: minSize, + maxSize: maxSize, + maxGroupSize: maxGroupSize, + ); + } + + /// PageRank **centrality** of every connected item over the entity graph + /// (P13e-2) — `id → score`, ranking items by how woven they are into the + /// library's web (shared uploader/playlist/tag + co-download). Every-device + /// (pure Datalog + Dart; no embedder). `{}` when the store is unavailable. + /// Feeds the "Rediscover" strip via `rankRediscover`. + Future> itemCentrality({int maxGroupSize = 50}) async { + if (!_store.isAvailable) return const {}; + final (:memberships, :pairs) = await _entityGraph(); + if (memberships.isEmpty && pairs.isEmpty) return const {}; + return pageRank( + buildItemGraph( + memberships: memberships, + pairs: pairs, + maxGroupSize: maxGroupSize, + ), + ); + } + + /// Decodes the entity-membership (`item`, type-prefixed `group`) + co-download + /// (`a`/`b`) pulls shared by the community (P13e-1) and centrality (P13e-2) + /// builders over the deterministic entity graph. + Future< + ({ + List<({String item, String group})> memberships, + List<({String a, String b})> pairs, + }) + > + _entityGraph() async { final memberships = [ for (final r in decodeRows( await _store.runScript(entityMembershipScript()), @@ -182,7 +222,6 @@ class GraphQueryService { if (r['key'] case final Object key) (item: id.toString(), group: '$kind:$key'), ]; - if (memberships.isEmpty) return const []; final pairs = [ for (final r in decodeRows( await _store.runScript(coDownloadPairsScript()), @@ -190,13 +229,7 @@ class GraphQueryService { if (r['a'] case final Object a) if (r['b'] case final Object b) (a: a.toString(), b: b.toString()), ]; - return detectCommunities( - memberships: memberships, - pairs: pairs, - minSize: minSize, - maxSize: maxSize, - maxGroupSize: maxGroupSize, - ); + return (memberships: memberships, pairs: pairs); } /// The immediate graph neighborhood of media item [id] — its connected diff --git a/lib/features/dashboard/presentation/dashboard_screen.dart b/lib/features/dashboard/presentation/dashboard_screen.dart index dddcb39..0089dd9 100644 --- a/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/lib/features/dashboard/presentation/dashboard_screen.dart @@ -18,6 +18,7 @@ import 'package:grabbit/features/dashboard/presentation/widgets/duplicates_callo import 'package:grabbit/features/dashboard/presentation/widgets/graph_entry_tile.dart'; import 'package:grabbit/features/dashboard/presentation/widgets/recent_activity_tile.dart'; import 'package:grabbit/features/dashboard/presentation/widgets/recent_media_row.dart'; +import 'package:grabbit/features/dashboard/presentation/widgets/rediscover_row.dart'; import 'package:grabbit/features/dashboard/presentation/widgets/stat_card.dart'; import 'package:grabbit/features/dashboard/presentation/widgets/storage_donut_tile.dart'; import 'package:grabbit/features/dashboard/presentation/widgets/suggestions_tile.dart'; @@ -171,6 +172,7 @@ class _DashboardBody extends ConsumerWidget { title: 'Recently opened', provider: recentlyPlayedProvider, ), + const RediscoverRow(), const RecentActivityTile(), const SuggestionsTile(), const DuplicatesCallout(), diff --git a/lib/features/dashboard/presentation/widgets/rediscover_row.dart b/lib/features/dashboard/presentation/widgets/rediscover_row.dart new file mode 100644 index 0000000..710fc96 --- /dev/null +++ b/lib/features/dashboard/presentation/widgets/rediscover_row.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:grabbit/core/theme/tokens.dart'; +import 'package:grabbit/core/widgets/section_header.dart'; +import 'package:grabbit/features/library/presentation/media_grid.dart'; +import 'package:grabbit/features/library/presentation/rediscover_provider.dart'; + +/// A horizontal strip of **central-but-stale** media — items woven into the +/// library graph that haven't been opened lately (P13e-2). Mirrors +/// `RecentMediaRow`, but fed by the graph-derived `rediscoverProvider`; auto- +/// hides when empty (small/new library, or the graph is unavailable). +class RediscoverRow extends ConsumerWidget { + const RediscoverRow({this.cap = 12, super.key}); + + final int cap; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tokens = GrabBitTokens.of(context); + final items = ref.watch(rediscoverProvider).asData?.value ?? const []; + if (items.isEmpty) return const SizedBox.shrink(); + + final shown = items.take(cap).toList(); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SectionHeader('Rediscover', icon: Icons.auto_awesome_motion), + SizedBox( + height: 172, + child: ListView.separated( + scrollDirection: Axis.horizontal, + clipBehavior: Clip.none, + padding: EdgeInsets.symmetric(horizontal: tokens.spaceLg), + itemCount: shown.length, + separatorBuilder: (_, _) => SizedBox(width: tokens.spaceMd), + itemBuilder: (_, i) => + SizedBox(width: 124, child: MediaTile(item: shown[i])), + ), + ), + ], + ); + } +} diff --git a/lib/features/library/presentation/library_view.dart b/lib/features/library/presentation/library_view.dart index f617cc0..7c57ab9 100644 --- a/lib/features/library/presentation/library_view.dart +++ b/lib/features/library/presentation/library_view.dart @@ -8,6 +8,7 @@ import 'package:grabbit/core/widgets/empty_state.dart'; import 'package:grabbit/core/widgets/error_view.dart'; import 'package:grabbit/core/widgets/skeleton.dart'; import 'package:grabbit/core/db/database.dart'; +import 'package:grabbit/features/dashboard/presentation/widgets/rediscover_row.dart'; import 'package:grabbit/features/library/data/metadata_repository.dart'; import 'package:grabbit/features/library/presentation/library_controller.dart'; import 'package:grabbit/features/library/presentation/library_filter_sheet.dart'; @@ -123,6 +124,9 @@ class _LibraryViewState extends ConsumerState { }, onFilters: () => showLibraryFilters(context), ), + // Resurface central-but-stale items, but only while browsing the full + // library (not mid-search/filter or selection) so it never intrudes. + if (!filtering && _selected.isEmpty) const RediscoverRow(), Expanded( child: RefreshIndicator( onRefresh: () async => refresh(), diff --git a/lib/features/library/presentation/rediscover.dart b/lib/features/library/presentation/rediscover.dart new file mode 100644 index 0000000..7fa8439 --- /dev/null +++ b/lib/features/library/presentation/rediscover.dart @@ -0,0 +1,40 @@ +/// Pure "Rediscover" ranking (P13e-2): combine graph **centrality** with +/// **staleness** to resurface central-but-faded items. No Flutter/Drift imports +/// so the scoring is unit-testable; the provider supplies centrality (from the +/// graph) and per-item last-touch times (from Drift). +library; + +/// Ranks items for the Rediscover strip: `score = centrality × staleness`, where +/// staleness grows with days since the item was last touched ([lastTouchById] = +/// `lastAccessedAt ?? createdAt`). Items touched within [freshWindow] are +/// **excluded** (they already live in "Recently opened"); staleness saturates at +/// [stalenessCapDays]. Returns the top [limit] ids, most-relevant first (ties → +/// more-stale, then id for stability). Items without a centrality score or a +/// known touch time are skipped. +List rankRediscover({ + required Map centrality, + required Map lastTouchById, + required DateTime now, + Duration freshWindow = const Duration(days: 14), + double stalenessCapDays = 30, + int limit = 12, +}) { + final scored = <({String id, double score, double days})>[]; + centrality.forEach((id, rank) { + if (rank <= 0) return; + final touched = lastTouchById[id]; + if (touched == null) return; + final days = now.difference(touched).inSeconds / Duration.secondsPerDay; + if (days < freshWindow.inSeconds / Duration.secondsPerDay) return; + final staleness = (days / stalenessCapDays).clamp(0.0, 1.0); + scored.add((id: id, score: rank * staleness, days: days)); + }); + + scored.sort((a, b) { + final byScore = b.score.compareTo(a.score); + if (byScore != 0) return byScore; + final byDays = b.days.compareTo(a.days); + return byDays != 0 ? byDays : a.id.compareTo(b.id); + }); + return [for (final s in scored.take(limit)) s.id]; +} diff --git a/lib/features/library/presentation/rediscover_provider.dart b/lib/features/library/presentation/rediscover_provider.dart new file mode 100644 index 0000000..7eb7170 --- /dev/null +++ b/lib/features/library/presentation/rediscover_provider.dart @@ -0,0 +1,37 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:grabbit/core/db/database.dart'; +import 'package:grabbit/core/db/database_provider.dart'; +import 'package:grabbit/core/graph/graph_query_provider.dart'; +import 'package:grabbit/features/library/presentation/rediscover.dart'; + +// Hand-written (returns Drift `MediaItem` rows): central-but-stale items for the +// "Rediscover" strip (P13e-2). + +/// Items to **resurface** — central in the library graph (PageRank) yet not +/// opened recently — for the Dashboard/Library "Rediscover" strip. Every-device +/// (graph only, no embedder). `[]` when the graph is unavailable or nothing +/// qualifies, so the strip simply hides. +final rediscoverProvider = FutureProvider>((ref) async { + final centrality = await ref + .watch(graphQueryServiceProvider) + .itemCentrality(); + if (centrality.isEmpty) return const []; + + final db = ref.watch(appDatabaseProvider); + final found = await (db.select( + db.mediaItems, + )..where((t) => t.id.isIn(centrality.keys.toList()))).get(); + final byId = {for (final m in found) m.id: m}; + + final ranked = rankRediscover( + centrality: centrality, + lastTouchById: { + for (final m in found) m.id: m.lastAccessedAt ?? m.createdAt, + }, + now: DateTime.now(), + ); + return [ + for (final id in ranked) + if (byId[id] case final MediaItem m) m, + ]; +}); diff --git a/test/core/graph/centrality_test.dart b/test/core/graph/centrality_test.dart new file mode 100644 index 0000000..44fb808 --- /dev/null +++ b/test/core/graph/centrality_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:grabbit/core/graph/centrality.dart'; + +List<({String item, String group})> _members( + Map> byGroup, +) => [ + for (final e in byGroup.entries) + for (final item in e.value) (item: item, group: e.key), +]; + +void main() { + group('buildItemGraph', () { + test('accumulates weight across shared buckets + co-download', () { + final adj = buildItemGraph( + memberships: _members({ + 't:rock': ['a', 'b'], + 'u:chan1': ['a', 'b'], // a–b share two buckets + }), + pairs: const [(a: 'a', b: 'b')], // ...and a direct edge + ); + expect(adj['a']!['b'], 3); + expect(adj['b']!['a'], 3); // symmetric + }); + + test('drops over-generic buckets (> maxGroupSize)', () { + final adj = buildItemGraph( + memberships: _members({ + 't:everything': ['a', 'b', 'c', 'd'], + }), + pairs: const [], + maxGroupSize: 3, + ); + expect(adj, isEmpty); + }); + }); + + group('pageRank', () { + test('a hub outranks its leaves', () { + // Star: h connected to a,b,c,d; leaves touch only h. + final adj = buildItemGraph( + memberships: const [], + pairs: const [ + (a: 'h', b: 'a'), + (a: 'h', b: 'b'), + (a: 'h', b: 'c'), + (a: 'h', b: 'd'), + ], + ); + final pr = pageRank(adj); + expect(pr['h']! > pr['a']!, isTrue); + expect(pr['a'], closeTo(pr['b']!, 1e-9)); // symmetric leaves tie + }); + + test('is deterministic across runs', () { + final adj = buildItemGraph( + memberships: _members({ + 't:rock': ['a', 'b', 'c'], + }), + pairs: const [(a: 'c', b: 'd')], + ); + expect(pageRank(adj), pageRank(adj)); + }); + + test('empty graph yields no scores', () { + expect(pageRank(const {}), isEmpty); + }); + + test('handles a dangling node without blowing up', () { + final pr = pageRank({ + 'a': {'b': 1}, + 'b': {}, // dangling (no out-edges) + }); + expect(pr.keys.toSet(), {'a', 'b'}); + expect(pr.values.every((v) => v.isFinite), isTrue); + expect(pr.values.reduce((s, v) => s + v), closeTo(1.0, 1e-6)); + }); + }); +} diff --git a/test/core/graph/graph_query_service_test.dart b/test/core/graph/graph_query_service_test.dart index 7bcc3ac..3b7a46e 100644 --- a/test/core/graph/graph_query_service_test.dart +++ b/test/core/graph/graph_query_service_test.dart @@ -344,4 +344,41 @@ void main() { }, ); }); + + group('GraphQueryService.itemCentrality', () { + Map respond(String script) { + if (script.contains('coDownloadedWith')) { + return const { + 'headers': ['a', 'b'], + 'rows': >[], + }; + } + return const { + 'headers': ['mediaId', 'kind', 'key'], + 'rows': [ + ['a', 't', 'rock'], + ['b', 't', 'rock'], + ['c', 't', 'rock'], + ], + }; + } + + test('scores every connected item (symmetric triangle ties)', () async { + final scores = await GraphQueryService( + FakeGraphStore(responder: respond), + ).itemCentrality(); + expect(scores.keys.toSet(), {'a', 'b', 'c'}); + expect(scores.values.every((v) => v > 0), isTrue); + expect(scores['a'], closeTo(scores['b']!, 1e-9)); + }); + + test( + 'returns empty when the store is unavailable (no query run)', + () async { + final store = FakeGraphStore(available: false); + expect(await GraphQueryService(store).itemCentrality(), isEmpty); + expect(store.calls, isEmpty); + }, + ); + }); } diff --git a/test/features/dashboard/rediscover_row_test.dart b/test/features/dashboard/rediscover_row_test.dart new file mode 100644 index 0000000..462ca88 --- /dev/null +++ b/test/features/dashboard/rediscover_row_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:grabbit/core/db/database.dart'; +import 'package:grabbit/features/dashboard/presentation/widgets/rediscover_row.dart'; +import 'package:grabbit/features/library/presentation/media_grid.dart'; +import 'package:grabbit/features/library/presentation/rediscover_provider.dart'; + +MediaItem _item(String id) => MediaItem( + id: id, + title: id, + sourceUrl: 'u', + site: 'youtube', + filePath: '/tmp/$id', + type: 'video', + sizeBytes: 1, + createdAt: DateTime.utc(2026), + storageState: 'private', + isFavorite: false, +); + +Future _pump(WidgetTester tester, List items) { + return tester.pumpWidget( + ProviderScope( + overrides: [rediscoverProvider.overrideWith((ref) async => items)], + child: const MaterialApp(home: Scaffold(body: RediscoverRow())), + ), + ); +} + +void main() { + testWidgets('renders the header and tiles when there are items', ( + tester, + ) async { + await _pump(tester, [_item('a'), _item('b')]); + await tester.pump(); + expect(find.text('Rediscover'), findsOneWidget); + expect(find.byType(MediaTile), findsNWidgets(2)); + }); + + testWidgets('auto-hides when empty', (tester) async { + await _pump(tester, const []); + await tester.pump(); + expect(find.text('Rediscover'), findsNothing); + expect(find.byType(MediaTile), findsNothing); + }); +} diff --git a/test/features/library/rediscover_provider_test.dart b/test/features/library/rediscover_provider_test.dart new file mode 100644 index 0000000..32e976c --- /dev/null +++ b/test/features/library/rediscover_provider_test.dart @@ -0,0 +1,82 @@ +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:grabbit/core/db/database.dart'; +import 'package:grabbit/core/db/database_provider.dart'; +import 'package:grabbit/core/graph/graph_query_provider.dart'; +import 'package:grabbit/core/graph/graph_query_service.dart'; +import 'package:grabbit/features/library/presentation/rediscover_provider.dart'; + +import '../../support/graph_fakes.dart'; + +void main() { + // A triangle a–b–c (all share one tag) so all three are equally central. + Map respond(String script) { + if (script.contains('coDownloadedWith')) { + return const { + 'headers': ['a', 'b'], + 'rows': >[], + }; + } + return const { + 'headers': ['mediaId', 'kind', 'key'], + 'rows': [ + ['a', 't', 'rock'], + ['b', 't', 'rock'], + ['c', 't', 'rock'], + ], + }; + } + + Future seed(AppDatabase db, String id, {DateTime? lastAccessed}) => db + .into(db.mediaItems) + .insert( + MediaItemsCompanion.insert( + id: id, + title: 'Clip $id', + sourceUrl: 'https://y/$id', + site: 'youtube', + filePath: '/m/$id', + type: 'video', + createdAt: DateTime.utc(2025), // old -> stale by default + storageState: 'private', + lastAccessedAt: Value(lastAccessed), + ), + ); + + test('surfaces central-but-stale items, excluding freshly opened', () async { + final db = AppDatabase(NativeDatabase.memory()); + addTearDown(db.close); + await seed(db, 'a'); // never opened, old -> stale + await seed(db, 'b', lastAccessed: DateTime.now()); // fresh -> excluded + await seed(db, 'c'); // stale + + final c = ProviderContainer( + overrides: [ + appDatabaseProvider.overrideWithValue(db), + graphQueryServiceProvider.overrideWithValue( + GraphQueryService(FakeGraphStore(responder: respond)), + ), + ], + ); + addTearDown(c.dispose); + + final items = await c.read(rediscoverProvider.future); + final ids = items.map((m) => m.id).toList(); + expect(ids, containsAll(['a', 'c'])); + expect(ids, isNot(contains('b'))); + }); + + test('empty when the graph store is unavailable', () async { + final c = ProviderContainer( + overrides: [ + graphQueryServiceProvider.overrideWithValue( + GraphQueryService(FakeGraphStore(available: false)), + ), + ], + ); + addTearDown(c.dispose); + expect(await c.read(rediscoverProvider.future), isEmpty); + }); +} diff --git a/test/features/library/rediscover_test.dart b/test/features/library/rediscover_test.dart new file mode 100644 index 0000000..304e0d8 --- /dev/null +++ b/test/features/library/rediscover_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:grabbit/features/library/presentation/rediscover.dart'; + +void main() { + final now = DateTime.utc(2026, 6, 1); + DateTime daysAgo(int d) => now.subtract(Duration(days: d)); + + group('rankRediscover', () { + test('excludes freshly-touched items, keeps stale central ones', () { + final ranked = rankRediscover( + centrality: {'a': 0.5, 'b': 0.3, 'c': 0.2}, + lastTouchById: { + 'a': daysAgo(60), // stale + 'b': daysAgo(2), // fresh -> excluded + 'c': daysAgo(60), // stale + }, + now: now, + ); + expect(ranked, ['a', 'c']); // by score, b dropped + }); + + test('staleness lifts an older item over a more central recent one', () { + final ranked = rankRediscover( + centrality: {'x': 0.6, 'y': 0.5}, + lastTouchById: { + 'x': daysAgo(20), // staleness 20/30 + 'y': daysAgo(60), // staleness capped at 1 + }, + now: now, + ); + // x: 0.6 * 0.667 = 0.40 ; y: 0.5 * 1 = 0.50 -> y first + expect(ranked, ['y', 'x']); + }); + + test('caps the result at limit', () { + final ranked = rankRediscover( + centrality: {for (var i = 0; i < 20; i++) 'i$i': 1.0 - i / 100}, + lastTouchById: {for (var i = 0; i < 20; i++) 'i$i': daysAgo(40)}, + now: now, + limit: 5, + ); + expect(ranked, hasLength(5)); + }); + + test('skips items with no centrality or no known touch time', () { + final ranked = rankRediscover( + centrality: {'a': 0.5, 'b': 0.5}, + lastTouchById: {'a': daysAgo(40)}, // b has no touch time + now: now, + ); + expect(ranked, ['a']); + }); + + test('empty input yields empty', () { + expect( + rankRediscover(centrality: const {}, lastTouchById: const {}, now: now), + isEmpty, + ); + }); + }); +}