import 'package:flutter/widgets.dart' show Color; import 'package:flutter_test/flutter_test.dart'; import 'package:timbre/library/browse_query.dart'; import 'package:timbre/playlists/playlists.dart'; import 'package:timbre/settings/settings_store.dart'; import 'package:timbre/state/providers.dart'; import 'package:timbre/subsonic/credentials.dart'; import 'package:timbre/subsonic/models.dart'; import 'package:timbre/subsonic/subsonic_client.dart'; /// Fake client so playlist mutations can be tested without a server. Methods /// are overridden; the (unused) real Dio is created but never hit. class _FakeClient extends SubsonicClient { _FakeClient() : super(baseUrl: 'http://x', username: 'u', password: 'p'); final List playlists = []; final Map details = {}; bool failNext = false; // Fails only the `setPlaylistComment` call — lets a test drive the tag // orphan-cleanup path (create succeeds, marker stamp fails). bool failComment = false; int _seq = 0; void _maybeThrow() { if (failNext) { failNext = false; throw Exception('boom'); } } @override Future> getPlaylists() async => List.of(playlists); @override Future getPlaylist(String id) async => details[id] ?? PlaylistDetail(id: id, name: 'x'); @override Future createPlaylist(String name) async { _maybeThrow(); final d = PlaylistDetail(id: 'pl-${_seq++}', name: name); playlists.add(d.toSummary()); details[d.id] = d; return d; } @override Future setPlaylistComment(String playlistId, String comment) async { if (failComment) { failComment = false; throw Exception('comment failed'); } final i = playlists.indexWhere((p) => p.id == playlistId); if (i >= 0) playlists[i] = _copy(playlists[i], comment: comment); final d = details[playlistId]; if (d != null) details[playlistId] = _copyDetail(d, comment: comment); } // The server persists appends, so getPlaylist reflects real membership — the // fake must too, or `addToTag`'s de-dupe check has nothing to read. @override Future addTracksToPlaylist(String playlistId, List songIds) async { _maybeThrow(); final d = details[playlistId]; if (d != null) { details[playlistId] = _copyDetail(d, songs: [ ...d.songs, for (final sid in songIds) Song(id: sid, title: 'Song $sid'), ]); } } static Playlist _copy(Playlist p, {String? comment, bool? public}) => Playlist( id: p.id, name: p.name, songCount: p.songCount, duration: p.duration, owner: p.owner, public: public ?? p.public, coverArt: p.coverArt, comment: comment ?? p.comment, ); static PlaylistDetail _copyDetail(PlaylistDetail d, {String? comment, List? songs}) => PlaylistDetail( id: d.id, name: d.name, songCount: d.songCount, duration: d.duration, coverArt: d.coverArt, comment: comment ?? d.comment, songs: songs ?? d.songs, ); @override Future setPlaylistPublic(String playlistId, bool isPublic) async { _maybeThrow(); final i = playlists.indexWhere((p) => p.id == playlistId); if (i >= 0) playlists[i] = _copy(playlists[i], public: isPublic); } @override Future removeTrackFromPlaylist(String playlistId, int index) async { _maybeThrow(); } @override Future renamePlaylist(String playlistId, String name) async { _maybeThrow(); } @override Future deletePlaylist(String id) async { _maybeThrow(); } } Song _song(String id) => Song(id: id, title: 'Song $id'); void main() { group('model round-trips', () { test('Playlist toJson/fromJson', () { final p = Playlist(id: 'p1', name: 'Mix', songCount: 3, duration: 600); final back = Playlist.fromJson(p.toJson()); expect(back.id, 'p1'); expect(back.name, 'Mix'); expect(back.songCount, 3); expect(back.duration, 600); }); test('PlaylistDetail parses tracks from the `entry` key', () { final detail = PlaylistDetail.fromJson({ 'id': 'p1', 'name': 'Mix', 'entry': [ {'id': 's1', 'title': 'One'}, {'id': 's2', 'title': 'Two'}, ], }); expect(detail.songs.length, 2); expect(detail.songs.first.id, 's1'); // toJson emits back under `entry` so the offline mirror round-trips. expect(PlaylistDetail.fromJson(detail.toJson()).songs.length, 2); }); }); group('AppSettings', () { test('copyWith preserves format when the arg is omitted', () { const s = AppSettings(downloadFormat: 'mp3'); expect(s.copyWith(streamMaxBitRate: 320).downloadFormat, 'mp3'); }); test('copyWith(downloadFormat: null) clears to Original', () { const s = AppSettings(downloadFormat: 'mp3'); expect(s.copyWith(downloadFormat: null).downloadFormat, isNull); }); test('toJson/fromJson round-trip', () { const s = AppSettings( streamMaxBitRate: 192, downloadMaxBitRate: 0, downloadFormat: 'opus'); final back = AppSettings.fromJson(s.toJson()); expect(back.streamMaxBitRate, 192); expect(back.downloadMaxBitRate, 0); expect(back.downloadFormat, 'opus'); }); test('toJson/fromJson round-trip preserves the new preference fields', () { const s = AppSettings( useStaticAccent: true, staticAccentColor: Color(0xFF926CE9), // Purple defaultBrowseMode: BrowseMode.albums, searchMode: SearchMode.standard, maxConcurrentDownloads: 7, albumSort: AlbumSort.recentlyAdded, trackSort: TrackSort.yearDesc, nowPlayingCassette: true, ); final back = AppSettings.fromJson(s.toJson()); expect(back.useStaticAccent, isTrue); expect(back.staticAccentColor, const Color(0xFF926CE9)); expect(back.defaultBrowseMode, BrowseMode.albums); expect(back.searchMode, SearchMode.standard); expect(back.maxConcurrentDownloads, 7); expect(back.albumSort, AlbumSort.recentlyAdded); expect(back.trackSort, TrackSort.yearDesc); expect(back.nowPlayingCassette, isTrue); }); test('fromJson falls back to defaults for missing/unknown values', () { // An older settings.json without the new keys loads with safe defaults. final back = AppSettings.fromJson({'streamMaxBitRate': 128}); expect(back.streamMaxBitRate, 128); expect(back.useStaticAccent, isFalse); expect(back.staticAccentColor, const Color(0xFFDF7E35)); // Orange expect(back.defaultBrowseMode, BrowseMode.artists); expect(back.searchMode, SearchMode.discovery); expect(back.maxConcurrentDownloads, AppSettings.defaultConcurrentDownloads); expect(back.albumSort, AlbumSort.nameAsc); expect(back.trackSort, TrackSort.titleAsc); expect(back.nowPlayingCassette, isFalse); // An unrecognised enum name also falls back rather than throwing. expect( AppSettings.fromJson({'defaultBrowseMode': 'bogus'}).defaultBrowseMode, BrowseMode.artists, ); }); test('fromJson clamps an out-of-range concurrent-download count', () { expect(AppSettings.fromJson({'maxConcurrentDownloads': 99}) .maxConcurrentDownloads, AppSettings.maxConcurrentDownloadsCap); expect(AppSettings.fromJson({'maxConcurrentDownloads': 0}) .maxConcurrentDownloads, AppSettings.minConcurrentDownloads); }); }); group('SubsonicCredentials', () { test('toJson/fromJson round-trip', () { const c = SubsonicCredentials( url: 'https://x', username: 'u', password: 'p', alias: 'Home'); final back = SubsonicCredentials.fromJson(c.toJson()); expect(back.url, 'https://x'); expect(back.username, 'u'); expect(back.password, 'p'); expect(back.alias, 'Home'); }); test('id ignores a trailing slash so the same server maps to one key', () { const a = SubsonicCredentials(url: 'https://x/', username: 'u', password: 'p'); const b = SubsonicCredentials(url: 'https://x', username: 'u', password: 'q'); expect(a.id, b.id); }); test('display prefers a non-empty alias, else the host', () { expect( const SubsonicCredentials(url: 'https://x', username: 'u', password: 'p') .display, 'x', ); expect( const SubsonicCredentials( url: 'https://x', username: 'u', password: 'p', alias: 'Home') .display, 'Home', ); }); }); group('filterSearchToStandard (TODO #4)', () { test('keeps name/title matches and drops artist-only song matches', () { final result = SearchResult3( artists: [Artist(id: 'a1', name: 'Chard')], albums: [ Album(id: 'al1', name: 'Cha Cha'), Album(id: 'al2', name: 'Nope'), ], songs: [ Song(id: 's1', title: 'Cha times', artist: 'Zed'), Song(id: 's2', title: 'Other', artist: 'Cha Band'), // artist-only ], ); final filtered = filterSearchToStandard(result, 'Cha'); expect(filtered.artists.map((a) => a.id), ['a1']); expect(filtered.albums.map((a) => a.id), ['al1']); expect(filtered.songs.map((s) => s.id), ['s1']); // s2 dropped }); test('is case-insensitive', () { final result = SearchResult3( artists: const [], albums: const [], songs: [Song(id: 's1', title: 'CHA')], ); expect(filterSearchToStandard(result, 'cha').songs.length, 1); }); }); group('PlaylistsController optimistic mutations', () { late _FakeClient client; late PlaylistsController controller; setUp(() { client = _FakeClient(); // Null server key => no filesystem access (persist/reload are no-ops). controller = PlaylistsController( clientGetter: () => client, serverKeyGetter: () => null, ); }); test('create adds a playlist and returns its id', () async { final id = await controller.create('Roadtrip'); expect(id, isNotNull); expect(controller.state.playlists.map((p) => p.name), contains('Roadtrip')); expect(controller.state.details[id!]!.name, 'Roadtrip'); }); test('addTracks appends optimistically and bumps the count', () async { final id = (await controller.create('Mix'))!; await controller.addTracks(id, [_song('a'), _song('b')]); expect(controller.state.details[id]!.songs.length, 2); final summary = controller.state.playlists.firstWhere((p) => p.id == id); expect(summary.songCount, 2); }); test('removeAt drops the track optimistically', () async { final id = (await controller.create('Mix'))!; await controller.addTracks(id, [_song('a'), _song('b')]); await controller.removeAt(id, 0); final songs = controller.state.details[id]!.songs; expect(songs.length, 1); expect(songs.single.id, 'b'); }); test('rename updates the summary and cached detail', () async { final id = (await controller.create('Old'))!; await controller.rename(id, 'New'); expect(controller.state.playlists.single.name, 'New'); expect(controller.state.details[id]!.name, 'New'); }); test('delete removes the playlist', () async { final id = (await controller.create('Temp'))!; await controller.delete(id); expect(controller.state.playlists, isEmpty); expect(controller.state.details.containsKey(id), isFalse); }); test('rename reverts when the server call fails', () async { final id = (await controller.create('Keep'))!; client.failNext = true; await controller.rename(id, 'Nope'); expect(controller.state.playlists.single.name, 'Keep'); }); }); group('Tags (playlists marked with the tag comment)', () { late _FakeClient client; late PlaylistsController controller; setUp(() { client = _FakeClient(); controller = PlaylistsController( clientGetter: () => client, serverKeyGetter: () => null, ); }); test('createTag stamps the marker so the playlist reads as a tag', () async { final id = (await controller.createTag('cooking'))!; final summary = controller.state.playlists.single; expect(summary.id, id); expect(summary.comment, kTagMarker); expect(isTagPlaylist(summary), isTrue); }); test('createTag is unique per name (case-insensitive)', () async { final first = await controller.createTag('Cooking'); final second = await controller.createTag('cooking'); expect(second, first); // reused, not duplicated expect(controller.state.playlists.length, 1); }); test('createTag deletes the orphan if the marker stamp fails', () async { client.failComment = true; final id = await controller.createTag('running'); expect(id, isNull); // No unmarked, nameless playlist left behind. expect(controller.state.playlists, isEmpty); }); test('addToTag skips songs already carrying the tag', () async { final id = (await controller.createTag('run'))!; await controller.addToTag(id, [_song('a'), _song('b')]); await controller.addToTag(id, [_song('b'), _song('c')]); // b duplicate final ids = controller.state.details[id]!.songs.map((s) => s.id).toList(); expect(ids, ['a', 'b', 'c']); // Re-adding an existing member is a no-op. await controller.addToTag(id, [_song('a')]); expect(controller.state.details[id]!.songs.length, 3); }); }); group('Playlist sharing (TODO #5)', () { late _FakeClient client; late PlaylistsController controller; setUp(() { client = _FakeClient(); controller = PlaylistsController( clientGetter: () => client, serverKeyGetter: () => null, ); }); test('isOwnedBy treats unknown owner/viewer as mine', () { expect(isOwnedBy(Playlist(id: 'a', name: 'A'), 'me'), isTrue); expect( isOwnedBy(Playlist(id: 'a', name: 'A', owner: 'bob'), null), isTrue); expect( isOwnedBy(Playlist(id: 'a', name: 'A', owner: 'me'), 'me'), isTrue); expect( isOwnedBy(Playlist(id: 'a', name: 'A', owner: 'bob'), 'me'), isFalse); }); test('setPublic flips the summary flag optimistically', () async { final id = (await controller.create('Mix'))!; await controller.setPublic(id, true); expect(controller.state.playlists.firstWhere((p) => p.id == id).public, isTrue); await controller.setPublic(id, false); expect(controller.state.playlists.firstWhere((p) => p.id == id).public, isFalse); }); test('setPublic reverts when the server call fails', () async { final id = (await controller.create('Mix'))!; client.failNext = true; await controller.setPublic(id, true); expect(controller.state.playlists.firstWhere((p) => p.id == id).public, isNot(true)); }); test('saveCopy clones tracks into a new owned playlist', () async { final srcId = (await controller.create('Shared Mix'))!; await controller.addTracks(srcId, [_song('a'), _song('b'), _song('c')]); final source = controller.state.playlists.firstWhere((p) => p.id == srcId); final copyId = await controller.saveCopy(source); expect(copyId, isNotNull); expect(copyId, isNot(srcId)); // Two playlists named "Shared Mix"; the copy carries all three tracks. expect(controller.state.playlists.where((p) => p.name == 'Shared Mix'), hasLength(2)); expect(controller.state.details[copyId]!.songs.map((s) => s.id), ['a', 'b', 'c']); }); }); group('browse filtering & sorting (TODO #2)', () { Album album(String id, {String? name, String? genre, int? year, String? created}) => Album(id: id, name: name ?? id, genre: genre, year: year, created: created); Song song(String id, {String? title, String? artist, String? album, String? genre, int? year, String? created}) => Song( id: id, title: title ?? id, artist: artist, album: album, genre: genre, year: year, created: created); test('distinctGenres dedupes case-insensitively and sorts', () { final g = distinctGenres(['Rock', 'rock', 'Jazz', null, '', ' Pop ']); expect(g, ['Jazz', 'Pop', 'Rock']); // first-seen casing, trimmed }); test('distinctYears drops null/zero and sorts newest-first', () { expect(distinctYears([1999, null, 2020, 0, 1999]), [2020, 1999]); }); test('album genre filter is case-insensitive', () { final albums = [ album('a', genre: 'Rock'), album('b', genre: 'jazz'), album('c', genre: 'ROCK'), ]; final out = applyAlbumQuery( albums, const BrowseFilter(genre: 'rock'), AlbumSort.nameAsc); expect(out.map((a) => a.id), ['a', 'c']); }); test('album year filter matches exactly', () { final albums = [album('a', year: 2001), album('b', year: 2002)]; final out = applyAlbumQuery( albums, const BrowseFilter(year: 2002), AlbumSort.nameAsc); expect(out.map((a) => a.id), ['b']); }); test('album recentlyAdded sorts newest first, nulls last', () { final albums = [ album('old', created: '2001-01-01T00:00:00'), album('new', created: '2020-01-01T00:00:00'), album('none'), ]; final out = applyAlbumQuery(albums, const BrowseFilter(), AlbumSort.recentlyAdded); expect(out.map((a) => a.id), ['new', 'old', 'none']); }); test('album yearDesc keeps null years last', () { final albums = [ album('a', year: 1990), album('b'), album('c', year: 2010), ]; final out = applyAlbumQuery(albums, const BrowseFilter(), AlbumSort.yearDesc); expect(out.map((a) => a.id), ['c', 'a', 'b']); }); test('track album sort falls back to track number then title', () { final songs = [ song('s2', album: 'X', title: 'Zed'), song('s1', album: 'X', title: 'Abe'), ]; final out = applyTrackQuery(songs, const BrowseFilter(), TrackSort.albumAsc); // Same album, no track numbers → tie-break by title. expect(out.map((s) => s.id), ['s1', 's2']); }); test('track filter + sort compose', () { final songs = [ song('a', genre: 'Rock', year: 2000, title: 'B'), song('b', genre: 'Rock', year: 2000, title: 'A'), song('c', genre: 'Jazz', year: 2000, title: 'C'), ]; final out = applyTrackQuery( songs, const BrowseFilter(genre: 'rock'), TrackSort.titleAsc); expect(out.map((s) => s.id), ['b', 'a']); }); }); }