581 lines
20 KiB
Dart
581 lines
20 KiB
Dart
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<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, 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<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> 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<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,
|
|
maxConcurrentDownloads: 7,
|
|
albumSort: AlbumSort.recentlyAdded,
|
|
trackSort: TrackSort.yearDesc,
|
|
nowPlayingCassette: true,
|
|
appTheme: AppTheme.lavender,
|
|
);
|
|
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);
|
|
expect(back.appTheme, AppTheme.lavender);
|
|
});
|
|
|
|
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);
|
|
expect(back.appTheme, AppTheme.standard);
|
|
// 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']);
|
|
});
|
|
|
|
test('track ratingDesc sorts highest first, unrated last, tie-break title',
|
|
() {
|
|
final songs = [
|
|
song('low', title: 'A'),
|
|
song('high', title: 'B'),
|
|
song('unrated', title: 'C'),
|
|
song('tieB', title: 'Zed'),
|
|
song('tieA', title: 'Abe'),
|
|
];
|
|
final out = applyTrackQuery(
|
|
songs,
|
|
const BrowseFilter(),
|
|
TrackSort.ratingDesc,
|
|
ratings: {'low': 2, 'high': 5, 'tieA': 3, 'tieB': 3},
|
|
);
|
|
// 5, then the two 3s (title tie-break Abe<Zed), then 2, then unrated (0).
|
|
expect(out.map((s) => s.id), ['high', 'tieA', 'tieB', 'low', 'unrated']);
|
|
});
|
|
|
|
test('track ratingDesc falls back to Song.userRating when not in map', () {
|
|
final songs = [
|
|
Song(id: 'r4', title: 'A', userRating: 4),
|
|
Song(id: 'r1', title: 'B', userRating: 1),
|
|
];
|
|
final out =
|
|
applyTrackQuery(songs, const BrowseFilter(), TrackSort.ratingDesc);
|
|
expect(out.map((s) => s.id), ['r4', 'r1']);
|
|
});
|
|
|
|
test('track minRating filter keeps only tracks at or above the threshold',
|
|
() {
|
|
final songs = [song('a'), song('b'), song('c'), song('d')];
|
|
final out = applyTrackQuery(
|
|
songs,
|
|
const BrowseFilter(minRating: 3),
|
|
TrackSort.titleAsc,
|
|
ratings: {'a': 5, 'b': 3, 'c': 2}, // d unrated (0)
|
|
);
|
|
expect(out.map((s) => s.id), ['a', 'b']);
|
|
});
|
|
});
|
|
}
|