Merge pull request 'fix: M3.5 polish — genre splitting, radio button, search focus' (#24) from dev into main
This commit was merged in pull request #24.
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
@@ -28,7 +30,9 @@ func BuildSessionVector(priorTracks []dbq.Track) SessionVector {
|
||||
v.Artists = append(v.Artists, artistID)
|
||||
}
|
||||
if t.Genre != nil && *t.Genre != "" {
|
||||
v.Tags[*t.Genre]++
|
||||
for _, g := range splitGenres(*t.Genre) {
|
||||
v.Tags[g]++
|
||||
}
|
||||
}
|
||||
v.RecentTrackIDs = append(v.RecentTrackIDs, uuidString(t.ID))
|
||||
}
|
||||
@@ -41,3 +45,22 @@ func uuidString(u pgtype.UUID) string {
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// splitGenres splits a track's denormalized genre string on the common
|
||||
// multi-genre delimiters (`;`, `,`) used by various tag editors. Trims
|
||||
// whitespace; drops empty fragments. Strings with no delimiter come back
|
||||
// as a single-element slice. Concatenated-without-separator inputs (e.g.
|
||||
// "ElectronicComplextroGlitch Hop" from broken tag-editor output) cannot
|
||||
// be split without a genre dictionary and stay as one opaque tag.
|
||||
func splitGenres(s string) []string {
|
||||
parts := strings.FieldsFunc(s, func(r rune) bool {
|
||||
return r == ';' || r == ','
|
||||
})
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -123,6 +123,77 @@ func TestBuildSessionVector_NilGenre_NotIndexed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSessionVector_MultiGenreSplitsOnSemicolon(t *testing.T) {
|
||||
a1 := uuid("11111111-1111-1111-1111-111111111111")
|
||||
v := BuildSessionVector([]dbq.Track{
|
||||
track(a1, "Indie Pop; Pop; Alternative Pop"),
|
||||
track(a1, "Pop"),
|
||||
track(a1, "Boom Bap; Hip Hop; Lo-Fi"),
|
||||
})
|
||||
// Pop appears once on track 1 and once on track 2 → count 2.
|
||||
if v.Tags["Pop"] != 2 {
|
||||
t.Errorf("Tags['Pop'] = %d, want 2 (one per matching track)", v.Tags["Pop"])
|
||||
}
|
||||
// Indie Pop, Alternative Pop only on track 1.
|
||||
if v.Tags["Indie Pop"] != 1 || v.Tags["Alternative Pop"] != 1 {
|
||||
t.Errorf("Indie/Alt Pop counts wrong: %+v", v.Tags)
|
||||
}
|
||||
// Track 3's genres.
|
||||
if v.Tags["Boom Bap"] != 1 || v.Tags["Hip Hop"] != 1 || v.Tags["Lo-Fi"] != 1 {
|
||||
t.Errorf("track-3 genre counts wrong: %+v", v.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSessionVector_NoSeparatorStaysOpaque(t *testing.T) {
|
||||
// Concatenated strings without separators (broken tag-editor output)
|
||||
// can't be parsed; they stay as one tag and only match identical strings.
|
||||
a1 := uuid("11111111-1111-1111-1111-111111111111")
|
||||
v := BuildSessionVector([]dbq.Track{
|
||||
track(a1, "ElectronicComplextroGlitch Hop"),
|
||||
track(a1, "ElectronicComplextroGlitch Hop"),
|
||||
})
|
||||
if v.Tags["ElectronicComplextroGlitch Hop"] != 2 {
|
||||
t.Errorf("opaque tag count = %d, want 2", v.Tags["ElectronicComplextroGlitch Hop"])
|
||||
}
|
||||
if len(v.Tags) != 1 {
|
||||
t.Errorf("len(Tags) = %d, want 1", len(v.Tags))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitGenres(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"", nil},
|
||||
{"Pop", []string{"Pop"}},
|
||||
{"Pop; Rock", []string{"Pop", "Rock"}},
|
||||
{"Pop, Rock", []string{"Pop", "Rock"}},
|
||||
{"Pop; Rock, Jazz", []string{"Pop", "Rock", "Jazz"}},
|
||||
{" Pop ; Rock ", []string{"Pop", "Rock"}},
|
||||
{";; Pop ;;", []string{"Pop"}},
|
||||
{"ElectronicComplextroGlitch Hop", []string{"ElectronicComplextroGlitch Hop"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := splitGenres(c.in)
|
||||
if c.want == nil {
|
||||
if len(got) != 0 {
|
||||
t.Errorf("splitGenres(%q) = %v, want empty", c.in, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(got) != len(c.want) {
|
||||
t.Errorf("splitGenres(%q) = %v, want %v", c.in, got, c.want)
|
||||
continue
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != c.want[i] {
|
||||
t.Errorf("splitGenres(%q)[%d] = %q, want %q", c.in, i, got[i], c.want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSessionVector_JSONRoundTrip(t *testing.T) {
|
||||
a1 := uuid("11111111-1111-1111-1111-111111111111")
|
||||
v := BuildSessionVector([]dbq.Track{
|
||||
|
||||
@@ -17,15 +17,20 @@
|
||||
function navigate(next: string) {
|
||||
const onSearchPage = page.url.pathname.startsWith('/search');
|
||||
const trimmed = next.trim();
|
||||
// keepFocus: SvelteKit blurs the active element on navigation by default.
|
||||
// For a typeahead-style URL update we want the user to keep typing
|
||||
// without re-clicking the input every 250 ms.
|
||||
if (trimmed === '') {
|
||||
if (onSearchPage) goto('/search', { replaceState: true });
|
||||
if (onSearchPage) goto('/search', { replaceState: true, keepFocus: true });
|
||||
return;
|
||||
}
|
||||
goto(`/search?q=${encodeURIComponent(trimmed)}`, { replaceState: onSearchPage });
|
||||
goto(`/search?q=${encodeURIComponent(trimmed)}`, {
|
||||
replaceState: onSearchPage,
|
||||
keepFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
function onInput(e: Event) {
|
||||
value = (e.currentTarget as HTMLInputElement).value;
|
||||
function onInput() {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
timeout = null;
|
||||
@@ -50,7 +55,7 @@
|
||||
type="search"
|
||||
placeholder="Search artists, albums, tracks…"
|
||||
aria-label="Search"
|
||||
{value}
|
||||
bind:value
|
||||
oninput={onInput}
|
||||
onkeydown={onKey}
|
||||
class="w-full max-w-md rounded border border-border bg-surface px-3 py-1 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
|
||||
@@ -41,7 +41,7 @@ describe('SearchInput', () => {
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(250);
|
||||
expect(goto).toHaveBeenCalledTimes(1);
|
||||
expect(goto).toHaveBeenCalledWith('/search?q=h', { replaceState: false });
|
||||
expect(goto).toHaveBeenCalledWith('/search?q=h', { replaceState: false, keepFocus: true });
|
||||
});
|
||||
|
||||
test('rapid keystrokes debounce to a single goto', async () => {
|
||||
@@ -54,7 +54,7 @@ describe('SearchInput', () => {
|
||||
await fireEvent.input(input, { target: { value: 'hel' } });
|
||||
vi.advanceTimersByTime(250);
|
||||
expect(goto).toHaveBeenCalledTimes(1);
|
||||
expect(goto).toHaveBeenCalledWith('/search?q=hel', { replaceState: false });
|
||||
expect(goto).toHaveBeenCalledWith('/search?q=hel', { replaceState: false, keepFocus: true });
|
||||
});
|
||||
|
||||
test('typing while on /search uses replaceState=true', async () => {
|
||||
@@ -63,7 +63,7 @@ describe('SearchInput', () => {
|
||||
const input = screen.getByRole('searchbox') as HTMLInputElement;
|
||||
await fireEvent.input(input, { target: { value: 'he' } });
|
||||
vi.advanceTimersByTime(250);
|
||||
expect(goto).toHaveBeenCalledWith('/search?q=he', { replaceState: true });
|
||||
expect(goto).toHaveBeenCalledWith('/search?q=he', { replaceState: true, keepFocus: true });
|
||||
});
|
||||
|
||||
test('clearing input on /search calls goto("/search") (drops ?q=)', async () => {
|
||||
@@ -72,7 +72,7 @@ describe('SearchInput', () => {
|
||||
const input = screen.getByRole('searchbox') as HTMLInputElement;
|
||||
await fireEvent.input(input, { target: { value: '' } });
|
||||
vi.advanceTimersByTime(250);
|
||||
expect(goto).toHaveBeenCalledWith('/search', { replaceState: true });
|
||||
expect(goto).toHaveBeenCalledWith('/search', { replaceState: true, keepFocus: true });
|
||||
});
|
||||
|
||||
test('Escape clears the input value', async () => {
|
||||
@@ -89,6 +89,6 @@ describe('SearchInput', () => {
|
||||
const input = screen.getByRole('searchbox') as HTMLInputElement;
|
||||
await fireEvent.input(input, { target: { value: 'a b&c' } });
|
||||
vi.advanceTimersByTime(250);
|
||||
expect(goto).toHaveBeenCalledWith('/search?q=a%20b%26c', { replaceState: false });
|
||||
expect(goto).toHaveBeenCalledWith('/search?q=a%20b%26c', { replaceState: false, keepFocus: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { TrackRef } from '$lib/api/types';
|
||||
import { formatDuration } from '$lib/media/duration';
|
||||
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
|
||||
import { playQueue, enqueueTrack, playRadio } from '$lib/player/store.svelte';
|
||||
import LikeButton from './LikeButton.svelte';
|
||||
|
||||
type PlayHandler = (tracks: TrackRef[], index: number) => void;
|
||||
@@ -34,6 +34,11 @@
|
||||
e.stopPropagation();
|
||||
enqueueTrack(track);
|
||||
}
|
||||
|
||||
function onRadioClick(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
playRadio(track.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -42,13 +47,20 @@
|
||||
aria-label={track.title}
|
||||
onclick={onRowClick}
|
||||
onkeydown={onRowKey}
|
||||
class="grid w-full cursor-pointer grid-cols-[32px_1fr_auto_auto_auto] items-center gap-4 px-3 py-2 text-left text-sm odd:bg-surface/50 hover:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
class="grid w-full cursor-pointer grid-cols-[32px_1fr_auto_auto_auto_auto] items-center gap-4 px-3 py-2 text-left text-sm odd:bg-surface/50 hover:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
<span class="text-right tabular-nums text-text-secondary">
|
||||
{track.track_number ?? '—'}
|
||||
</span>
|
||||
<span class="truncate">{track.title}</span>
|
||||
<LikeButton entityType="track" entityId={track.id} />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Play radio from this track"
|
||||
title="Play radio"
|
||||
onclick={onRadioClick}
|
||||
class="rounded p-1 text-text-secondary hover:text-text-primary"
|
||||
>📻</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add to queue"
|
||||
|
||||
@@ -5,7 +5,8 @@ import { readable } from 'svelte/store';
|
||||
|
||||
vi.mock('$lib/player/store.svelte', () => ({
|
||||
playQueue: vi.fn(),
|
||||
enqueueTrack: vi.fn()
|
||||
enqueueTrack: vi.fn(),
|
||||
playRadio: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/likes', () => ({
|
||||
@@ -24,7 +25,7 @@ vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
});
|
||||
|
||||
import TrackRow from './TrackRow.svelte';
|
||||
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
|
||||
import { playQueue, enqueueTrack, playRadio } from '$lib/player/store.svelte';
|
||||
|
||||
const tracks: TrackRef[] = [
|
||||
{
|
||||
@@ -91,4 +92,11 @@ describe('TrackRow', () => {
|
||||
expect(enqueueTrack).toHaveBeenCalledWith(tracks[0]);
|
||||
expect(playQueue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('radio button calls playRadio with track id and does NOT trigger row play', async () => {
|
||||
render(TrackRow, { props: { tracks, index: 0 } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /play radio/i }));
|
||||
expect(playRadio).toHaveBeenCalledWith('t1');
|
||||
expect(playQueue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user