Files
SpotiFLAC-Mobile/test/library_search_test.dart
T

173 lines
6.7 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:sqflite/sqflite.dart';
import 'package:spotiflac_android/services/library_search.dart';
// Execute the production SQL, including bound parameters, against real SQLite.
// Python is also used by the repository's native artifact checks in CI.
class _SearchDatabase implements DatabaseExecutor {
@override
Future<List<Map<String, Object?>>> rawQuery(
String sql, [
List<Object?>? arguments,
]) async {
final result = await Process.run('python3', [
'-c',
r'''
import json, sqlite3, sys
db = sqlite3.connect(':memory:')
db.row_factory = sqlite3.Row
db.execute("ATTACH DATABASE ':memory:' AS history_db")
columns = """id TEXT PRIMARY KEY, track_name TEXT, artist_name TEXT,
album_name TEXT, album_artist TEXT, album_key TEXT, sort_track TEXT,
sort_album TEXT, sort_album_artist TEXT, track_name_norm TEXT,
album_name_norm TEXT, album_artist_norm TEXT, search_text TEXT,
cover_url TEXT, cover_path TEXT, file_path TEXT, enabled INTEGER"""
db.execute('CREATE TABLE history_db.history (' + columns + ')')
db.execute('CREATE TABLE library (' + columns + ')')
db.execute('CREATE VIEW library_visible AS SELECT * FROM library WHERE enabled = 1')
for table in ['library_path_keys', 'history_db.history_path_keys']:
db.execute('CREATE TABLE ' + table + ' (item_id TEXT, path_key TEXT)')
for table in ['history_db.history_search_fts', 'library_search_fts']:
db.execute('CREATE VIRTUAL TABLE ' + table + " USING fts5(search_text, tokenize='trigram')")
def track(local, id, title, artist, album, path=None, enabled=1):
table = 'library' if local else 'history_db.history'
path = path or '/' + id + '.flac'
fields = dict(id=id, track_name=title, artist_name=artist,
album_name=album, album_artist=artist,
album_key=(album+'|'+artist).lower(), sort_track=title.lower(),
sort_album=album.lower(), sort_album_artist=artist.lower(),
track_name_norm=title.lower(), album_name_norm=album.lower(),
album_artist_norm=artist.lower(), search_text=(title+' '+artist+' '+album).lower(),
file_path=path, enabled=enabled)
cur = db.execute('INSERT INTO '+table+' ('+','.join(fields)+') VALUES ('+','.join('?' for _ in fields)+')', list(fields.values()))
fts = 'library_search_fts' if local else 'history_db.history_search_fts'
db.execute('INSERT INTO '+fts+' (rowid, search_text) VALUES (?, ?)', (cur.lastrowid, fields['search_text']))
paths = 'library_path_keys' if local else 'history_db.history_path_keys'
db.execute('INSERT INTO '+paths+' VALUES (?, ?)', (id, path))
track(False, '1', 'Lilac', 'Mrs. GREEN APPLE', 'Spring')
track(False, '2', 'Blue', 'Mrs. GREEN APPLE', 'Spring')
track(False, '3', 'Live Lilac', 'Someone Else', 'Concert')
track(True, 'local', 'Lilac acoustic', 'Mrs. GREEN APPLE', 'Acoustic')
track(True, 'duplicate', 'Lilac', 'Mrs. GREEN APPLE', 'Spring', path='/1.flac')
track(True, 'hidden', 'Lilac hidden', 'Hidden Artist', 'Hidden', enabled=0)
track(False, '4', "Don't Stop", 'Artist', 'Night')
track(True, '5', '夜の歌', '歌手', '夜空')
track(False, '6', 'OR Nothing', 'Artist', 'Night')
for i in range(85):
track(False, 'page-'+str(i), 'Page '+str(i).zfill(3), 'Artist', 'Many')
query = json.loads(sys.argv[1])
print(json.dumps([dict(row) for row in db.execute(query['sql'], query['args'])]))
''',
jsonEncode({'sql': sql, 'args': arguments}),
]);
if (result.exitCode != 0) fail('${result.stderr}\n$sql\n$arguments');
return (jsonDecode(result.stdout as String) as List)
.cast<Map<String, dynamic>>();
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
for (final fts in [false, true]) {
group('Library search (FTS: $fts)', () {
final store = LibrarySearchStore(
_SearchDatabase(),
historyFts: fts,
localFts: fts,
);
Future<List<LibrarySearchHit>> search(
String query, {
LibrarySearchKind kind = LibrarySearchKind.songs,
bool includeLocal = true,
int limit = 40,
int offset = 0,
}) => store.search(
query: query,
kind: kind,
includeLocal: includeLocal,
limit: limit,
offset: offset,
);
test(
'matches unordered words across title and artist, including partial words',
() async {
expect((await search('green lil')).map((hit) => hit.id), [
'1',
'local',
]);
expect((await search('mrs. APPLE lilac')).map((hit) => hit.id), [
'1',
'local',
]);
},
);
test(
'ranks exact title first and excludes hidden sources and duplicate paths',
() async {
expect((await search('lilac')).map((hit) => hit.id), [
'1',
'local',
'3',
]);
expect(
(await search('lilac', includeLocal: false)).map((hit) => hit.id),
['1', '3'],
);
},
);
test(
'handles short, non-Latin and punctuated queries as literal terms',
() async {
expect((await search('夜')).single.id, '5');
expect((await search("DON’T stop")).single.id, '4');
expect((await search('OR')).map((hit) => hit.id), contains('6'));
expect(await search('***'), isEmpty);
},
);
test(
'album results match album fields and retain full track counts',
() async {
final hits = await search(
'apple spring',
kind: LibrarySearchKind.albums,
);
expect(hits.single.title, 'Spring');
expect(hits.single.trackCount, 2);
expect(
await search('lilac', kind: LibrarySearchKind.albums),
isEmpty,
);
},
);
test(
'artists combine local and downloaded songs without counting duplicate files',
() async {
final hits = await search('green', kind: LibrarySearchKind.artists);
expect(hits.single.title, 'Mrs. GREEN APPLE');
expect(hits.single.trackCount, 3);
},
);
test(
'pagination has stable ordering without missing or repeated hits',
() async {
final first = await search('page', limit: 40);
final second = await search('page', limit: 40, offset: 40);
final third = await search('page', limit: 40, offset: 80);
expect([first.length, second.length, third.length], [40, 40, 5]);
expect({
...first.map((h) => h.id),
...second.map((h) => h.id),
...third.map((h) => h.id),
}, hasLength(85));
},
);
});
}
}