03c13d21c6
End-to-end pilot of the per-item architecture. Home now reads from the new homeIndexProvider (drift-first over CachedHomeIndex with /api/home/index discovery + SWR), then each tile is a small ConsumerWidget watching its own albumTileProvider/artistTileProvider/ trackTileProvider. Tiles render a matched-dimension skeleton while their entity is still hydrating, and swap in the real card once drift emits the populated row. Track hydration is wired up — /api/tracks/:id already existed so the queue's case 'track' just calls api.getTrack(id) and persists. The visible behavior: * Cold visit: small /api/home/index round-trip (IDs only, ~10× smaller than /api/home), then sections appear shaped with skeleton tiles; each tile materializes as the hydration queue drains. No more "30s blank → everything pops in at once." * Warm visit: drift index emits instantly, drift entity rows emit instantly, no network. Page paints fully in the first frame. * Mid-state: scrolling through a partially-hydrated section sees real cards next to skeleton cards. Layout doesn't shift because skeletons match real-card dimensions exactly. CachedHomeSnapshot (and the legacy homeProvider) stay in place but unconsumed by Flutter — left in for now so revert is cheap if the new path needs reworking. Cleanup follow-up in a later slice. Old /api/home endpoint untouched, so the web client keeps working unchanged.
110 lines
4.2 KiB
Dart
110 lines
4.2 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import 'package:minstrel/api/endpoints/playlists.dart';
|
|
import 'package:minstrel/library/home_screen.dart';
|
|
import 'package:minstrel/library/library_providers.dart';
|
|
import 'package:minstrel/models/home_index.dart';
|
|
import 'package:minstrel/models/playlist.dart';
|
|
import 'package:minstrel/models/system_playlists_status.dart';
|
|
import 'package:minstrel/playlists/playlists_provider.dart';
|
|
import 'package:minstrel/theme/theme_data.dart';
|
|
|
|
// All tests pump an empty HomeIndex unless they care about populated
|
|
// section IDs — per-tile hydration is intentionally not exercised here
|
|
// (that requires drift, and the @Tags(['drift']) tier covers it).
|
|
// What this suite verifies is the screen's section-level shape:
|
|
// placeholders / empty-state copy / section presence given the index.
|
|
const _emptyIndex = HomeIndex.empty;
|
|
|
|
void main() {
|
|
testWidgets('renders 4 placeholder cards in Playlists row when no system playlists exist',
|
|
(tester) async {
|
|
await tester.pumpWidget(ProviderScope(
|
|
overrides: [
|
|
homeIndexProvider.overrideWith((ref) => Stream.value(_emptyIndex)),
|
|
playlistsListProvider('all').overrideWith(
|
|
(ref) => Stream.value(PlaylistsList.empty()),
|
|
),
|
|
systemPlaylistsStatusProvider.overrideWith(
|
|
(ref) async => SystemPlaylistsStatus.empty(),
|
|
),
|
|
],
|
|
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
|
|
));
|
|
await tester.pumpAndSettle();
|
|
// For-You placeholder + Discover placeholder + 3 Songs-like placeholders.
|
|
expect(find.text('For You'), findsOneWidget);
|
|
expect(find.text('Discover'), findsOneWidget);
|
|
expect(find.text('Songs like…'), findsNWidgets(3));
|
|
});
|
|
|
|
testWidgets('renders empty-state copy for each section when the index is empty',
|
|
(tester) async {
|
|
await tester.pumpWidget(ProviderScope(
|
|
overrides: [
|
|
homeIndexProvider.overrideWith((ref) => Stream.value(_emptyIndex)),
|
|
playlistsListProvider('all').overrideWith(
|
|
(ref) => Stream.value(PlaylistsList.empty()),
|
|
),
|
|
systemPlaylistsStatusProvider.overrideWith(
|
|
(ref) async => SystemPlaylistsStatus.empty(),
|
|
),
|
|
],
|
|
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
|
|
));
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
find.text("Nothing added yet. Scan a folder via the server's config."),
|
|
findsOneWidget,
|
|
);
|
|
expect(
|
|
find.text('No forgotten favourites yet. Like some albums or artists to fill this in.'),
|
|
findsOneWidget,
|
|
);
|
|
expect(find.text('No plays to draw from. Listen to something.'),
|
|
findsOneWidget);
|
|
expect(find.text('No recent plays.'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('renders For-You card when system playlist exists',
|
|
(tester) async {
|
|
const forYou = Playlist(
|
|
id: 'fy',
|
|
userId: 'u1',
|
|
name: 'For You',
|
|
description: '',
|
|
isPublic: false,
|
|
systemVariant: 'for_you',
|
|
trackCount: 75,
|
|
coverUrl: '',
|
|
ownerUsername: 'alice',
|
|
createdAt: '2026-05-01T00:00:00Z',
|
|
updatedAt: '2026-05-01T00:00:00Z',
|
|
);
|
|
await tester.pumpWidget(ProviderScope(
|
|
overrides: [
|
|
homeIndexProvider.overrideWith((ref) => Stream.value(_emptyIndex)),
|
|
playlistsListProvider('all').overrideWith(
|
|
(ref) => Stream.value(
|
|
const PlaylistsList(owned: [forYou], public: [])),
|
|
),
|
|
systemPlaylistsStatusProvider.overrideWith(
|
|
(ref) async => SystemPlaylistsStatus.empty(),
|
|
),
|
|
],
|
|
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
|
|
));
|
|
await tester.pumpAndSettle();
|
|
// The real card carries the playlist name + the "for you" badge.
|
|
expect(find.text('For You'), findsOneWidget);
|
|
expect(find.text('for you'), findsOneWidget);
|
|
});
|
|
|
|
// Section-with-populated-IDs coverage lives in the drift-tagged
|
|
// integration suite (Slice F follow-up) because per-tile providers
|
|
// require a real (in-memory) drift DB. The flutter-ci runner skips
|
|
// drift tests pending libsqlite3.
|
|
}
|