12 Commits

Author SHA1 Message Date
bvandeusen a9e277eb13 test(flutter): fix 6 latent failures uncovered by the drift cohort un-skip
All six tests had been silently skipped under @Tags(['drift']) for
6+ months, so they accumulated test-vs-implementation drift. Now
running against the libsqlite3-bearing ci-flutter:3.44 image, each
failed for a distinct reason. Diagnoses below.

audio_cache_manager_test 'usageBytes sums sizeBytes across rows':
  usageBytes() is a directory walk (authoritative on-disk total,
  catches orphan partials the index misses). The test inserted drift
  rows but never wrote files, so the walk returned 0. The actual API
  for summing drift sizeBytes is bucketUsage(). Rename to
  'bucketUsage sums drift sizeBytes across rows', use that API,
  assert liked+rolling == 350. Also give the two rows unique paths
  per row-shape sanity.

sync_controller_test (3 200-path tests, all returning null result):
  Map literals in Dart 3 with mixed value types infer as
  Map<String, Object>, not Map<String, dynamic>. The sync controller
  casts `resp.data as Map<String, dynamic>` (and several nested
  casts), which is invariant on generics and throws TypeError. The
  silent try/catch in sync() swallowed the throw and returned null.
  Real JSON parsing produces Map<String, dynamic>, so this never
  surfaced in production. Fix: route the test stub body through
  jsonDecode(jsonEncode(body)) in _stubDio — mimics real Dio's
  parsed-response shape. Affects '200 with artist upsert', '200 with
  track delete', and 'like_track upsert + delete round-trip'.

quarantine_provider_test 'flag keeps drift optimistic + queues
mutation on server failure':
  When the API stub throws, the controller catches + queues to
  CachedMutations. The drift watch() stream in MyQuarantineController
  was still in loading state when addTearDown disposed the
  container, tripping Riverpod's "StreamProvider disposed during
  loading" assertion. The success-path tests resolved before
  tearDown so they didn't see it. Fix: await one microtask before
  the test ends so the stream emits.

like_button_test 'tap toggles icon optimistically; rollback on error':
  After the LikeButton was migrated to LucideHeart in the Lucide
  sweep, the prior fix replaced the find.byIcon assertion with a
  heartFilled() helper reading LucideHeart.filled. But likesController
  .toggle() goes through an async chain (optimistic state flip + await
  api.like + state notification), which one frame of tester.pump()
  doesn't flush. Use pumpAndSettle after both tap and rollback toggle
  so the widget rebuilds with the new state before the assertion.

After this push, the drift cohort should be all-green on the
libsqlite3-bearing image. Fable #399 / local #62.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:52:05 -04:00
bvandeusen 85183c455a test(flutter): re-enable drift tests after ci-flutter:1.26 ships libsqlite3-0
Drops the libsqlite3-missing skip cohort now that the ci-flutter
runner image installs libsqlite3-0 (CI-runner commit on its main).

Per-file removals (no behavior change in tests themselves — they
just stop being skipped):
- `@Tags(['drift'])` + `library;` directive from 5 files.
- `const _skipDrift = ...;` declaration + its rationale comment
  from 6 files (the 5 above + like_button_test.dart, which had its
  own _skipDrift for the rollback-via-drift case).
- `skip: _skipDrift` annotations from 17 test invocations across
  those 6 files (16 single-line + 1 multi-line in like_button).
- Stale `@Tags(['drift']) tier covers it` reference in
  home_screen_test.dart's drift-coverage comment.

