Files
Esa Kataja 400b5d3833 Release 2026.08.01-1
Lyrics, versioning, and the song page's fact line.

- Lyrics are suggested at submission and never imposed: the worker makes one
  LRCLIB lookup, and Hae sanoitukset re-queries with whatever title and artist
  are typed. Neither overwrites what the submitter wrote. They live on the
  submission, travel to the song at publish, and stay editable after the song
  locks — the lock freezes what a song claims to be, and nobody reviewed the
  lyrics
- The review strip reads and writes side by side: lyrics left, review right.
  Synced LRC highlights the playing line and seeks on click; plain text scrolls
  continuously with a nudge knob. Following can be turned off
- CalVer YYYY.MM.DD-N, injected from the git tag with -ldflags, shown in the
  footer, the startup log and /healthz
- The song page's metadata became four labelled cells instead of one flat run
  of five different kinds of fact

Fixes: publishing wiped lyrics the worker had just fetched (a request that
omitted a field cleared it), lyric auto-scroll landed in the wrong place, and
the fader shifted the deck sideways at score 100.
2026-08-01 00:45:55 +03:00

113 lines
4.1 KiB
Go

package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// Line breaks are the content here — LRC timestamps are per line — so cleanLyrics must not do what
// clean() does to a title.
func TestCleanLyrics(t *testing.T) {
got := cleanLyrics(" [00:11.74] Rivi yksi\r\n[00:13.99] Rivi\x07 kaksi\r\n\n")
want := "[00:11.74] Rivi yksi\n[00:13.99] Rivi kaksi"
if got != want {
t.Fatalf("cleanLyrics gave %q, want %q", got, want)
}
if n := len([]rune(cleanLyrics(strings.Repeat("a", maxLyrics+500)))); n != maxLyrics {
t.Fatalf("truncated to %d runes, want %d", n, maxLyrics)
}
}
// Plain text must parse to nil: that is the signal to scroll continuously rather than highlight
// lines on timings nobody measured.
func TestParseLRC(t *testing.T) {
if got := parseLRC("Ihan tavallista tekstiä\ntoinen rivi"); got != nil {
t.Fatalf("plain text parsed as synced: %v", got)
}
lines := parseLRC("[00:11.74] Ensimmäinen\n[01:02] Toinen\n[00:05.5] Aikaisempi\nrivi ilman aikaa")
if len(lines) != 3 {
t.Fatalf("got %d lines, want 3 — untimed lines are dropped", len(lines))
}
// Sorted by time, whatever order the file had.
if lines[0].At != 5.5 || lines[0].Text != "Aikaisempi" {
t.Fatalf("first line is %+v, want 5.5s Aikaisempi", lines[0])
}
if lines[1].At != 11.74 || lines[2].At != 62 {
t.Fatalf("timestamps parsed as %v and %v, want 11.74 and 62", lines[1].At, lines[2].At)
}
// A refrain can carry several timestamps on one line, and each is its own occurrence.
rep := parseLRC("[00:10.00][01:10.00] Kertosäe")
if len(rep) != 2 || rep[0].At != 10 || rep[1].At != 70 {
t.Fatalf("repeated stamps gave %+v, want two occurrences", rep)
}
}
// The lookup is a suggestion, so "nothing found" is a normal answer rather than an error, and a
// synced hit always beats a plain one.
func TestFetchLyrics(t *testing.T) {
var lastPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
lastPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/get" && r.URL.Query().Get("track_name") == "Paranoid":
w.Write([]byte(`{"trackName":"Paranoid","artistName":"Black Sabbath","duration":168,
"plainLyrics":"plain version","syncedLyrics":"[00:11.74] synced version"}`))
case r.URL.Path == "/get":
http.Error(w, `{"code":404}`, http.StatusNotFound)
case r.URL.Path == "/search" && strings.Contains(r.URL.Query().Get("q"), "Soittorasia"):
// An instrumental and a wrong-length take come first: both must be skipped.
w.Write([]byte(`[{"trackName":"Soittorasia","duration":200,"instrumental":true,
"plainLyrics":"","syncedLyrics":"[00:01.00] should be skipped"},
{"trackName":"Soittorasia","duration":600,
"plainLyrics":"wrong length take"},
{"trackName":"Soittorasia","duration":201,
"plainLyrics":"right one"}]`))
default:
w.Write([]byte(`[]`))
}
}))
defer srv.Close()
old := lrclibBase
lrclibBase = srv.URL
defer func() { lrclibBase = old }()
ctx := context.Background()
got, err := fetchLyrics(ctx, "Paranoid", "Black Sabbath", 168)
if err != nil {
t.Fatal(err)
}
if got != "[00:11.74] synced version" {
t.Fatalf("exact match returned %q, want the synced version", got)
}
got, err = fetchLyrics(ctx, "Soittorasia", "Joku", 200)
if err != nil {
t.Fatal(err)
}
if got != "right one" {
t.Fatalf("search fallback returned %q — instrumental and wrong-length takes must be skipped", got)
}
if lastPath != "/search" {
t.Fatalf("last request was %s, want the search fallback", lastPath)
}
// Nothing found is not an error: the submitter simply types their own.
got, err = fetchLyrics(ctx, "Ei olemassa", "Kukaan", 100)
if err != nil || got != "" {
t.Fatalf("miss returned %q, %v — want empty and no error", got, err)
}
// No title means nothing to match on, and no request at all.
if got, err := fetchLyrics(ctx, "", "Artisti", 100); err != nil || got != "" {
t.Fatalf("empty title returned %q, %v", got, err)
}
}