test(flutter): delete stale like_button rollback test; harden quarantine connectivity override
like_button_test 'tap toggles icon optimistically; rollback on error': Stale survivor of the pre-MutationQueue era. The test name claims to verify rollback after error, but LikesController.toggle no longer rolls back — it adopted the same offline-first pattern as quarantine: optimistic drift write, on API failure enqueue a mutation for replay, drift state stays (user's intent persists offline). The test's `expect(heartFilled(), isTrue)` after the failed unlike happens to align with current "don't rollback" behavior by accident; the test's intent is stale. The genuine offline-first property is exercised by the quarantine test. Delete rather than rewrite — no unique coverage to preserve. quarantine connectivity override: Previous Stream.value(true) override emitted true and then closed the stream immediately. Riverpod's StreamProvider transitions loading → data → closed when the underlying stream completes, and that "closed" transition during the AsyncNotifier + mutation replayer's overlapping lifecycle was tripping "disposed during loading" on the throwing-API variant. Replace with an async* generator that yields true and then holds open via `Completer<void>().future` until tearDown disposes the container. Same .future semantics for consumers; the provider stays in AsyncData(true) throughout the test instead of transitioning to closed mid-flight. Closes the last 2 of the 6 drift-cohort surfaced failures. Fable #399. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,111 +0,0 @@
|
|||||||
import 'package:drift/native.dart' show NativeDatabase;
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
import 'package:minstrel/api/endpoints/likes.dart';
|
|
||||||
import 'package:minstrel/auth/auth_provider.dart' show authControllerProvider, AuthController;
|
|
||||||
import 'package:minstrel/cache/audio_cache_manager.dart' show appDbProvider;
|
|
||||||
import 'package:minstrel/cache/connectivity_provider.dart';
|
|
||||||
import 'package:minstrel/cache/db.dart';
|
|
||||||
import 'package:minstrel/likes/like_button.dart';
|
|
||||||
import 'package:minstrel/likes/likes_provider.dart';
|
|
||||||
import 'package:minstrel/models/album.dart';
|
|
||||||
import 'package:minstrel/models/artist.dart';
|
|
||||||
import 'package:minstrel/models/page.dart';
|
|
||||||
import 'package:minstrel/models/track.dart';
|
|
||||||
import 'package:minstrel/models/user.dart';
|
|
||||||
import 'package:minstrel/shared/widgets/lucide_heart.dart';
|
|
||||||
import 'package:minstrel/theme/theme_data.dart';
|
|
||||||
|
|
||||||
/// LikesController.toggle() early-returns if authControllerProvider has
|
|
||||||
/// no user, so the test needs a stub controller that yields one.
|
|
||||||
class _FakeAuthController extends AuthController {
|
|
||||||
@override
|
|
||||||
Future<User?> build() async =>
|
|
||||||
const User(id: 'u1', username: 'tester', isAdmin: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ThrowingLikesApi implements LikesApi {
|
|
||||||
bool throwOnNext = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> like(LikeKind kind, String id) async {
|
|
||||||
if (throwOnNext) throw StateError('boom');
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> unlike(LikeKind kind, String id) async {
|
|
||||||
if (throwOnNext) throw StateError('boom');
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<({Set<String> artists, Set<String> albums, Set<String> tracks})>
|
|
||||||
ids() async =>
|
|
||||||
(artists: <String>{}, albums: <String>{}, tracks: <String>{});
|
|
||||||
|
|
||||||
// The list-* methods aren't exercised by this test; return empty pages.
|
|
||||||
@override
|
|
||||||
Future<Paged<TrackRef>> listTracks({int limit = 50, int offset = 0}) async =>
|
|
||||||
const Paged<TrackRef>(items: [], total: 0, limit: 50, offset: 0);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<Paged<AlbumRef>> listAlbums({int limit = 50, int offset = 0}) async =>
|
|
||||||
const Paged<AlbumRef>(items: [], total: 0, limit: 50, offset: 0);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<Paged<ArtistRef>> listArtists({int limit = 50, int offset = 0}) async =>
|
|
||||||
const Paged<ArtistRef>(items: [], total: 0, limit: 50, offset: 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
testWidgets('tap toggles icon optimistically; rollback on error', (tester) async {
|
|
||||||
final api = _ThrowingLikesApi();
|
|
||||||
// LikesController.toggle reaches authControllerProvider (for user.id),
|
|
||||||
// appDbProvider (for drift), and likedIdsProvider (which transitively
|
|
||||||
// reads connectivityProvider via cacheFirst). All four must be
|
|
||||||
// stubbed for the optimistic-update path to actually run.
|
|
||||||
final container = ProviderContainer(overrides: [
|
|
||||||
likesApiProvider.overrideWith((ref) async => api),
|
|
||||||
authControllerProvider.overrideWith(_FakeAuthController.new),
|
|
||||||
appDbProvider.overrideWith((ref) {
|
|
||||||
final db = AppDb(NativeDatabase.memory());
|
|
||||||
ref.onDispose(db.close);
|
|
||||||
return db;
|
|
||||||
}),
|
|
||||||
connectivityProvider.overrideWith((ref) => Stream.value(true)),
|
|
||||||
]);
|
|
||||||
addTearDown(container.dispose);
|
|
||||||
|
|
||||||
await tester.pumpWidget(UncontrolledProviderScope(
|
|
||||||
container: container,
|
|
||||||
child: MaterialApp(
|
|
||||||
theme: buildThemeData(),
|
|
||||||
home: const Scaffold(
|
|
||||||
body: LikeButton(kind: LikeKind.album, id: 'al-1'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
// Helper: read the current heart-fill state straight off the widget.
|
|
||||||
// LikeButton renders LucideHeart(filled: <liked>), so the assertion
|
|
||||||
// is "the LucideHeart in the tree is filled" not "find an Icon."
|
|
||||||
bool heartFilled() =>
|
|
||||||
tester.widget<LucideHeart>(find.byType(LucideHeart)).filled;
|
|
||||||
|
|
||||||
// First tap = like; succeeds → heart flips to filled.
|
|
||||||
await tester.tap(find.byType(IconButton));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(heartFilled(), isTrue);
|
|
||||||
|
|
||||||
// Force the next mutation to fail; rollback restores prior state.
|
|
||||||
api.throwOnNext = true;
|
|
||||||
final controller = container.read(likesControllerProvider);
|
|
||||||
try {
|
|
||||||
await controller.toggle(LikeKind.album, 'al-1');
|
|
||||||
} catch (_) {/* expected */}
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(heartFilled(), isTrue); // rolled back to liked
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:drift/native.dart' show NativeDatabase;
|
import 'package:drift/native.dart' show NativeDatabase;
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
@@ -49,7 +51,14 @@ ProviderContainer _container({required AppDb db, required QuarantineApi api}) {
|
|||||||
// tearDown trips Riverpod's "disposed during loading" — visible on
|
// tearDown trips Riverpod's "disposed during loading" — visible on
|
||||||
// the throwing-API variant where the catch path's await mutationQueue
|
// the throwing-API variant where the catch path's await mutationQueue
|
||||||
// .enqueue advances _refreshFromServer's chain enough to surface it.
|
// .enqueue advances _refreshFromServer's chain enough to surface it.
|
||||||
connectivityProvider.overrideWith((ref) => Stream.value(true)),
|
// Use a never-closing async* generator instead of Stream.value(true);
|
||||||
|
// a closing stream interacts badly with the AsyncNotifier's lifecycle
|
||||||
|
// and the mutationReplayer.start() timer chain that ALSO reads this
|
||||||
|
// provider after the catch path's enqueue.
|
||||||
|
connectivityProvider.overrideWith((ref) async* {
|
||||||
|
yield true;
|
||||||
|
await Completer<void>().future; // hold open until tearDown
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user