Net -79 +18 lines across 7 files; 17 previously-silent tests are
now part of the CI signal. Fable #399.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:02:17 -04:00
bvandeusen 3f61079c02 feat(offline): #427 S2(+S3) — two-bucket cache model
Replaces the single 5GB capBytes with independent Liked + Rolling
budgets (5GB each default). Bucket = liked-ness, NOT CacheSource:
a cached track currently in the liked set is charged to / evicted
under Liked; everything else is Rolling. Storage-only dedup — it
never filters playback (S4's offline lists query the whole index).

- db.dart: schema 8→9, AudioCacheIndex.lastPlayedAt (real play
  recency for S4 + rolling LRU; migration backfills to cachedAt).
  drift codegen run.
- cache_settings: likedCapBytes + rollingCapBytes (+ setters); old
  cache_cap_bytes key dropped, defaults reapply (not data loss).
- audio_cache_manager: touch(); bucketUsage() counts orphan
  partials (LockCaching files never indexed) as Rolling so the cap
  truly bounds disk; evictBuckets() drains non-liked LRU then
  sweeps orphans, Liked only by its own (large) cap — normal use
  never evicts the user's liked library.
- prefetcher → evictBuckets with the cached liked set.
- storage_section: two cap selectors + per-bucket usage (folds in
  S3 to avoid a broken intermediate).
- Explicit Download dropped: removed album + playlist Download
  buttons, autoPlaylist pins, now-unused imports.
- Tests updated/compiled (drift-cohort tests are CI-skipped).

High blast radius (eviction deletes files) — liked-protective by
design; needs operator device-check before "done".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:07:45 -04:00
bvandeusen 67bacac84b fix(test): capture cacheFirst stream Future before feeding controller 2026-05-14 15:59:16 -04:00
bvandeusen e38189470b test(cache_first): update test for new yield-after-fetch semantics
The cacheFirst fix in 5511f87 added a yield after fetchAndPopulate
so streams never hang when populate is a no-op for this filter
(the liked-tab spinner-forever bug). Test expectation updated: the
first emission after an empty drift is now the still-empty yield
("we tried, nothing to show yet"), and the simulated drift re-emit
yields the populated rows as the second emission.
2026-05-14 15:41:00 -04:00
bvandeusen 4eb9935f8e feat(flutter/cache): cacheFirst helper + drift↔model adapters (#357 plan C)
Foundation for the provider migrations. cacheFirst<D, T> wraps the
drift.watch() + REST cold-cache fallback pattern: yields cached rows
when present, kicks off REST fetch + drift populate when empty +
online, yields empty when offline. REST failures swallow to empty so
callers can surface errors via toast.

adapters.dart adds CachedX → XRef extension methods + reverse
XRef.toDrift() companions for Artist/Album/Track/Playlist. Adapters
accept some loss of server-derived fields (coverUrl, streamUrl,
ownerUsername) — UI already handles empty values; cold-cache fallback
briefly shows the real values before drift takes over.

cache_first_test covers all 4 branches (non-empty, empty+online,
empty+offline, REST failure). adapters_test covers basic round-trips.
Both safe to run on CI runner — no drift open required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:18:29 -04:00
bvandeusen 1b223d3891 fix(flutter/test): skip drift tests pending libsqlite3 in CI runner image
CI's flutter-ci runner doesn't ship libsqlite3.so. drift's NativeDatabase
fails at first use ("Failed to load dynamic library 'libsqlite3.so'").
Affected files:
  - test/cache/sync_controller_test.dart (4 tests)
  - test/cache/audio_cache_manager_test.dart (5 tests)
  - test/settings/storage_section_test.dart (2 tests, was silently
    succeeding because the drift call was best-effort but emitted
    multiple-AppDb warnings)

All 11 marked with skip: '...' + a top-level @Tags(['drift']) library
declaration so they can be re-enabled by tag once the runner image has
libsqlite3-dev installed (or once we move VM tests to sqlite3/wasm).
On-device verification covers the actual cache + sync logic.

Plus two collateral fixes:
  - test/cache/connectivity_provider_test.dart: connectivity_plus needs
    a platform channel that doesn't exist in unit tests; reduced to a
    non-null import smoke check.
  - test/library/widgets_smoke_test.dart: TrackRow now contains
    CachedIndicator (ConsumerWidget); wrapped TrackRow test in
    ProviderScope.

Filing follow-up for the runner image fix in next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:27:49 -04:00
bvandeusen 0d171490c3 fix(flutter): analyze errors after Plan B push
Cleared 5 errors + 1 warning + 1 info from CI flutter analyze on 5114a81:

- prefetcher.dart: Riverpod listener callbacks used (_, _) for two
  placeholder params — Dart 3 enforces unique names. Changed to (_, __).
- prefetcher.dart: Riverpod 3's AsyncValue exposes `.value` (nullable)
  not `.valueOrNull`. Three call sites updated.
- sync_controller.dart: doc comment had `?since=<cursor>` → analyzer
  warned about unintended HTML. Wrapped in backticks with `{cursor}`.
- sync_controller.dart: delete loop over heterogeneous Table list
  inferred as List<Table>; drift's delete() expects TableInfo. Unrolled
  to explicit per-table deletes.
- audio_cache_manager_test.dart: db.into(...).insertAll([...]) doesn't
  exist on InsertStatement — only insert/insertOnConflictUpdate. Used
  db.batch((b) => b.insertAll(table, [...])) instead, in two test cases.
- audio_handler.dart: LockCachingAudioSource is marked experimental in
  just_audio. Added // ignore: experimental_member_use — operator
  acknowledged the experimental status during brainstorming and we're
  the project; CI's --fatal-infos would otherwise gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:18:38 -04:00
bvandeusen 536350380f feat(flutter/cache): Prefetcher — queue-ahead pre-download window
Listens to mediaItemProvider + cacheSettingsProvider. On track change
(or settings change), computes the [currentIdx, currentIdx + N] window
in the queue and pins each uncached track via AudioCacheManager with
source: autoPrefetch.

Window N comes from cacheSettingsProvider.prefetchWindow (default 5,
configurable in Settings → Storage card).

Smoke-test only at the unit level — real coverage via on-device pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:19:01 -04:00
bvandeusen ab58f3ffcb feat(flutter/cache): SyncController — token-based delta sync
Drives /api/library/sync against drift:
  - reads cursor from SyncMetadata (default 0 = full snapshot)
  - 204 → just bump lastSyncAt, return zeroes
  - 410 → wipe all cached entities + retry from cursor=0
  - 200 → apply upserts + deletes per entity type, advance cursor

Handles all 8 entity types (artist/album/track + like_track/like_album/
like_artist + playlist/playlist_track) for both upsert and delete paths.
Composite-key entities use the "<a>:<b>" string format the server emits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:18:15 -04:00
bvandeusen 16669a0365 feat(flutter/cache): AudioCacheManager — pin/unpin/evict + tiered LRU
API:
  - isCached(trackId), pathFor(trackId)
  - pin(trackId, source) — dio.download → drift index entry
  - unpin(trackId), clearAll()
  - usageBytes(), evict(targetBytes)

Eviction order: incidental → autoPrefetch → autoPlaylist → autoLiked.
'manual' never evicts via cap-driven eviction; only clearAll() removes
operator-pinned tracks.

Two providers:
  - appDbProvider — singleton AppDb per run
  - audioCacheManagerProvider — wraps the AppDb + dioProvider

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:17:14 -04:00
bvandeusen 2bcbb53c49 feat(flutter/cache): connectivityProvider + cacheSettingsProvider
connectivityProvider — StreamProvider<bool>; true when any connectivity
result is non-none. No Wi-Fi gate per operator decision.

cacheSettingsProvider — AsyncNotifier<CacheSettings> persisting
capBytes / prefetchWindow / cacheLikedTracks via flutter_secure_storage.
Defaults: 5 GB cap, 5-track prefetch, cache liked = on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:15:17 -04:00