Recover missing offline library entries, recognize cached-only libraries, and refresh availability after cache changes. Unify per-tab filters, compact the toolbar, and add reversible sort criteria with complete available purchase metadata. cx
310 lines
17 KiB
JavaScript
310 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
||
import assert from 'node:assert/strict';
|
||
import fs from 'node:fs';
|
||
import vm from 'node:vm';
|
||
const code = fs.readFileSync(new URL('../crates/qbz-qt/qml/assets/release-sort.js', import.meta.url), 'utf8');
|
||
const sort = vm.createContext({});
|
||
vm.runInContext(code.replace(/^\.pragma library\s*/, ''), sort);
|
||
const rows = [
|
||
{ id: 'souls', title: 'The Book of Souls', artist: 'Iron Maiden', year: 'Sep 4, 2015', releaseSortKey: 20150904 },
|
||
{ id: 'iron', title: 'Iron Maiden', artist: 'Iron Maiden', year: 'Apr 1, 1980', releaseSortKey: 19800401 },
|
||
{ id: 'senjutsu', title: 'Senjutsu', artist: 'Iron Maiden', year: 'Sep 3, 2021', releaseSortKey: 20210903 },
|
||
{ id: 'live', title: 'Live After Death', artist: 'Iron Maiden', year: 'Aug 1, 1985', releaseSortKey: 19850801 },
|
||
{ id: 'killers', title: 'Killers', artist: 'Iron Maiden', year: 'Feb 1, 1981', releaseSortKey: 19810201 },
|
||
];
|
||
const ids = items => Array.from(items, x => x.id);
|
||
const oldest = ['iron', 'killers', 'live', 'souls', 'senjutsu'];
|
||
for (const group of ['off', 'artist', 'year', 'decade']) {
|
||
assert.deepEqual(ids(sort.sortAlbums(rows, 'oldest', group)), oldest);
|
||
assert.deepEqual(ids(sort.sortAlbums(rows, 'newest', group)), oldest.slice().reverse());
|
||
}
|
||
assert.deepEqual(ids(sort.sortAlbums(rows, 'default', 'off')), ids(rows));
|
||
const localized = rows.map(x => ({...x, year: 'fecha visible localizada'}));
|
||
assert.deepEqual(ids(sort.sortAlbums(localized, 'oldest', 'off')), oldest);
|
||
for (const direction of ['oldest', 'newest']) {
|
||
assert.equal(sort.sortAlbums([{id: 'unknown'}, ...rows], direction, 'off').at(-1).id, 'unknown');
|
||
}
|
||
assert.deepEqual(ids(sort.sortAlbums([
|
||
{id:'later', releaseSortKey: 20210904}, {id:'earlier', releaseSortKey: 20210903},
|
||
], 'oldest', 'off')), ['earlier', 'later']);
|
||
assert.equal(sort.groupLabel(rows[1], 'year'), '1980');
|
||
assert.equal(sort.groupLabel(rows[1], 'decade'), '1980–1989');
|
||
assert.equal(sort.groupLabel({}, 'decade'), '#');
|
||
assert.deepEqual(ids(rows), ['souls', 'iron', 'senjutsu', 'live', 'killers']);
|
||
console.log('Release chronology, original-date keys, grouping and localized-label independence: passed');
|
||
|
||
// Execute ArtistView's actual comparator too, including Default after an
|
||
// arbitrary sort and after an appended Load more page.
|
||
const artistQml = fs.readFileSync(new URL('../crates/qbz-qt/qml/views/ArtistView.qml', import.meta.url), 'utf8');
|
||
const begin = artistQml.indexOf('function sortReleaseCards(');
|
||
let end = artistQml.indexOf('{', begin) + 1, depth = 1;
|
||
while (depth && end < artistQml.length) {
|
||
if (artistQml[end] === '{') ++depth;
|
||
if (artistQml[end] === '}') --depth;
|
||
++end;
|
||
}
|
||
assert.equal(depth, 0);
|
||
sort.ReleaseSort = sort;
|
||
vm.runInContext(artistQml.slice(begin, end), sort);
|
||
const ranked = rows.map((x, index) => ({...x, defaultOrder: index}));
|
||
assert.deepEqual(ids(sort.sortReleaseCards(ranked, 'oldest')), oldest);
|
||
const shuffled = sort.sortReleaseCards(ranked, 'newest');
|
||
assert.deepEqual(ids(sort.sortReleaseCards(shuffled, 'default')), ids(rows));
|
||
shuffled.push({id:'page2', defaultOrder:10, releaseSortKey:19700101});
|
||
assert.deepEqual(ids(sort.sortReleaseCards(sort.sortReleaseCards(shuffled, 'oldest'), 'default')),
|
||
[...ids(rows), 'page2']);
|
||
const libraryQml = fs.readFileSync(new URL('../crates/qbz-qt/qml/views/LibraryView.qml', import.meta.url), 'utf8');
|
||
const dateGrouping = libraryQml.match(/readonly property bool dateGrouping:([\s\S]*?)readonly property bool alphaActive:/)[1].trim();
|
||
for (const activeTab of ['tracks', 'albums', 'artists']) {
|
||
for (const albumsGroup of ['off', 'artist', 'alpha', 'year', 'decade']) {
|
||
assert.equal(vm.runInNewContext(dateGrouping, {activeTab, albumsGroup}),
|
||
activeTab === 'albums' && ['year', 'decade'].includes(albumsGroup));
|
||
}
|
||
}
|
||
console.log('Artist Default restores server order across sorts/pages; date grouping does not affect track A-Z: passed');
|
||
|
||
// Run the Library feed parser and visible-items derivation, not just the sort
|
||
// helper: duplicate favorite/purchase rows must select the purchased row and
|
||
// retain its release date, while source switches remain independent per tab.
|
||
function qmlFunction(name) {
|
||
const start = libraryQml.indexOf(`function ${name}(`);
|
||
assert.ok(start >= 0, name);
|
||
let stop = libraryQml.indexOf('{', start) + 1, level = 1;
|
||
while (level && stop < libraryQml.length) {
|
||
if (libraryQml[stop] === '{') ++level;
|
||
if (libraryQml[stop] === '}') --level;
|
||
++stop;
|
||
}
|
||
assert.equal(level, 0, name);
|
||
return libraryQml.slice(start, stop);
|
||
}
|
||
const bridge = {
|
||
sessionShowPurchases: false, sessionShowFavorites: false, sessionShowFollowing: false,
|
||
sessionAlbumsShowPurchases: false, sessionAlbumsShowFavorites: false,
|
||
sessionTracksShowPurchases: false, sessionTracksShowFavorites: false,
|
||
sessionAlbumsHiresOnly:false, sessionTracksHiresOnly:false, sessionHideLocalAlbums:false,
|
||
};
|
||
const lib = vm.createContext({
|
||
QbzLibrary: bridge, ReleaseSort: sort, Qt: {callLater() {}},
|
||
activeTab: 'all', search: '', tabSearch: '', showLocal: true,
|
||
sortBy: 'date', sortAsc: false, albumsSort: 'oldest', albumsGroup: 'off',
|
||
tracksGroup: 'off', artistsGroup: 'off', artistsView:'grid', genreNames: [],
|
||
tracksSort:'default', playlistsSort:'default', artistsSort:'default', labelsSort:'default',
|
||
});
|
||
lib.root = lib;
|
||
for (const name of ['showPurchases', 'showFavorites', 'showFollowing', 'genreContext', 'hiresOnly', 'activeSort']) {
|
||
const expr = libraryQml.match(new RegExp(`readonly property (?:bool|string) ${name}:([^\\n]*(?:\\n [^\\n]*)*)`))[1];
|
||
Object.defineProperty(lib, name, {get() { return vm.runInContext(expr, lib); }});
|
||
}
|
||
for (const name of ['alphaKey', 'pickSort', 'isLocalFeedItem', 'hideableLocalAlbum', 'parseFeed', 'visibleItems', 'matchesSources', 'setShowPurchases', 'setShowFavorites', 'setShowFollowing', 'setHiresOnly']) {
|
||
vm.runInContext(qmlFunction(name), lib);
|
||
}
|
||
const libraryRows = [];
|
||
for (const kind of ['album', 'track']) {
|
||
for (const [id, groups, date] of [
|
||
['both', ['favorites', 'purchases'], 20150904],
|
||
['purchased', ['purchases'], 20210903],
|
||
['favorite', ['favorites'], 19800401],
|
||
]) {
|
||
for (const group of groups) libraryRows.push({id, kind, group, title:id,
|
||
artist:'Iron Maiden', source:'qobuz', year:String(date), releaseSortKey:date});
|
||
}
|
||
}
|
||
libraryRows.push({id:'followed', kind:'playlist', group:'following', source:'qobuz', title:'Followed'});
|
||
lib.feed = lib.parseFeed(JSON.stringify(libraryRows));
|
||
for (const tab of ['all', 'albums', 'tracks']) {
|
||
lib.activeTab = tab;
|
||
if (tab === 'all') lib.setShowFollowing(false);
|
||
for (const [purchases, favorites, expected] of [
|
||
[true, true, ['both']],
|
||
[true, false, ['both','purchased']],
|
||
[false, true, ['both','favorite']],
|
||
[false, false, ['both','favorite','purchased']],
|
||
]) {
|
||
lib.setShowPurchases(purchases); lib.setShowFavorites(favorites);
|
||
const shown = lib.visibleItems().filter(x => x.kind === (tab === 'tracks' ? 'track' : 'album'));
|
||
assert.deepEqual([...new Set(ids(shown))].sort(), expected, `${tab}: ${purchases}/${favorites}`);
|
||
if (tab !== 'all') {
|
||
assert.equal(shown.length, expected.length, 'No duplicated entities');
|
||
assert.equal(shown.find(x => x.id === 'both').group, 'purchases');
|
||
}
|
||
}
|
||
}
|
||
lib.activeTab = 'albums'; lib.setShowPurchases(false); lib.setShowFavorites(false);
|
||
assert.deepEqual(ids(lib.visibleItems()), ['favorite', 'both', 'purchased']);
|
||
lib.albumsSort = 'newest';
|
||
assert.deepEqual(ids(lib.visibleItems()), ['purchased', 'both', 'favorite']);
|
||
// Navigation away and component recreation must read the singleton's own tab
|
||
// state, rather than copy whichever tab was last visible.
|
||
lib.activeTab = 'all'; lib.setShowPurchases(false); lib.setShowFavorites(true); lib.setShowFollowing(false);
|
||
lib.activeTab = 'albums'; lib.setShowPurchases(true); lib.setShowFavorites(false);
|
||
lib.activeTab = 'tracks'; lib.setShowPurchases(false); lib.setShowFavorites(false);
|
||
for (const [tab, expected] of [['all',[false,true]], ['albums',[true,false]], ['tracks',[false,false]]]) {
|
||
lib.activeTab = tab;
|
||
assert.deepEqual([lib.showPurchases, lib.showFavorites], expected);
|
||
assert.equal(lib.genreContext, `library-${tab}`);
|
||
}
|
||
lib.activeTab = 'all'; lib.setShowPurchases(false); lib.setShowFavorites(false); lib.setShowFollowing(true);
|
||
assert.deepEqual(ids(lib.visibleItems()), ['followed']);
|
||
lib.activeTab = 'albums';
|
||
assert.deepEqual(ids(lib.visibleItems()), ['purchased', 'both'], 'Following cannot gate Albums');
|
||
console.log('Library only-filter intersections, per-tab state, purchase-row dates, deduplication and chronology: passed');
|
||
|
||
// Popularity leaves server order intact, and changing its paging order must
|
||
// discard the old overlay/cursor while a local date/title sort keeps them.
|
||
assert.deepEqual(ids(sort.sortReleaseCards(ranked, 'relevant')), ids(rows));
|
||
const changeStart = artistQml.indexOf('function releaseSortChanged(');
|
||
let changeEnd = artistQml.indexOf('{', changeStart) + 1, changeDepth = 1;
|
||
while (changeDepth && changeEnd < artistQml.length) {
|
||
if (artistQml[changeEnd] === '{') ++changeDepth;
|
||
if (artistQml[changeEnd] === '}') --changeDepth;
|
||
++changeEnd;
|
||
}
|
||
const artistState = vm.createContext({QbzArtist:{setSectionSort() {}}});
|
||
artistState.root = artistState;
|
||
vm.runInContext(artistQml.slice(changeStart, changeEnd), artistState);
|
||
for (const [from,to,reset] of [['default','relevant',true],['relevant','oldest',true],['oldest','newest',false]]) {
|
||
artistState.artist = {releaseSections:[{releaseType:'album',sortBy:from}]};
|
||
artistState.releaseFade = {};
|
||
artistState.releaseOverlay = {album:{cards:[{id:'old-page'}],hasMore:false}};
|
||
artistState.releaseCursor = {album:{next:40}};
|
||
artistState.releasePending = {album:true};
|
||
artistState.releaseSortChanged('album',to);
|
||
assert.equal(artistState.releaseCursor.album === undefined, reset);
|
||
assert.equal(artistState.releaseOverlay.album === undefined, reset);
|
||
if (reset) assert.equal(artistState.releasePending.album, undefined);
|
||
}
|
||
console.log('Artist popularity preserves server rank and resets pagination across server-order changes: passed');
|
||
|
||
// Artists must remain independent of album/track source and genre switches.
|
||
// The #758 log contains 19 artists; it does not establish a rendering cause.
|
||
const artistRows = Array.from({length:19}, (_, i) => ({kind:'artist', id:`artist-${i}`,
|
||
title:`Artist ${i}`, group:'favorites', source:'qobuz'}));
|
||
lib.feed = lib.parseFeed(JSON.stringify([...libraryRows, ...artistRows]));
|
||
lib.activeTab = 'artists';
|
||
lib.genreNames = ['genre absent from every artist'];
|
||
for (const group of ['off', 'alpha']) {
|
||
lib.artistsGroup = group;
|
||
for (const enabled of [false, true]) {
|
||
bridge.sessionShowPurchases = enabled;
|
||
bridge.sessionShowFavorites = enabled;
|
||
bridge.sessionAlbumsShowPurchases = enabled;
|
||
bridge.sessionTracksShowFavorites = enabled;
|
||
assert.equal(lib.visibleItems().length, 19);
|
||
}
|
||
}
|
||
lib.tabSearch = 'Artist 18';
|
||
assert.deepEqual(ids(lib.visibleItems()), ['artist-18']);
|
||
lib.tabSearch = '';
|
||
assert.equal(lib.visibleItems().length, 19);
|
||
console.log('Library Artists preserves all 19 rows independently of source/genre switches: passed');
|
||
|
||
// Each Hi-Res restriction combines with source restrictions in its own tab.
|
||
lib.genreNames = [];
|
||
lib.feed = lib.parseFeed(JSON.stringify(libraryRows.map(row => ({...row,
|
||
qualityTier: row.id === 'both' ? 'hires' : 'cd'}))));
|
||
lib.activeTab = 'albums'; lib.setShowPurchases(false); lib.setShowFavorites(false);
|
||
lib.setHiresOnly(true);
|
||
assert.deepEqual(ids(lib.visibleItems()), ['both']);
|
||
lib.activeTab = 'tracks'; lib.setShowPurchases(false); lib.setShowFavorites(false);
|
||
assert.equal(lib.visibleItems().length, 3);
|
||
lib.setHiresOnly(true); lib.setShowFavorites(true);
|
||
assert.deepEqual(ids(lib.visibleItems()), ['both']);
|
||
lib.activeTab = 'all'; lib.setShowFollowing(false);
|
||
lib.setShowPurchases(false); lib.setShowFavorites(false);
|
||
lib.feed = lib.parseFeed(JSON.stringify([
|
||
...['local','plex','jellyfin','subsonic'].map(source => ({
|
||
kind:'album',id:source+':album',source,group:'local'})),
|
||
{kind:'track',id:'local-track',source:'local',group:'local'},
|
||
{kind:'album',id:'online',source:'qobuz',group:'favorites'},
|
||
{kind:'album',id:'matched-cache',source:'qobuz',group:'favorites',sources:['offline']},
|
||
{kind:'album',id:'cache',source:'local',group:'local',sources:['offline']},
|
||
]));
|
||
lib.showLocal = false;
|
||
assert.deepEqual(ids(lib.visibleItems()).sort(), ['cache','local-track','matched-cache','online']);
|
||
lib.showLocal = true;
|
||
assert.equal(lib.visibleItems().length, 8);
|
||
const playlists = ['you','qobuz','others','unknown'].map((creator, i) => ({
|
||
id:String(i), kind:'playlist', source:'qobuz', group:i ? 'following':'favorites',
|
||
title:creator, playlistCreator:creator, isFavorite:i===2,
|
||
}));
|
||
lib.feed = lib.parseFeed(JSON.stringify([...playlists, {...playlists[2],group:'favorites'}]));
|
||
lib.activeTab = 'playlists';
|
||
for (const creator of ['all','you','qobuz','others']) {
|
||
lib.playlistsSubTab = creator;
|
||
assert.equal(lib.visibleItems().length, creator === 'all' ? 4 : 1);
|
||
if (creator !== 'all') assert.equal(lib.visibleItems()[0].playlistCreator, creator);
|
||
}
|
||
assert.equal(lib.feed._tabTotals.playlists, 4);
|
||
console.log('Hi-Res intersections, per-tab isolation, local-album exclusion and playlist author select: passed');
|
||
|
||
// Catalog IDs and local row IDs live in different namespaces.
|
||
lib.feed = lib.parseFeed(JSON.stringify([
|
||
{kind:'track',id:'7',source:'local',group:'local'},
|
||
{kind:'track',id:'7',source:'qobuz',group:'purchases'},
|
||
]));
|
||
lib.activeTab = 'all'; lib.setShowPurchases(true); lib.setShowFavorites(false); lib.setShowFollowing(false);
|
||
assert.equal(lib.visibleItems().length, 1);
|
||
assert.equal(lib.visibleItems()[0].source, 'qobuz');
|
||
|
||
// Re-selecting one criterion flips it; other tabs retain their own choices.
|
||
lib.setPref = (key, value) => { lib[key] = value; };
|
||
for (const tab of ['all','tracks','albums','artists','labels','playlists']) {
|
||
lib.activeTab = tab;
|
||
lib.pickSort('title');
|
||
assert.equal(lib.activeSort, 'title-asc');
|
||
lib.pickSort('title');
|
||
assert.equal(lib.activeSort, 'title-desc');
|
||
lib.pickSort('date');
|
||
assert.equal(lib.activeSort, 'date-desc');
|
||
lib.pickSort('date');
|
||
assert.equal(lib.activeSort, 'date-asc');
|
||
}
|
||
lib.activeTab='albums'; lib.albumsSort='oldest'; lib.pickSort('release-date');
|
||
assert.equal(lib.albumsSort,'release-date-desc');
|
||
lib.activeTab='tracks'; assert.equal(lib.tracksSort,'date-asc');
|
||
const sortRows = [
|
||
{id:'b',title:'Bravo',artist:'B',album:'B',label:'B',genre:'Rock',releaseSortKey:20000101,durationSecs:200,trackCount:10,updatedAt:200,added_rank:0},
|
||
{id:'a',title:'Alpha',artist:'A',album:'A',label:'A',genre:'Jazz',releaseSortKey:19800101,durationSecs:100,trackCount:0,updatedAt:100,added_rank:1},
|
||
{id:'missing'}
|
||
];
|
||
for (const field of ['title','artist','release','label','genre','release-date','duration','track-count','updated']) {
|
||
assert.deepEqual(ids(sort.sortRows(sortRows,field+'-asc')),['a','b','missing'],field);
|
||
assert.deepEqual(ids(sort.sortRows(sortRows,field+'-desc')),['b','a','missing'],field);
|
||
}
|
||
assert.deepEqual(ids(sort.sortRows(sortRows.slice(0,2),'date-desc')),['b','a']);
|
||
assert.deepEqual(ids(sort.sortRows(sortRows.slice(0,2),'date-asc')),['a','b']);
|
||
assert.deepEqual(ids(sort.sortRows(sortRows,'default-reverse')),['missing','a','b']);
|
||
assert.deepEqual(ids(sortRows),['b','a','missing']);
|
||
console.log('All six tabs: one criterion, repeat-click reversal, independent state, numeric metadata, unknowns last: passed');
|
||
|
||
// Exercise tab derivation, so a grouping or old forced sort cannot override it.
|
||
lib.genreNames=[]; lib.tabSearch=''; lib.tracksGroup='off'; lib.artistsGroup='off';
|
||
bridge.sessionTracksHiresOnly=false; bridge.sessionAlbumsHiresOnly=false;
|
||
bridge.sessionTracksShowFavorites=false; bridge.sessionTracksShowPurchases=false;
|
||
bridge.sessionAlbumsShowFavorites=false; bridge.sessionAlbumsShowPurchases=false;
|
||
for (const [tab,kind] of [['tracks','track'],['albums','album'],['artists','artist'],['labels','label'],['playlists','playlist']]) {
|
||
lib.activeTab=tab; lib.playlistsSubTab='all';
|
||
lib.feed=lib.parseFeed(JSON.stringify(sortRows.map(row=>({...row,kind,source:'qobuz',group:'favorites'}))));
|
||
lib[tab+'Sort']='title-asc'; assert.deepEqual(ids(lib.visibleItems()),['a','b','missing'],tab);
|
||
lib[tab+'Sort']='title-desc'; assert.deepEqual(ids(lib.visibleItems()),['b','a','missing'],tab);
|
||
}
|
||
lib.activeTab='artists'; lib.artistsGroup='alpha'; lib.artistsView='sidepanel';
|
||
lib.artistsSort='date-desc';
|
||
lib.feed=lib.parseFeed(JSON.stringify(sortRows.slice(0,2).map(row=>({...row,kind:'artist',source:'qobuz',group:'favorites'}))));
|
||
assert.deepEqual(ids(lib.visibleItems()),['b','a'],'Sidepanel must not inherit grid grouping');
|
||
console.log('Every Library tab applies chosen order through the real visible-items pipeline: passed');
|
||
|
||
assert.deepEqual(ids(sort.sortRows([
|
||
{id:'full',kind:'playlist',trackCount:2,durationSecs:200},
|
||
{id:'empty',kind:'playlist',trackCount:0},
|
||
{id:'unknown',kind:'playlist',trackCount:2}
|
||
], 'duration-asc')), ['empty','full','unknown']);
|
||
|
||
lib.activeTab='albums'; lib.albumsSort='title-asc';
|
||
lib.pickSort('default'); assert.equal(lib.albumsSort,'default');
|
||
assert.equal(sort.ascending(lib.activeSort),false);
|
||
lib.pickSort('default'); assert.equal(lib.albumsSort,'default-reverse');
|
||
assert.equal(sort.ascending(lib.activeSort),true);
|
||
lib.pickSort('default'); assert.equal(lib.albumsSort,'default');
|