Files
david 568691542a feat: Sample Tag Viewer — read-only tag browser for AKAI disk images
Backend:
- extract.go: listTags() navigates to volume, runs akaiutil lstags
- extract.go: parseTags() parses tag name/index from lstags output
- serve.go: GET /api/disk/partitions — list partitions on an ISO
- serve.go: GET /api/disk/volumes — list volumes on a partition
- serve.go: GET /api/volume/tags — list tags on a volume

Web UI:
- Tag Viewer section with ISO path input and Browse button
- Partition selector buttons
- Volume grid with click-to-select
- Tag chips with color-coded dots (20-color palette)

Tests:
- 6 parseTags tests (basic, spaces, defaults, no tags, empty, no-type)
- 1 listTags integration test
- 4 API endpoint tests (partitions, missing-ISO, missing-params, tags)

Karpathy: read-only view first — no write operations yet (settagi, clrtagi)
2026-06-22 03:21:54 -07:00

636 lines
16 KiB
Go

package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestWriteJSON(t *testing.T) {
t.Run("sets correct headers", func(t *testing.T) {
rr := httptest.NewRecorder()
writeJSON(rr, map[string]string{"key": "value"})
if ct := rr.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("expected application/json, got %q", ct)
}
if ac := rr.Header().Get("Access-Control-Allow-Origin"); ac != "*" {
t.Errorf("expected *, got %q", ac)
}
})
t.Run("writes valid JSON", func(t *testing.T) {
rr := httptest.NewRecorder()
data := map[string]int{"count": 42, "total": 100}
writeJSON(rr, data)
var result map[string]int
if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if result["count"] != 42 {
t.Errorf("expected count=42, got %d", result["count"])
}
})
t.Run("writes nested structs", func(t *testing.T) {
rr := httptest.NewRecorder()
type inner struct {
Name string `json:"name"`
}
writeJSON(rr, map[string]any{"item": inner{Name: "test"}})
var result map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
item := result["item"].(map[string]any)
if item["name"] != "test" {
t.Errorf("expected name=test, got %v", item["name"])
}
})
}
func TestHandleProgress_SSEHeaders(t *testing.T) {
downloadJobsMu.Lock()
downloadJobs = make(map[string]*downloadState)
downloadJobs["test-job"] = &downloadState{
ID: "test-job",
Identifier: "testid",
FileName: "test.iso",
TotalSize: 1000,
Downloaded: 500,
Done: false,
}
downloadJobsMu.Unlock()
req := httptest.NewRequest("GET", "/api/progress", nil)
rr := httptest.NewRecorder()
done := make(chan string)
go func() {
handleProgress(rr, req)
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
}
if ct := rr.Header().Get("Content-Type"); ct != "text/event-stream" {
t.Errorf("expected text/event-stream, got %q", ct)
}
if cc := rr.Header().Get("Cache-Control"); cc != "no-cache" {
t.Errorf("expected no-cache, got %q", cc)
}
if conn := rr.Header().Get("Connection"); conn != "keep-alive" {
t.Errorf("expected keep-alive, got %q", conn)
}
}
func TestHandleProgress_SSEContent(t *testing.T) {
downloadJobsMu.Lock()
downloadJobs = make(map[string]*downloadState)
downloadJobs["job1"] = &downloadState{
ID: "job1",
Identifier: "item1",
FileName: "file1.iso",
TotalSize: 1000,
Downloaded: 1000,
Done: true,
}
downloadJobsMu.Unlock()
req := httptest.NewRequest("GET", "/api/progress", nil)
rr := httptest.NewRecorder()
done := make(chan struct{})
go func() {
handleProgress(rr, req)
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
}
body := rr.Body.String()
if body == "" {
t.Error("expected non-empty SSE body")
}
body = strings.TrimPrefix(body, "data: ")
body = strings.TrimSuffix(strings.TrimSpace(body), "\n\n")
var states map[string]*downloadState
if err := json.Unmarshal([]byte(body), &states); err != nil {
t.Fatalf("invalid JSON in SSE data: %v\nbody: %q", err, body)
}
if len(states) != 1 {
t.Errorf("expected 1 state, got %d", len(states))
}
}
func TestHandleProgress_EmptyJobs(t *testing.T) {
downloadJobsMu.Lock()
downloadJobs = make(map[string]*downloadState)
downloadJobsMu.Unlock()
req := httptest.NewRequest("GET", "/api/progress", nil)
rr := httptest.NewRecorder()
done := make(chan struct{})
go func() {
handleProgress(rr, req)
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
}
body := rr.Body.String()
if body == "" {
t.Error("expected non-empty SSE body even with no jobs")
}
}
func TestHandleSearch_DelegatesToSearchArchive(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
if q != "(drums) AND (vocal)" {
t.Errorf("unexpected query: %q", q)
}
resp := SearchResult{Response: struct {
NumFound int `json:"numFound"`
Docs []SearchDoc `json:"docs"`
}{
NumFound: 1, Docs: []SearchDoc{
{Identifier: "test-id", Title: "Test", Downloads: 50},
},
}}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
return
}
}))
defer server.Close()
origSearch := iaSearchURL
iaSearchURL = server.URL + "/advancedsearch.php"
defer func() { iaSearchURL = origSearch }()
req := httptest.NewRequest("GET", "/api/search?q=drums&filter=vocal", nil)
rr := httptest.NewRecorder()
handleSearch(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
}
func TestHandleList_DelegatesToGetItemFiles(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/metadata/testitem" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
resp := MetadataResult{Files: []MetadataFile{
{Name: "test.iso", Size: "1000000"},
}}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
return
}
}))
defer server.Close()
origBase := iaBaseURL
iaBaseURL = server.URL
defer func() { iaBaseURL = origBase }()
req := httptest.NewRequest("GET", "/api/list?identifier=testitem", nil)
rr := httptest.NewRecorder()
handleList(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
}
func TestHandleList_MissingIdentifier(t *testing.T) {
req := httptest.NewRequest("GET", "/api/list", nil)
rr := httptest.NewRecorder()
handleList(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", rr.Code)
}
}
func TestHandleAPIDownload_CreatesJobsAndReturnsJSON(t *testing.T) {
origDownloadFile := downloadFile
defer func() { downloadFile = origDownloadFile }()
downloadFile = func(job downloadJob) (int64, error) {
downloadJobsMu.Lock()
if s, ok := downloadJobs[job.Identifier+"/"+job.Name]; ok {
s.Done = true
s.Downloaded = job.Size
}
downloadJobsMu.Unlock()
return job.Size, nil
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := MetadataResult{Files: []MetadataFile{
{Name: "test1.iso", Size: "1000"},
{Name: "test2.iso", Size: "2000"},
}}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
return
}
}))
defer server.Close()
origBase := iaBaseURL
iaBaseURL = server.URL
defer func() { iaBaseURL = origBase }()
tmp := t.TempDir()
req := httptest.NewRequest("GET", "/api/download?identifier=testitem&dir="+tmp, nil)
rr := httptest.NewRecorder()
handleAPIDownload(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON response: %v", err)
}
jobs, ok := resp["jobs"].([]any)
if !ok || len(jobs) != 2 {
t.Errorf("expected 2 jobs, got %v", resp["jobs"])
}
downloadJobsMu.Lock()
defer downloadJobsMu.Unlock()
if len(downloadJobs) != 2 {
t.Errorf("expected 2 jobs in state map, got %d", len(downloadJobs))
}
}
func TestHandleAPIDownload_MissingIdentifier(t *testing.T) {
req := httptest.NewRequest("GET", "/api/download", nil)
rr := httptest.NewRecorder()
handleAPIDownload(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", rr.Code)
}
}
func TestHandleAPIExtract_NoISOs(t *testing.T) {
origExtractISO := extractISO
defer func() { extractISO = origExtractISO }()
extractISO = func(isoPath, outDir string) (string, error) {
return "skipped (0 WAVs)", nil
}
tmp := t.TempDir()
isoDir := filepath.Join(tmp, "isos")
if err := os.MkdirAll(isoDir, 0755); err != nil {
t.Fatal(err)
}
fd, _ := os.Create(filepath.Join(isoDir, "empty.iso"))
defer func() { _ = fd.Close() }()
req := httptest.NewRequest("GET", "/api/extract?dir="+isoDir+"&out="+filepath.Join(tmp, "wavs"), nil)
rr := httptest.NewRecorder()
handleAPIExtract(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if _, ok := resp["error"]; ok {
t.Errorf("unexpected error: %v", resp["error"])
}
}
func TestHandleAPIExtract_WithISOs(t *testing.T) {
origExtractISO := extractISO
defer func() { extractISO = origExtractISO }()
extractISO = func(isoPath, outDir string) (string, error) {
return "OK test.iso: Total WAV files: 10 (5s)", nil
}
tmp := t.TempDir()
isoDir := filepath.Join(tmp, "isos")
if err := os.MkdirAll(isoDir, 0755); err != nil {
t.Fatal(err)
}
fd, _ := os.Create(filepath.Join(isoDir, "test.iso"))
defer func() { _ = fd.Close() }()
outDir := filepath.Join(tmp, "wavs")
req := httptest.NewRequest("GET", "/api/extract?dir="+isoDir+"&out="+outDir, nil)
rr := httptest.NewRecorder()
handleAPIExtract(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
}
func TestHandleAPIDownload_GetItemFilesReturnsEmpty(t *testing.T) {
origGet := getItemFiles
defer func() { getItemFiles = origGet }()
getItemFiles = func(id string) []MetadataFile {
return []MetadataFile{}
}
tmp := t.TempDir()
req := httptest.NewRequest("GET", "/api/download?identifier=testitem&dir="+tmp, nil)
rr := httptest.NewRecorder()
handleAPIDownload(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var resp map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if _, ok := resp["error"]; !ok {
t.Error("expected error for empty files")
}
if resp["error"] != "no ISO files found" {
t.Errorf("expected 'no ISO files found', got %v", resp["error"])
}
}
func TestHandleAPIExtract_ReadDirFails(t *testing.T) {
tmp := t.TempDir()
badDir := filepath.Join(tmp, "nonexistent")
req := httptest.NewRequest("GET", "/api/extract?dir="+badDir+"&out="+filepath.Join(tmp, "wavs"), nil)
rr := httptest.NewRecorder()
handleAPIExtract(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var resp map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if _, ok := resp["error"]; !ok {
t.Error("expected error for failed ReadDir")
}
}
func TestHandleAPIExtract_ExtractISOError(t *testing.T) {
origExtractISO := extractISO
defer func() { extractISO = origExtractISO }()
extractISO = func(isoPath, outDir string) (string, error) {
return "", fmt.Errorf("partition read failed")
}
tmp := t.TempDir()
isoDir := filepath.Join(tmp, "isos")
if err := os.MkdirAll(isoDir, 0755); err != nil {
t.Fatal(err)
}
fd, _ := os.Create(filepath.Join(isoDir, "bad.iso"))
defer func() { _ = fd.Close() }()
req := httptest.NewRequest("GET", "/api/extract?dir="+isoDir+"&out="+filepath.Join(tmp, "wavs"), nil)
rr := httptest.NewRecorder()
handleAPIExtract(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var resp map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
msg, ok := resp["message"].(string)
if !ok {
t.Fatal("expected message string")
}
if !strings.Contains(msg, "FAIL bad.iso") {
t.Errorf("expected FAIL message, got: %s", msg)
}
}
func TestHandleListWavs_ReturnsWavFiles(t *testing.T) {
tmp := t.TempDir()
wavDir := filepath.Join(tmp, "wavs")
if err := os.MkdirAll(wavDir, 0755); err != nil {
t.Fatal(err)
}
for _, name := range []string{"kick.wav", "snare.wav", "hihat.wav"} {
fd, _ := os.Create(filepath.Join(wavDir, name))
_ = fd.Close()
}
fd, _ := os.Create(filepath.Join(wavDir, "readme.txt"))
_ = fd.Close()
req := httptest.NewRequest("GET", "/api/list-wavs?dir="+wavDir, nil)
rr := httptest.NewRecorder()
handleListWavs(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var files []string
if err := json.Unmarshal(rr.Body.Bytes(), &files); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(files) != 3 {
t.Errorf("expected 3 wav files, got %d: %v", len(files), files)
}
}
func TestHandleListWavs_RecursiveSubdirs(t *testing.T) {
tmp := t.TempDir()
wavDir := filepath.Join(tmp, "wavs")
subDir := filepath.Join(wavDir, "Kicks")
if err := os.MkdirAll(subDir, 0755); err != nil {
t.Fatal(err)
}
fd, _ := os.Create(filepath.Join(wavDir, "root.wav"))
_ = fd.Close()
fd2, _ := os.Create(filepath.Join(subDir, "kick1.wav"))
_ = fd2.Close()
req := httptest.NewRequest("GET", "/api/list-wavs?dir="+wavDir, nil)
rr := httptest.NewRecorder()
handleListWavs(rr, req)
var files []string
if err := json.Unmarshal(rr.Body.Bytes(), &files); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(files) != 2 {
t.Errorf("expected 2 wav files, got %d: %v", len(files), files)
}
found := make(map[string]bool)
for _, f := range files {
found[f] = true
}
if !found["root.wav"] {
t.Errorf("missing root.wav, got %v", files)
}
if !found[filepath.Join("Kicks", "kick1.wav")] {
t.Errorf("missing Kicks/kick1.wav, got %v", files)
}
}
func TestHandleListWavs_EmptyDir(t *testing.T) {
tmp := t.TempDir()
req := httptest.NewRequest("GET", "/api/list-wavs?dir="+tmp, nil)
rr := httptest.NewRecorder()
handleListWavs(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var files []string
if err := json.Unmarshal(rr.Body.Bytes(), &files); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if files == nil {
t.Fatal("expected empty array [], got null")
}
if len(files) != 0 {
t.Errorf("expected 0 files, got %d", len(files))
}
}
func TestHandleListWavs_NonExistentDir(t *testing.T) {
req := httptest.NewRequest("GET", "/api/list-wavs?dir=/nonexistent/wavs", nil)
rr := httptest.NewRecorder()
handleListWavs(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var resp map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if _, ok := resp["error"]; !ok {
t.Error("expected error for nonexistent dir")
}
}
func TestHandleDiskPartitions_ReturnsPartitions(t *testing.T) {
origRun := runAkaiutil
defer func() { runAkaiutil = origRun }()
runAkaiutil = func(isoPath, commands string) (string, error) {
return `
Disk /disk0
Total blocks: 0 (0 KB each) 0 KB
Partition table:
A - S1000 HD (0 blocks)
B - S1000 HD (0 blocks)
`, nil
}
req := httptest.NewRequest("GET", "/api/disk/partitions?iso=test.iso", nil)
rr := httptest.NewRecorder()
handleDiskPartitions(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var parts []string
if err := json.Unmarshal(rr.Body.Bytes(), &parts); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(parts) != 2 {
t.Errorf("expected 2 partitions, got %d: %v", len(parts), parts)
}
}
func TestHandleDiskPartitions_MissingISO(t *testing.T) {
req := httptest.NewRequest("GET", "/api/disk/partitions", nil)
rr := httptest.NewRecorder()
handleDiskPartitions(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var resp map[string]any
_ = json.Unmarshal(rr.Body.Bytes(), &resp)
if _, ok := resp["error"]; !ok {
t.Error("expected error for missing iso")
}
}
func TestHandleVolumeTags_MissingParams(t *testing.T) {
req := httptest.NewRequest("GET", "/api/volume/tags", nil)
rr := httptest.NewRecorder()
handleVolumeTags(rr, req)
var resp map[string]any
_ = json.Unmarshal(rr.Body.Bytes(), &resp)
if resp["error"] == nil {
t.Error("expected error for missing params")
}
}
func TestHandleVolumeTags_ReturnsTags(t *testing.T) {
origRun := runAkaiutil
defer func() { runAkaiutil = origRun }()
runAkaiutil = func(isoPath, commands string) (string, error) {
return `
/disk0/A/SomeVol > lstags
tag name
-----------------
01 Drums
02 Bass
-----------------
`, nil
}
req := httptest.NewRequest("GET", "/api/volume/tags?iso=test.iso&part=A&vol=SomeVol", nil)
rr := httptest.NewRecorder()
handleVolumeTags(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var tags []TagInfo
if err := json.Unmarshal(rr.Body.Bytes(), &tags); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(tags) != 2 {
t.Errorf("expected 2 tags, got %d", len(tags))
}
}