Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions e2e/smoke.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,23 @@ test.describe('first run', () => {
});

test.describe('seeded library', () => {
test('finds a song by normalized album and charter metadata', async () => {
harness = await launchApp({ seedLibrary: true });
page = await harness.app.firstWindow();

await page.getByTestId('settings-trigger').click();
await page.getByTestId('rescan-folder').click();

const song = page.getByText('Master of Puppets').first();

await expect(song).toBeVisible({ timeout: 30_000 });

for (const query of ['metal masters', 'test charter']) {
await page.getByTestId('song-search').fill(query);
await expect(song).toBeVisible();
}
});

test('scans the folder, lists the song, and renders real sheet music', async () => {
harness = await launchApp({ seedLibrary: true });
page = await harness.app.firstWindow();
Expand Down
1 change: 1 addition & 0 deletions e2e/support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ function writeFixtureLibrary(): string {
'[song]',
'name = Master of Puppets',
'artist = Metallica',
'album = Métal Masters',
'charter = Test Charter',
'pro_drums = True',
'five_lane_drums = False',
Expand Down
8 changes: 2 additions & 6 deletions src/renderer/hooks/useSongFilter.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { useMemo, useState } from 'react';
import Fuse from 'fuse.js';
import { Difficulty } from 'scan-chart';
import { Song } from '../../types';
import { type SortState } from '../components/SortButton';
import { useOnlineSearch } from './useOnlineSearch';
import { usePersisted } from './usePersisted';
import { LibraryMode } from '../types';
import { searchLocalSongs } from '../songSearch';

export function useSongFilter(songList: Song[], difficulty: Difficulty) {
const [nameFilter, setNameFilter] = useState('');
Expand All @@ -30,11 +30,7 @@ export function useSongFilter(songList: Song[], difficulty: Difficulty) {
);

if (nameFilter) {
const fuse = new Fuse(byDifficulty, {
keys: ['name', 'artist', 'charter'],
});

return fuse.search(nameFilter).map((result) => result.item);
return searchLocalSongs(byDifficulty, nameFilter);
}

return [...byDifficulty].sort((a, b) => {
Expand Down
39 changes: 39 additions & 0 deletions src/renderer/songSearch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { makeListSong } from './views/test-support';
import { normalizeSearchText, searchLocalSongs } from './songSearch';

describe('normalizeSearchText', () => {
it('folds case, diacritics and whitespace', () => {
expect(normalizeSearchText(' BEYONCÉ Knowles ')).toBe(
'beyonce knowles',
);
});
});

describe('searchLocalSongs', () => {
const songs = [
makeListSong('raging', {
name: 'Raging',
artist: 'Kygo feat. Kodaline',
album: 'Cloud Nine',
charter: '',
}),
makeListSong('lose', {
name: 'Lose Somebody',
artist: 'Kygo & OneRepublic',
album: 'Golden Hour',
charter: 'Human Charter',
}),
];

it.each([
['Kodaline', 'raging'],
['Cloud Nine', 'raging'],
['Human Charter', 'lose'],
['onerepublic', 'lose'],
])('matches %s across local metadata', (query, expectedId) => {
expect(searchLocalSongs(songs, query).map((song) => song.id)).toEqual([
expectedId,
]);
});
});
77 changes: 77 additions & 0 deletions src/renderer/songSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import Fuse from 'fuse.js';
import { Song } from '../types';

type SearchableSong = Pick<Song, 'name' | 'artist' | 'charter'> &
Partial<Pick<Song, 'album'>>;

const DIACRITICS = /\p{Diacritic}/gu;
const WHITESPACE = /\s+/g;

export function normalizeSearchText(value = ''): string {
return value
.normalize('NFKD')
.replace(DIACRITICS, '')
.toLocaleLowerCase()
.replace(WHITESPACE, ' ')
.trim();
}

function fields(song: SearchableSong): string[] {
return [song.name, song.artist, song.album ?? '', song.charter]
.map(normalizeSearchText)
.filter(Boolean);
}

function matchRank(song: SearchableSong, query: string): number {
const values = fields(song);

if (values.some((value) => value === query)) {
return 0;
}

if (values.some((value) => value.startsWith(query))) {
return 1;
}

if (values.some((value) => value.includes(query))) {
return 2;
}

return 3;
}

export function searchLocalSongs(songs: Song[], input: string): Song[] {
const query = normalizeSearchText(input);

if (!query) {
return [...songs];
}

const indexed = songs.map((song, index) => ({
song,
index,
searchText: fields(song).join(' '),
}));
const fuse = new Fuse(indexed, {
keys: ['searchText'],
includeScore: true,
ignoreLocation: true,
threshold: 0.35,
});

return fuse
.search(query)
.sort((a, b) => {
const rank =
matchRank(a.item.song, query) - matchRank(b.item.song, query);

if (rank !== 0) {
return rank;
}

const score = (a.score ?? 1) - (b.score ?? 1);

return score !== 0 ? score : a.item.index - b.item.index;
})
.map((result) => result.item.song);
}
21 changes: 21 additions & 0 deletions src/renderer/views/SongListView/SongListView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,27 @@ describe('SongListView — filtering and sorting', () => {
expect(screen.queryByText('Two')).not.toBeInTheDocument();
});

it('searches local album, charter and folded diacritics', () => {
const view = setupSongListView();

view.loadSongs([
makeListSong('raging', {
name: 'Raging',
artist: 'Kygo feat. Kodaliné',
album: 'Cloud Nine',
charter: 'Community Charter',
}),
makeListSong('other', { name: 'Other' }),
]);

for (const query of ['kodaline', 'cloud nine', 'community charter']) {
view.search(query);

expect(screen.getByText('Raging')).toBeInTheDocument();
expect(screen.queryByText('Other')).not.toBeInTheDocument();
}
});

it('reorders the list when a sort option is chosen', () => {
const view = setupSongListView();

Expand Down
Loading