diff --git a/internal/download/bpm_test.go b/internal/download/bpm_test.go
new file mode 100644
index 0000000..55b90f5
--- /dev/null
+++ b/internal/download/bpm_test.go
@@ -0,0 +1,42 @@
+package download
+
+import "testing"
+
+func bpmHTML(bpm, key, mode string) string {
+ return `tempo of ` + bpm + ` BPM` +
+ ` with a ` + key + ` key` +
+ ` and a ` + mode + ` mode`
+}
+
+func TestParseBPM(t *testing.T) {
+ tests := []struct {
+ name string
+ html string
+ wantBPM string
+ wantKey string
+ }{
+ {"major key", bpmHTML("128", "A", "major"), "128", "A"},
+ {"minor key gets m suffix", bpmHTML("90", "F", "minor"), "90", "Fm"},
+ {"unicode sharp normalized", bpmHTML("124", "C♯", "major"), "124", "C#"},
+ {"unicode flat normalized", bpmHTML("100", "B♭", "minor"), "100", "Bbm"},
+ {"enharmonic pair keeps first", bpmHTML("110", "A♯/B♭", "major"), "110", "A#"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := parseBPM(tt.html)
+ if err != nil {
+ t.Fatalf("parseBPM: %v", err)
+ }
+ if got.BPM != tt.wantBPM || got.Key != tt.wantKey {
+ t.Errorf("parseBPM() = %+v, want BPM %q Key %q", got, tt.wantBPM, tt.wantKey)
+ }
+ })
+ }
+}
+
+func TestParseBPMNoData(t *testing.T) {
+ if _, err := parseBPM("nothing here"); err == nil {
+ t.Error("expected error for page without BPM data")
+ }
+}
diff --git a/internal/download/genre_test.go b/internal/download/genre_test.go
new file mode 100644
index 0000000..4e52ba8
--- /dev/null
+++ b/internal/download/genre_test.go
@@ -0,0 +1,51 @@
+package download
+
+import (
+ "slices"
+ "testing"
+)
+
+func TestMatchesKeyword(t *testing.T) {
+ if !matchesKeyword("Deep House", electronicKeywords) {
+ t.Error("expected Deep House to match electronic keywords")
+ }
+ if !matchesKeyword("classic rock", nonElectronicKeywords) {
+ t.Error("expected classic rock to match non-electronic keywords")
+ }
+ if matchesKeyword("Spoken Word", electronicKeywords) {
+ t.Error("did not expect Spoken Word to match electronic keywords")
+ }
+}
+
+func TestFilterTags(t *testing.T) {
+ got := filterTags([]string{"Deep House", "Rock", "Spoken Word"})
+ want := []string{"Deep House", "Rock"}
+ if !slices.Equal(got, want) {
+ t.Errorf("filterTags() = %v, want %v", got, want)
+ }
+
+ if got := filterTags([]string{"Rock", "Jazz"}); len(got) != 0 {
+ t.Errorf("filterTags() = %v, want empty", got)
+ }
+
+ if got := filterTags(nil); len(got) != 0 {
+ t.Errorf("filterTags(nil) = %v, want empty", got)
+ }
+}
+
+func TestFormatTags(t *testing.T) {
+ tests := []struct {
+ tags []string
+ want string
+ }{
+ {[]string{"deep house"}, "Deep House"},
+ {[]string{"TECHNO", "trance"}, "Techno / Trance"},
+ {[]string{" house ", ""}, "House"},
+ }
+
+ for _, tt := range tests {
+ if got := formatTags(tt.tags); got != tt.want {
+ t.Errorf("formatTags(%v) = %q, want %q", tt.tags, got, tt.want)
+ }
+ }
+}
diff --git a/internal/download/hash_test.go b/internal/download/hash_test.go
new file mode 100644
index 0000000..ed6b6ef
--- /dev/null
+++ b/internal/download/hash_test.go
@@ -0,0 +1,71 @@
+package download
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestHashFile(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "file.txt")
+ if err := os.WriteFile(path, []byte("hello world"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := hashFile(path)
+ if err != nil {
+ t.Fatalf("hashFile: %v", err)
+ }
+
+ want := "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
+ if got != want {
+ t.Errorf("hashFile() = %s, want %s", got, want)
+ }
+}
+
+func TestHashFileMissing(t *testing.T) {
+ if _, err := hashFile(filepath.Join(t.TempDir(), "missing")); err == nil {
+ t.Error("expected error for missing file")
+ }
+}
+
+func TestHashIndexFind(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "sub", "track.mp3")
+ if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("hello world"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ index, err := newHashIndex(context.Background(), dir)
+ if err != nil {
+ t.Fatalf("newHashIndex: %v", err)
+ }
+
+ hash := "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
+ found, ok := index.find(hash)
+ if !ok || found != path {
+ t.Errorf("find(%s) = %q, %v; want %q, true", hash, found, ok, path)
+ }
+
+ if _, ok := index.find("deadbeef"); ok {
+ t.Error("find() reported a match for an unknown hash")
+ }
+}
+
+func TestHashIndexCanceledContext(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "a"), []byte("x"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ if _, err := newHashIndex(ctx, dir); err == nil {
+ t.Error("expected error for canceled context")
+ }
+}
diff --git a/internal/download/options_test.go b/internal/download/options_test.go
new file mode 100644
index 0000000..a2aa1b2
--- /dev/null
+++ b/internal/download/options_test.go
@@ -0,0 +1,39 @@
+package download
+
+import (
+ "testing"
+ "time"
+)
+
+func TestOptionsValidate(t *testing.T) {
+ valid := Options{Quality: "mp3_320", Timeout: time.Minute, Limit: 10}
+
+ tests := []struct {
+ name string
+ mutate func(o *Options)
+ wantErr bool
+ }{
+ {"valid", func(o *Options) {}, false},
+ {"mp3_128", func(o *Options) { o.Quality = "mp3_128" }, false},
+ {"flac", func(o *Options) { o.Quality = "flac" }, false},
+ {"invalid quality", func(o *Options) { o.Quality = "ogg" }, true},
+ {"uppercase quality", func(o *Options) { o.Quality = "MP3_320" }, true},
+ {"zero timeout", func(o *Options) { o.Timeout = 0 }, true},
+ {"negative timeout", func(o *Options) { o.Timeout = -time.Second }, true},
+ {"zero limit", func(o *Options) { o.Limit = 0 }, true},
+ {"limit too high", func(o *Options) { o.Limit = 101 }, true},
+ {"limit at max", func(o *Options) { o.Limit = 100 }, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ opts := valid
+ tt.mutate(&opts)
+
+ err := opts.Validate()
+ if (err != nil) != tt.wantErr {
+ t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/store/store_test.go b/internal/store/store_test.go
new file mode 100644
index 0000000..2817d1b
--- /dev/null
+++ b/internal/store/store_test.go
@@ -0,0 +1,63 @@
+package store
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestOpenPutGet(t *testing.T) {
+ dir := t.TempDir()
+
+ s, err := Open(dir)
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ defer s.Close()
+
+ if _, err := os.Stat(filepath.Join(dir, ".tracks.db")); err != nil {
+ t.Errorf("expected .tracks.db to exist: %v", err)
+ }
+
+ info := &DownloadInfo{
+ TrackID: "123",
+ Quality: "MP3_320",
+ Path: "/music/track.mp3",
+ Hash: "abc",
+ Downloaded: time.Now().Truncate(time.Second),
+ }
+ if err := s.PutDownloadInfo(info); err != nil {
+ t.Fatalf("PutDownloadInfo: %v", err)
+ }
+
+ got, err := s.DownloadInfo("123")
+ if err != nil {
+ t.Fatalf("DownloadInfo: %v", err)
+ }
+ if got.TrackID != info.TrackID || got.Quality != info.Quality || got.Path != info.Path || got.Hash != info.Hash {
+ t.Errorf("DownloadInfo() = %+v, want %+v", got, info)
+ }
+}
+
+func TestDownloadInfoNotFound(t *testing.T) {
+ s, err := Open(t.TempDir())
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ defer s.Close()
+
+ if _, err := s.DownloadInfo("missing"); err == nil {
+ t.Error("expected error for unknown track ID")
+ }
+}
+
+func TestClose(t *testing.T) {
+ s, err := Open(t.TempDir())
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ if err := s.Close(); err != nil {
+ t.Errorf("Close: %v", err)
+ }
+}