From 2df86f4faab1431139ac822838b8cb68b75867a8 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sun, 2 Nov 2025 18:35:13 +0900 Subject: [PATCH] improve album tests --- .../library-manager/scripts/music-download.ts | 2 +- .../library-manager/scripts/setup-servers.ts | 57 ++++++- .../library-manager/scripts/util/subsonic.ts | 38 ++++- docker/library-manager/scripts/util/util.ts | 3 + lib/sources/models.dart | 7 + lib/sources/models.freezed.dart | 120 +++++++++++---- lib/sources/models.g.dart | 142 ++++++++++++++++++ mise-tasks/servers-reset.sh | 6 +- test/sources/subsonic_test.dart | 61 ++++++++ 9 files changed, 399 insertions(+), 37 deletions(-) create mode 100644 docker/library-manager/scripts/util/util.ts create mode 100644 lib/sources/models.g.dart diff --git a/docker/library-manager/scripts/music-download.ts b/docker/library-manager/scripts/music-download.ts index 50dade6..5e9aec9 100755 --- a/docker/library-manager/scripts/music-download.ts +++ b/docker/library-manager/scripts/music-download.ts @@ -13,7 +13,7 @@ const client = new SubsonicClient( ); for (const id of ["197", "199", "321"]) { - const res = await client.get("download", { id }); + const { res } = await client.get("download", { id }); let filename = res.headers.get("Content-Disposition") ?.split(";")[1]; diff --git a/docker/library-manager/scripts/setup-servers.ts b/docker/library-manager/scripts/setup-servers.ts index 4a596d5..b3c0f26 100755 --- a/docker/library-manager/scripts/setup-servers.ts +++ b/docker/library-manager/scripts/setup-servers.ts @@ -1,18 +1,65 @@ #!/usr/bin/env -S deno --allow-all +import { SubsonicClient } from "./util/subsonic.ts"; +import { sleep } from "./util/util.ts"; + +async function scrobbleTrack( + client: SubsonicClient, + album: string, + track: number, +) { + const { xml: albumsXml } = await client.get("getAlbumList2", { + type: "newest", + }); + const albumId = albumsXml.querySelector(`album[name='${album}']`)?.id; + + const { xml: songsXml } = await client.get("getAlbum", { id: albumId! }); + const songId = songsXml.querySelector(`song[track='${track}']`)?.id; + + await client.get("scrobble", { + id: songId!, + submission: "true", + }); +} + +async function setupTestData(client: SubsonicClient) { + await scrobbleTrack(client, "Retroconnaissance EP", 1); + await sleep(1_000); + await scrobbleTrack(client, "Retroconnaissance EP", 2); + await sleep(1_000); + await scrobbleTrack(client, "Kosmonaut", 1); +} async function setupNavidrome() { console.log("setting up navidrome..."); + const baseUrl = "http://navidrome:4533"; + const username = "admin"; + const password = "password"; + await fetch("http://navidrome:4533/auth/createAdmin", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - username: "admin", - password: "password", - }), + body: JSON.stringify({ username, password }), }); + + const client = new SubsonicClient(baseUrl, username, password); + await setupTestData(client); } -await setupNavidrome(); +async function setupGonic() { + console.log("setting up gonic..."); + + const baseUrl = "http://gonic"; + const username = "admin"; + const password = "admin"; + + const client = new SubsonicClient(baseUrl, username, password); + await setupTestData(client); +} + +await Promise.all([ + setupNavidrome(), + setupGonic(), +]); console.log("setup-servers complete"); diff --git a/docker/library-manager/scripts/util/subsonic.ts b/docker/library-manager/scripts/util/subsonic.ts index 0d3f313..e444093 100644 --- a/docker/library-manager/scripts/util/subsonic.ts +++ b/docker/library-manager/scripts/util/subsonic.ts @@ -1,3 +1,6 @@ +// @deno-types="npm:@types/jsdom@27.0.0" +import { JSDOM } from "npm:jsdom@27.1.0"; + export class SubsonicClient { constructor( readonly baseUrl: string, @@ -5,7 +8,18 @@ export class SubsonicClient { readonly password: string, ) {} - get(method: string, params?: Record) { + async get( + method: "download", + params?: Record, + ): Promise<{ res: Response; xml: undefined }>; + async get( + method: string, + params?: Record, + ): Promise<{ res: Response; xml: Document }>; + async get( + method: string, + params?: Record, + ): Promise<{ res: Response; xml: Document | undefined }> { const url = new URL(`rest/${method}.view`, this.baseUrl); url.searchParams.set("u", this.username); @@ -19,6 +33,26 @@ export class SubsonicClient { ); } - return fetch(url); + const res = await fetch(url); + + let xml: Document | undefined; + if (res.headers.get("content-type")?.includes("xml")) { + xml = new JSDOM(await res.text(), { + contentType: "text/xml", + }).window.document; + } + + if (!res.ok) { + let message = `HTTP error ${res.status}`; + if (xml) { + const error = xml.querySelector("error"); + const errorCode = error?.getAttribute("code"); + const errorMessage = error?.getAttribute("message"); + message += `\nSubsonic error${errorCode}: ${errorMessage}`; + } + throw new Error(message); + } + + return { res, xml }; } } diff --git a/docker/library-manager/scripts/util/util.ts b/docker/library-manager/scripts/util/util.ts new file mode 100644 index 0000000..7e78bac --- /dev/null +++ b/docker/library-manager/scripts/util/util.ts @@ -0,0 +1,3 @@ +export function sleep(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} diff --git a/lib/sources/models.dart b/lib/sources/models.dart index 8e2fc1f..ae68c99 100644 --- a/lib/sources/models.dart +++ b/lib/sources/models.dart @@ -3,6 +3,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'models.freezed.dart'; +part 'models.g.dart'; mixin Starred { DateTime? get starred; @@ -67,6 +68,9 @@ abstract class SourceItem with _$SourceItem { String? genre, String? coverArt, }) = SourceSong; + + factory SourceItem.fromJson(Map json) => + _$SourceItemFromJson(json); } @freezed @@ -76,4 +80,7 @@ abstract class SourcePlaylistSong with _$SourcePlaylistSong { required String songId, required int position, }) = _SourcePlaylistSong; + + factory SourcePlaylistSong.fromJson(Map json) => + _$SourcePlaylistSongFromJson(json); } diff --git a/lib/sources/models.freezed.dart b/lib/sources/models.freezed.dart index 4e7743f..38694f6 100644 --- a/lib/sources/models.freezed.dart +++ b/lib/sources/models.freezed.dart @@ -11,6 +11,38 @@ part of 'models.dart'; // dart format off T _$identity(T value) => value; +SourceItem _$SourceItemFromJson( + Map json +) { + switch (json['runtimeType']) { + case 'artist': + return SourceArtist.fromJson( + json + ); + case 'album': + return SourceAlbum.fromJson( + json + ); + case 'playlist': + return SourcePlaylist.fromJson( + json + ); + case 'song': + return SourceSong.fromJson( + json + ); + + default: + throw CheckedFromJsonException( + json, + 'runtimeType', + 'SourceItem', + 'Invalid union type "${json['runtimeType']}"!' +); + } + +} + /// @nodoc mixin _$SourceItem { @@ -21,6 +53,8 @@ mixin _$SourceItem { @pragma('vm:prefer-inline') $SourceItemCopyWith get copyWith => _$SourceItemCopyWithImpl(this as SourceItem, _$identity); + /// Serializes this SourceItem to a JSON map. + Map toJson(); @override @@ -28,7 +62,7 @@ bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType&&other is SourceItem&&(identical(other.id, id) || other.id == id)); } - +@JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash(runtimeType,id); @@ -221,11 +255,11 @@ return song(_that.id,_that.albumId,_that.artistId,_that.title,_that.artist,_that } /// @nodoc - +@JsonSerializable() class SourceArtist with Starred implements SourceItem { - const SourceArtist({required this.id, required this.name, this.starred, this.smallImage, this.largeImage}); - + const SourceArtist({required this.id, required this.name, this.starred, this.smallImage, this.largeImage, final String? $type}): $type = $type ?? 'artist'; + factory SourceArtist.fromJson(Map json) => _$SourceArtistFromJson(json); @override final String id; final String name; @@ -233,20 +267,27 @@ class SourceArtist with Starred implements SourceItem { final Uri? smallImage; final Uri? largeImage; +@JsonKey(name: 'runtimeType') +final String $type; + + /// Create a copy of SourceItem /// with the given fields replaced by the non-null parameter values. @override @JsonKey(includeFromJson: false, includeToJson: false) @pragma('vm:prefer-inline') $SourceArtistCopyWith get copyWith => _$SourceArtistCopyWithImpl(this, _$identity); - +@override +Map toJson() { + return _$SourceArtistToJson(this, ); +} @override bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType&&other is SourceArtist&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.starred, starred) || other.starred == starred)&&(identical(other.smallImage, smallImage) || other.smallImage == smallImage)&&(identical(other.largeImage, largeImage) || other.largeImage == largeImage)); } - +@JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash(runtimeType,id,name,starred,smallImage,largeImage); @@ -295,11 +336,11 @@ as Uri?, } /// @nodoc - +@JsonSerializable() class SourceAlbum with Starred, CoverArt implements SourceItem { - const SourceAlbum({required this.id, this.artistId, required this.name, this.albumArtist, required this.created, this.coverArt, this.year, this.starred, this.genre, this.frequentRank, this.recentRank}); - + const SourceAlbum({required this.id, this.artistId, required this.name, this.albumArtist, required this.created, this.coverArt, this.year, this.starred, this.genre, this.frequentRank, this.recentRank, final String? $type}): $type = $type ?? 'album'; + factory SourceAlbum.fromJson(Map json) => _$SourceAlbumFromJson(json); @override final String id; final String? artistId; @@ -313,20 +354,27 @@ class SourceAlbum with Starred, CoverArt implements SourceItem { final int? frequentRank; final int? recentRank; +@JsonKey(name: 'runtimeType') +final String $type; + + /// Create a copy of SourceItem /// with the given fields replaced by the non-null parameter values. @override @JsonKey(includeFromJson: false, includeToJson: false) @pragma('vm:prefer-inline') $SourceAlbumCopyWith get copyWith => _$SourceAlbumCopyWithImpl(this, _$identity); - +@override +Map toJson() { + return _$SourceAlbumToJson(this, ); +} @override bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType&&other is SourceAlbum&&(identical(other.id, id) || other.id == id)&&(identical(other.artistId, artistId) || other.artistId == artistId)&&(identical(other.name, name) || other.name == name)&&(identical(other.albumArtist, albumArtist) || other.albumArtist == albumArtist)&&(identical(other.created, created) || other.created == created)&&(identical(other.coverArt, coverArt) || other.coverArt == coverArt)&&(identical(other.year, year) || other.year == year)&&(identical(other.starred, starred) || other.starred == starred)&&(identical(other.genre, genre) || other.genre == genre)&&(identical(other.frequentRank, frequentRank) || other.frequentRank == frequentRank)&&(identical(other.recentRank, recentRank) || other.recentRank == recentRank)); } - +@JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash(runtimeType,id,artistId,name,albumArtist,created,coverArt,year,starred,genre,frequentRank,recentRank); @@ -381,11 +429,11 @@ as int?, } /// @nodoc - +@JsonSerializable() class SourcePlaylist with CoverArt implements SourceItem { - const SourcePlaylist({required this.id, required this.name, this.comment, required this.created, required this.changed, this.coverArt, this.owner, this.public}); - + const SourcePlaylist({required this.id, required this.name, this.comment, required this.created, required this.changed, this.coverArt, this.owner, this.public, final String? $type}): $type = $type ?? 'playlist'; + factory SourcePlaylist.fromJson(Map json) => _$SourcePlaylistFromJson(json); @override final String id; final String name; @@ -396,20 +444,27 @@ class SourcePlaylist with CoverArt implements SourceItem { final String? owner; final bool? public; +@JsonKey(name: 'runtimeType') +final String $type; + + /// Create a copy of SourceItem /// with the given fields replaced by the non-null parameter values. @override @JsonKey(includeFromJson: false, includeToJson: false) @pragma('vm:prefer-inline') $SourcePlaylistCopyWith get copyWith => _$SourcePlaylistCopyWithImpl(this, _$identity); - +@override +Map toJson() { + return _$SourcePlaylistToJson(this, ); +} @override bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType&&other is SourcePlaylist&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.comment, comment) || other.comment == comment)&&(identical(other.created, created) || other.created == created)&&(identical(other.changed, changed) || other.changed == changed)&&(identical(other.coverArt, coverArt) || other.coverArt == coverArt)&&(identical(other.owner, owner) || other.owner == owner)&&(identical(other.public, public) || other.public == public)); } - +@JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash(runtimeType,id,name,comment,created,changed,coverArt,owner,public); @@ -461,11 +516,11 @@ as bool?, } /// @nodoc - +@JsonSerializable() class SourceSong with Starred, CoverArt implements SourceItem { - const SourceSong({required this.id, this.albumId, this.artistId, required this.title, this.artist, this.album, this.duration, this.track, this.disc, this.starred, this.genre, this.coverArt}); - + const SourceSong({required this.id, this.albumId, this.artistId, required this.title, this.artist, this.album, this.duration, this.track, this.disc, this.starred, this.genre, this.coverArt, final String? $type}): $type = $type ?? 'song'; + factory SourceSong.fromJson(Map json) => _$SourceSongFromJson(json); @override final String id; final String? albumId; @@ -480,20 +535,27 @@ class SourceSong with Starred, CoverArt implements SourceItem { final String? genre; final String? coverArt; +@JsonKey(name: 'runtimeType') +final String $type; + + /// Create a copy of SourceItem /// with the given fields replaced by the non-null parameter values. @override @JsonKey(includeFromJson: false, includeToJson: false) @pragma('vm:prefer-inline') $SourceSongCopyWith get copyWith => _$SourceSongCopyWithImpl(this, _$identity); - +@override +Map toJson() { + return _$SourceSongToJson(this, ); +} @override bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType&&other is SourceSong&&(identical(other.id, id) || other.id == id)&&(identical(other.albumId, albumId) || other.albumId == albumId)&&(identical(other.artistId, artistId) || other.artistId == artistId)&&(identical(other.title, title) || other.title == title)&&(identical(other.artist, artist) || other.artist == artist)&&(identical(other.album, album) || other.album == album)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.track, track) || other.track == track)&&(identical(other.disc, disc) || other.disc == disc)&&(identical(other.starred, starred) || other.starred == starred)&&(identical(other.genre, genre) || other.genre == genre)&&(identical(other.coverArt, coverArt) || other.coverArt == coverArt)); } - +@JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash(runtimeType,id,albumId,artistId,title,artist,album,duration,track,disc,starred,genre,coverArt); @@ -548,6 +610,7 @@ as String?, } + /// @nodoc mixin _$SourcePlaylistSong { @@ -558,6 +621,8 @@ mixin _$SourcePlaylistSong { @pragma('vm:prefer-inline') $SourcePlaylistSongCopyWith get copyWith => _$SourcePlaylistSongCopyWithImpl(this as SourcePlaylistSong, _$identity); + /// Serializes this SourcePlaylistSong to a JSON map. + Map toJson(); @override @@ -565,7 +630,7 @@ bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType&&other is SourcePlaylistSong&&(identical(other.playlistId, playlistId) || other.playlistId == playlistId)&&(identical(other.songId, songId) || other.songId == songId)&&(identical(other.position, position) || other.position == position)); } - +@JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash(runtimeType,playlistId,songId,position); @@ -742,11 +807,11 @@ return $default(_that.playlistId,_that.songId,_that.position);case _: } /// @nodoc - +@JsonSerializable() class _SourcePlaylistSong implements SourcePlaylistSong { const _SourcePlaylistSong({required this.playlistId, required this.songId, required this.position}); - + factory _SourcePlaylistSong.fromJson(Map json) => _$SourcePlaylistSongFromJson(json); @override final String playlistId; @override final String songId; @@ -758,14 +823,17 @@ class _SourcePlaylistSong implements SourcePlaylistSong { @pragma('vm:prefer-inline') _$SourcePlaylistSongCopyWith<_SourcePlaylistSong> get copyWith => __$SourcePlaylistSongCopyWithImpl<_SourcePlaylistSong>(this, _$identity); - +@override +Map toJson() { + return _$SourcePlaylistSongToJson(this, ); +} @override bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType&&other is _SourcePlaylistSong&&(identical(other.playlistId, playlistId) || other.playlistId == playlistId)&&(identical(other.songId, songId) || other.songId == songId)&&(identical(other.position, position) || other.position == position)); } - +@JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash(runtimeType,playlistId,songId,position); diff --git a/lib/sources/models.g.dart b/lib/sources/models.g.dart new file mode 100644 index 0000000..2b32fbc --- /dev/null +++ b/lib/sources/models.g.dart @@ -0,0 +1,142 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'models.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SourceArtist _$SourceArtistFromJson(Map json) => SourceArtist( + id: json['id'] as String, + name: json['name'] as String, + starred: json['starred'] == null + ? null + : DateTime.parse(json['starred'] as String), + smallImage: json['smallImage'] == null + ? null + : Uri.parse(json['smallImage'] as String), + largeImage: json['largeImage'] == null + ? null + : Uri.parse(json['largeImage'] as String), + $type: json['runtimeType'] as String?, +); + +Map _$SourceArtistToJson(SourceArtist instance) => + { + 'id': instance.id, + 'name': instance.name, + 'starred': instance.starred?.toIso8601String(), + 'smallImage': instance.smallImage?.toString(), + 'largeImage': instance.largeImage?.toString(), + 'runtimeType': instance.$type, + }; + +SourceAlbum _$SourceAlbumFromJson(Map json) => SourceAlbum( + id: json['id'] as String, + artistId: json['artistId'] as String?, + name: json['name'] as String, + albumArtist: json['albumArtist'] as String?, + created: DateTime.parse(json['created'] as String), + coverArt: json['coverArt'] as String?, + year: (json['year'] as num?)?.toInt(), + starred: json['starred'] == null + ? null + : DateTime.parse(json['starred'] as String), + genre: json['genre'] as String?, + frequentRank: (json['frequentRank'] as num?)?.toInt(), + recentRank: (json['recentRank'] as num?)?.toInt(), + $type: json['runtimeType'] as String?, +); + +Map _$SourceAlbumToJson(SourceAlbum instance) => + { + 'id': instance.id, + 'artistId': instance.artistId, + 'name': instance.name, + 'albumArtist': instance.albumArtist, + 'created': instance.created.toIso8601String(), + 'coverArt': instance.coverArt, + 'year': instance.year, + 'starred': instance.starred?.toIso8601String(), + 'genre': instance.genre, + 'frequentRank': instance.frequentRank, + 'recentRank': instance.recentRank, + 'runtimeType': instance.$type, + }; + +SourcePlaylist _$SourcePlaylistFromJson(Map json) => + SourcePlaylist( + id: json['id'] as String, + name: json['name'] as String, + comment: json['comment'] as String?, + created: DateTime.parse(json['created'] as String), + changed: DateTime.parse(json['changed'] as String), + coverArt: json['coverArt'] as String?, + owner: json['owner'] as String?, + public: json['public'] as bool?, + $type: json['runtimeType'] as String?, + ); + +Map _$SourcePlaylistToJson(SourcePlaylist instance) => + { + 'id': instance.id, + 'name': instance.name, + 'comment': instance.comment, + 'created': instance.created.toIso8601String(), + 'changed': instance.changed.toIso8601String(), + 'coverArt': instance.coverArt, + 'owner': instance.owner, + 'public': instance.public, + 'runtimeType': instance.$type, + }; + +SourceSong _$SourceSongFromJson(Map json) => SourceSong( + id: json['id'] as String, + albumId: json['albumId'] as String?, + artistId: json['artistId'] as String?, + title: json['title'] as String, + artist: json['artist'] as String?, + album: json['album'] as String?, + duration: json['duration'] == null + ? null + : Duration(microseconds: (json['duration'] as num).toInt()), + track: (json['track'] as num?)?.toInt(), + disc: (json['disc'] as num?)?.toInt(), + starred: json['starred'] == null + ? null + : DateTime.parse(json['starred'] as String), + genre: json['genre'] as String?, + coverArt: json['coverArt'] as String?, + $type: json['runtimeType'] as String?, +); + +Map _$SourceSongToJson(SourceSong instance) => + { + 'id': instance.id, + 'albumId': instance.albumId, + 'artistId': instance.artistId, + 'title': instance.title, + 'artist': instance.artist, + 'album': instance.album, + 'duration': instance.duration?.inMicroseconds, + 'track': instance.track, + 'disc': instance.disc, + 'starred': instance.starred?.toIso8601String(), + 'genre': instance.genre, + 'coverArt': instance.coverArt, + 'runtimeType': instance.$type, + }; + +_SourcePlaylistSong _$SourcePlaylistSongFromJson(Map json) => + _SourcePlaylistSong( + playlistId: json['playlistId'] as String, + songId: json['songId'] as String, + position: (json['position'] as num).toInt(), + ); + +Map _$SourcePlaylistSongToJson(_SourcePlaylistSong instance) => + { + 'playlistId': instance.playlistId, + 'songId': instance.songId, + 'position': instance.position, + }; diff --git a/mise-tasks/servers-reset.sh b/mise-tasks/servers-reset.sh index e4bea48..d27e78e 100755 --- a/mise-tasks/servers-reset.sh +++ b/mise-tasks/servers-reset.sh @@ -5,12 +5,12 @@ docker compose build docker compose down docker volume rm $(docker compose volumes -q) || true -docker compose up -d - docker compose run --rm library-manager music-download.ts -docker compose run --rm library-manager setup-servers.ts +docker compose up -d echo "waiting for library scans..." sleep 10 +docker compose run --rm library-manager setup-servers.ts + docker compose down diff --git a/test/sources/subsonic_test.dart b/test/sources/subsonic_test.dart index 76ed8da..26f64dc 100644 --- a/test/sources/subsonic_test.dart +++ b/test/sources/subsonic_test.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:http/http.dart'; import 'package:subtracks/sources/subsonic/client.dart'; import 'package:subtracks/sources/subsonic/source.dart'; @@ -56,6 +57,33 @@ void main() { final items = await source.allAlbums().toList(); expect(items.length, equals(3)); + + final kosmo = items.firstWhere((a) => a.name == 'Kosmonaut'); + + expect(kosmo.id.length, greaterThan(0)); + expect(kosmo.artistId?.length, greaterThan(0)); + expect(kosmo.albumArtist, equals('Ugress')); + expect(kosmo.created.compareTo(DateTime.now()), lessThan(0)); + expect(kosmo.coverArt?.length, greaterThan(0)); + expect(kosmo.year, equals(2006)); + expect(kosmo.starred, isNull); + expect(kosmo.genre, equals('Electronic')); + + final retro = items.firstWhere( + (a) => a.name == 'Retroconnaissance EP', + ); + final dunno = items.firstWhere( + (a) => a.name == "I Don't Know What I'm Doing", + ); + + expect(kosmo.recentRank, equals(0)); + expect(kosmo.frequentRank, equals(1)); + + expect(retro.recentRank, equals(1)); + expect(retro.frequentRank, equals(0)); + + expect(dunno.recentRank, isNull); + expect(dunno.frequentRank, isNull); }); test('allArtists', () async { @@ -81,6 +109,39 @@ void main() { expect(items.length, equals(0)); }); + + test('album-artist relation', () async { + final artists = await source.allArtists().toList(); + final albums = await source.allAlbums().toList(); + + final artistAlbums = artists + .map( + (artist) => [ + artist.name, + ...albums + .where((album) => album.artistId == artist.id) + .map((album) => album.name) + .sorted(), + ], + ) + .sorted((a, b) => (a[0]).compareTo(b[0])); + + expect(artistAlbums.length, equals(2)); + expect( + artistAlbums, + equals([ + [ + 'Brad Sucks', + "I Don't Know What I'm Doing", + ], + [ + 'Ugress', + 'Kosmonaut', + 'Retroconnaissance EP', + ], + ]), + ); + }); }); } }