mobile-music/test/features_test.dart
2026-07-29 22:41:38 -04:00

362 lines
12 KiB
Dart

import 'package:flutter/widgets.dart' show Color;
import 'package:flutter_test/flutter_test.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<Playlist> playlists = [];
final Map<String, PlaylistDetail> 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<List<Playlist>> getPlaylists() async => List.of(playlists);
@override
Future<PlaylistDetail> getPlaylist(String id) async =>
details[id] ?? PlaylistDetail(id: id, name: 'x');
@override
Future<PlaylistDetail?> 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<void> 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<void> addTracksToPlaylist(String playlistId, List<String> 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}) => Playlist(
id: p.id,
name: p.name,
songCount: p.songCount,
duration: p.duration,
owner: p.owner,
public: p.public,
coverArt: p.coverArt,
comment: comment ?? p.comment,
);
static PlaylistDetail _copyDetail(PlaylistDetail d,
{String? comment, List<Song>? 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<void> removeTrackFromPlaylist(String playlistId, int index) async {
_maybeThrow();
}
@override
Future<void> renamePlaylist(String playlistId, String name) async {
_maybeThrow();
}
@override
Future<void> 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,
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.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.nowPlayingCassette, isFalse);
// An unrecognised enum name also falls back rather than throwing.
expect(
AppSettings.fromJson({'defaultBrowseMode': 'bogus'}).defaultBrowseMode,
BrowseMode.artists,
);
});
});
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);
});
});
}