Files
minstrel/flutter_client/test/cache/audio_cache_manager_test.dart
T
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

160 lines
5.4 KiB
Dart

import 'dart:io';
import 'package:dio/dio.dart';
import 'package:drift/drift.dart' show Value;
import 'package:drift/native.dart' show NativeDatabase;
import 'package:flutter_test/flutter_test.dart';
import 'package:minstrel/cache/audio_cache_manager.dart';
import 'package:minstrel/cache/db.dart';
AppDb _testDb() => AppDb(NativeDatabase.memory());
void main() {
test('isCached returns false when no row exists', () async {
final db = _testDb();
addTearDown(db.close);
final mgr = AudioCacheManager(
db: db,
dioFactory: () async => Dio(),
cacheDirFactory: () async => Directory.systemTemp.createTempSync(),
);
expect(await mgr.isCached('nonexistent'), false);
expect(await mgr.pathFor('nonexistent'), null);
});
test('bucketUsage sums drift sizeBytes across rows', () async {
final db = _testDb();
addTearDown(db.close);
final tmp = Directory.systemTemp.createTempSync();
final mgr = AudioCacheManager(
db: db,
dioFactory: () async => Dio(),
cacheDirFactory: () async => tmp,
);
// Each row needs a distinct `path` because path is not the primary
// key, but mapping by trackId only works if rows are distinct.
await db.batch((b) {
b.insertAll(db.audioCacheIndex, [
AudioCacheIndexCompanion.insert(
trackId: 'a',
path: 'p_a',
sizeBytes: 100,
source: CacheSource.manual),
AudioCacheIndexCompanion.insert(
trackId: 'b',
path: 'p_b',
sizeBytes: 250,
source: CacheSource.incidental),
]);
});
// usageBytes() is a directory walk (authoritative on-disk total,
// catches orphan partials); for a drift-sizeBytes sum, bucketUsage
// is the correct API. With empty liked set, both rows go to rolling.
final usage = await mgr.bucketUsage(const <String>{});
expect(usage.liked + usage.rolling, 350);
});
test('rolling cap evicts non-liked LRU; liked protected', () async {
final db = _testDb();
addTearDown(db.close);
final tmp = Directory.systemTemp.createTempSync();
final mgr = AudioCacheManager(
db: db,
dioFactory: () async => Dio(),
cacheDirFactory: () async => tmp,
);
Future<void> mk(String id) async {
final f = File('${tmp.path}/audio_cache/$id.mp3');
await f.create(recursive: true);
await f.writeAsBytes(List.filled(100, 0));
}
await mk('old');
await mk('new');
await mk('lik');
await db.batch((b) {
b.insertAll(db.audioCacheIndex, [
// 'old' has the older lastPlayedAt → evicted first.
AudioCacheIndexCompanion.insert(
trackId: 'old',
path: '${tmp.path}/audio_cache/old.mp3',
sizeBytes: 100,
source: CacheSource.incidental,
lastPlayedAt: Value(DateTime(2020))),
AudioCacheIndexCompanion.insert(
trackId: 'new',
path: '${tmp.path}/audio_cache/new.mp3',
sizeBytes: 100,
source: CacheSource.incidental,
lastPlayedAt: Value(DateTime(2024))),
AudioCacheIndexCompanion.insert(
trackId: 'lik',
path: '${tmp.path}/audio_cache/lik.mp3',
sizeBytes: 100,
source: CacheSource.incidental,
lastPlayedAt: Value(DateTime(2019))),
]);
});
final liked = {'lik'};
final u = await mgr.bucketUsage(liked);
expect(u.liked, 100);
expect(u.rolling, 200);
// Rolling cap fits one 100-byte file; Liked cap huge.
await mgr.evictBuckets(
likedCap: 1 << 30, rollingCap: 100, liked: liked);
expect(await mgr.isCached('old'), false); // oldest rolling, evicted
expect(await mgr.isCached('new'), true); // newer rolling, kept
expect(await mgr.isCached('lik'), true); // liked, protected
});
test('clearAll removes everything including manual', () async {
final db = _testDb();
addTearDown(db.close);
final tmp = Directory.systemTemp.createTempSync();
final mgr = AudioCacheManager(
db: db,
dioFactory: () async => Dio(),
cacheDirFactory: () async => tmp,
);
final f = File('${tmp.path}/audio_cache/man.mp3');
await f.create(recursive: true);
await f.writeAsBytes(List.filled(50, 0));
await db.into(db.audioCacheIndex).insertOnConflictUpdate(
AudioCacheIndexCompanion.insert(
trackId: 'man',
path: f.path,
sizeBytes: 50,
source: CacheSource.manual),
);
expect(await mgr.usageBytes(), 50);
await mgr.clearAll();
expect(await mgr.usageBytes(), 0);
expect(await mgr.isCached('man'), false);
});
test('unpin removes index row + deletes file', () async {
final db = _testDb();
addTearDown(db.close);
final tmp = Directory.systemTemp.createTempSync();
final mgr = AudioCacheManager(
db: db,
dioFactory: () async => Dio(),
cacheDirFactory: () async => tmp,
);
final f = File('${tmp.path}/audio_cache/x.mp3');
await f.create(recursive: true);
await f.writeAsBytes(List.filled(10, 0));
await db.into(db.audioCacheIndex).insertOnConflictUpdate(
AudioCacheIndexCompanion.insert(
trackId: 'x',
path: f.path,
sizeBytes: 10,
source: CacheSource.autoPrefetch),
);
expect(await mgr.isCached('x'), true);
await mgr.unpin('x');
expect(await mgr.isCached('x'), false);
expect(f.existsSync(), false);
});
}