import 'package:flutter_test/flutter_test.dart'; import 'package:ratune_mobile/playlists/playlists.dart'; import 'package:ratune_mobile/settings/settings_store.dart'; import 'package:ratune_mobile/subsonic/models.dart'; import 'package:ratune_mobile/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; 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 addTracksToPlaylist(String playlistId, List songIds) async { _maybeThrow(); } @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'); }); }); 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'); }); }); }