a9e277eb13
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>
160 lines
5.4 KiB
Dart
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);
|
|
});
|
|
}
|