368 lines
9.7 KiB
Go
368 lines
9.7 KiB
Go
package cmd
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
)
|
|
|
|
// keyPress builds a synthetic key event matching what the runtime sends.
|
|
func keyPress(code rune, text string) tea.KeyPressMsg {
|
|
return tea.KeyPressMsg(tea.Key{Code: code, Text: text})
|
|
}
|
|
|
|
func TestMenuNavigation(t *testing.T) {
|
|
m := newSetupModel()
|
|
|
|
// Down twice from index 0.
|
|
m.handleKey(keyPress(tea.KeyDown, ""))
|
|
m.handleKey(keyPress(tea.KeyDown, ""))
|
|
if m.menuCursor != 2 {
|
|
t.Fatalf("menuCursor = %d, want 2", m.menuCursor)
|
|
}
|
|
|
|
// Up once.
|
|
m.handleKey(keyPress(tea.KeyUp, ""))
|
|
if m.menuCursor != 1 {
|
|
t.Fatalf("after up: menuCursor = %d, want 1", m.menuCursor)
|
|
}
|
|
|
|
// Down past the end clamps.
|
|
for i := 0; i < 99; i++ {
|
|
m.handleKey(keyPress(tea.KeyDown, ""))
|
|
}
|
|
if want := len(m.provs) - 1; m.menuCursor != want {
|
|
t.Fatalf("clamped menuCursor = %d, want %d", m.menuCursor, want)
|
|
}
|
|
}
|
|
|
|
// TestPickerSelectionFiltersFields verifies that picking the Jellyfin
|
|
// "API token" option hides the user/password fields and vice versa.
|
|
func TestPickerSelectionFiltersFields(t *testing.T) {
|
|
m := newSetupModel()
|
|
|
|
// Find Jellyfin's index.
|
|
jfIdx := -1
|
|
for i, p := range m.provs {
|
|
if p.section == "jellyfin" {
|
|
jfIdx = i
|
|
break
|
|
}
|
|
}
|
|
if jfIdx < 0 {
|
|
t.Fatal("jellyfin spec missing")
|
|
}
|
|
|
|
m.menuCursor = jfIdx
|
|
m.handleKey(keyPress(tea.KeyEnter, "")) // open picker
|
|
if m.stage != stagePicker {
|
|
t.Fatalf("stage = %v, want stagePicker", m.stage)
|
|
}
|
|
|
|
// Pick "API token" (option 0).
|
|
m.handleKey(keyPress(tea.KeyEnter, ""))
|
|
if m.stage != stageForm {
|
|
t.Fatalf("stage = %v, want stageForm", m.stage)
|
|
}
|
|
|
|
// Visible fields should be url + token, not user + password.
|
|
visibleKeys := map[string]bool{}
|
|
for _, idx := range m.visible {
|
|
visibleKeys[m.provs[jfIdx].fields[idx].key] = true
|
|
}
|
|
if !visibleKeys["url"] || !visibleKeys["token"] {
|
|
t.Fatalf("token mode missing url/token; got %v", visibleKeys)
|
|
}
|
|
if visibleKeys["user"] || visibleKeys["password"] {
|
|
t.Fatalf("token mode should hide user/password; got %v", visibleKeys)
|
|
}
|
|
|
|
// Switch back, pick password mode, verify the inverse.
|
|
m.stage = stagePicker
|
|
m.values = map[string]string{}
|
|
m.pickerCursor = 1
|
|
m.handleKey(keyPress(tea.KeyEnter, ""))
|
|
visibleKeys = map[string]bool{}
|
|
for _, idx := range m.visible {
|
|
visibleKeys[m.provs[jfIdx].fields[idx].key] = true
|
|
}
|
|
if !visibleKeys["user"] || !visibleKeys["password"] {
|
|
t.Fatalf("password mode missing user/password; got %v", visibleKeys)
|
|
}
|
|
if visibleKeys["token"] {
|
|
t.Fatalf("password mode should hide token; got %v", visibleKeys)
|
|
}
|
|
}
|
|
|
|
// TestEmbyPickerSelectionFiltersFields mirrors TestPickerSelectionFiltersFields
|
|
// for the Emby provider, which uses the same token/password picker shape.
|
|
func TestEmbyPickerSelectionFiltersFields(t *testing.T) {
|
|
m := newSetupModel()
|
|
|
|
embyIdx := -1
|
|
for i, p := range m.provs {
|
|
if p.section == "emby" {
|
|
embyIdx = i
|
|
break
|
|
}
|
|
}
|
|
if embyIdx < 0 {
|
|
t.Fatal("emby spec missing")
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
pickerCursor int
|
|
wantVisible []string
|
|
wantHidden []string
|
|
}{
|
|
{"API key", 0, []string{"url", "token", "user"}, []string{"password"}},
|
|
{"password", 1, []string{"url", "user", "password"}, []string{"token"}},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
m.menuCursor = embyIdx
|
|
m.stage = stageMenu
|
|
m.values = map[string]string{}
|
|
m.handleKey(keyPress(tea.KeyEnter, "")) // open picker
|
|
if m.stage != stagePicker {
|
|
t.Fatalf("stage = %v, want stagePicker", m.stage)
|
|
}
|
|
m.pickerCursor = tc.pickerCursor
|
|
m.handleKey(keyPress(tea.KeyEnter, "")) // select picker option
|
|
if m.stage != stageForm {
|
|
t.Fatalf("stage = %v, want stageForm", m.stage)
|
|
}
|
|
visible := map[string]bool{}
|
|
for _, idx := range m.visible {
|
|
visible[m.provs[embyIdx].fields[idx].key] = true
|
|
}
|
|
for _, k := range tc.wantVisible {
|
|
if !visible[k] {
|
|
t.Errorf("field %q not visible; got %v", k, visible)
|
|
}
|
|
}
|
|
for _, k := range tc.wantHidden {
|
|
if visible[k] {
|
|
t.Errorf("field %q should be hidden; got %v", k, visible)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRequiredFieldBlocksSubmit ensures pressing Enter on the last field
|
|
// without filling required values produces an error result rather than
|
|
// silently saving.
|
|
func TestRequiredFieldBlocksSubmit(t *testing.T) {
|
|
m := newSetupModel()
|
|
// Pick Navidrome.
|
|
for i, p := range m.provs {
|
|
if p.section == "navidrome" {
|
|
m.menuCursor = i
|
|
break
|
|
}
|
|
}
|
|
m.handleKey(keyPress(tea.KeyEnter, "")) // open form (no picker)
|
|
if m.stage != stageForm {
|
|
t.Fatalf("stage = %v, want stageForm", m.stage)
|
|
}
|
|
|
|
// Submit immediately with all fields blank.
|
|
m.submitForm()
|
|
if m.stage != stageResult {
|
|
t.Fatalf("stage = %v, want stageResult", m.stage)
|
|
}
|
|
if m.resultErr == nil || !strings.Contains(m.resultErr.Error(), "required") {
|
|
t.Fatalf("resultErr = %v, want a 'required' error", m.resultErr)
|
|
}
|
|
}
|
|
|
|
// TestPasteIntoActiveField checks that bracketed-paste content lands in
|
|
// the focused field, with newlines stripped (Spotify Client IDs sometimes
|
|
// arrive with a trailing newline from the source app).
|
|
func TestPasteIntoActiveField(t *testing.T) {
|
|
m := newSetupModel()
|
|
for i, p := range m.provs {
|
|
if p.section == "spotify" {
|
|
m.menuCursor = i
|
|
break
|
|
}
|
|
}
|
|
m.handleKey(keyPress(tea.KeyEnter, "")) // opens picker (custom is first, default cursor)
|
|
if m.stage != stagePicker {
|
|
t.Fatalf("stage = %v, want stagePicker", m.stage)
|
|
}
|
|
m.handleKey(keyPress(tea.KeyEnter, "")) // confirm "custom" → opens form
|
|
if m.stage != stageForm {
|
|
t.Fatalf("stage = %v, want stageForm after picker", m.stage)
|
|
}
|
|
|
|
m.handlePaste("abc123def\n")
|
|
if got := m.values["client_id"]; got != "abc123def" {
|
|
t.Fatalf("after paste: client_id = %q, want %q", got, "abc123def")
|
|
}
|
|
|
|
// A second paste appends.
|
|
m.handlePaste("XYZ")
|
|
if got := m.values["client_id"]; got != "abc123defXYZ" {
|
|
t.Fatalf("after second paste: client_id = %q", got)
|
|
}
|
|
|
|
// Pasting outside the form (e.g. on the menu) is a no-op.
|
|
m.stage = stageMenu
|
|
before := m.values["client_id"]
|
|
m.handlePaste("should not land")
|
|
if m.values["client_id"] != before {
|
|
t.Fatalf("paste leaked across stages: %q", m.values["client_id"])
|
|
}
|
|
}
|
|
|
|
func TestNetEaseSetupBody(t *testing.T) {
|
|
spec := providerSpec{}
|
|
for _, p := range providers() {
|
|
if p.section == "netease" {
|
|
spec = p
|
|
break
|
|
}
|
|
}
|
|
if spec.section == "" {
|
|
t.Fatal("netease spec missing")
|
|
}
|
|
body := spec.body(map[string]string{
|
|
keyNetEaseBrowser: "chrome",
|
|
"user_id": "42",
|
|
})
|
|
for _, want := range []string{
|
|
"enabled = true",
|
|
`cookies_from = "chrome"`,
|
|
`user_id = "42"`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Fatalf("body missing %q: %q", want, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestQobuzSetupBody(t *testing.T) {
|
|
spec := providerSpec{}
|
|
for _, p := range providers() {
|
|
if p.section == "qobuz" {
|
|
spec = p
|
|
break
|
|
}
|
|
}
|
|
if spec.section == "" {
|
|
t.Fatal("qobuz spec missing")
|
|
}
|
|
|
|
// Explicit quality selection.
|
|
body := spec.body(map[string]string{keyQobuzQuality: "27"})
|
|
for _, want := range []string{"enabled = true", "quality = 27"} {
|
|
if !strings.Contains(body, want) {
|
|
t.Fatalf("body missing %q: %q", want, body)
|
|
}
|
|
}
|
|
|
|
// Default quality when none picked.
|
|
if got := spec.body(map[string]string{}); !strings.Contains(got, "quality = 6") {
|
|
t.Fatalf("default quality not 6: %q", got)
|
|
}
|
|
|
|
// No live probe (auth happens interactively in the TUI).
|
|
if spec.validate != nil {
|
|
t.Fatal("qobuz spec should not define a validate probe")
|
|
}
|
|
}
|
|
|
|
func TestNetEasePickerSelectionFiltersFields(t *testing.T) {
|
|
base := newSetupModel()
|
|
neteaseIdx := -1
|
|
for i, p := range base.provs {
|
|
if p.section == "netease" {
|
|
neteaseIdx = i
|
|
break
|
|
}
|
|
}
|
|
if neteaseIdx < 0 {
|
|
t.Fatal("netease spec missing")
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
browser string
|
|
wantVisible int
|
|
wantKey string
|
|
}{
|
|
{"chrome hides cookies_from", "chrome", 0, ""},
|
|
{"custom shows cookies_from", "custom", 1, "cookies_from"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
m := newSetupModel()
|
|
m.pidx = neteaseIdx
|
|
m.values = map[string]string{keyNetEaseBrowser: tc.browser}
|
|
m.refreshVisibleFields()
|
|
if len(m.visible) != tc.wantVisible {
|
|
t.Fatalf("visible fields = %d, want %d", len(m.visible), tc.wantVisible)
|
|
}
|
|
if tc.wantVisible == 1 {
|
|
field := m.provs[neteaseIdx].fields[m.visible[0]]
|
|
if field.key != tc.wantKey {
|
|
t.Fatalf("field = %q, want %q", field.key, tc.wantKey)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSaveSection covers the three write paths: new file, append, replace.
|
|
func TestSaveSection(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("HOME", dir)
|
|
|
|
cfg := filepath.Join(dir, ".config", "cliamp", "config.toml")
|
|
|
|
// 1. New file.
|
|
if err := saveSection("plex", "url = \"http://x\"\ntoken = \"t\""); err != nil {
|
|
t.Fatalf("first save: %v", err)
|
|
}
|
|
got, err := os.ReadFile(cfg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.HasPrefix(string(got), "[plex]\n") {
|
|
t.Fatalf("new file: %q", got)
|
|
}
|
|
|
|
// 2. Append a new section.
|
|
if err := saveSection("ytmusic", "enabled = true"); err != nil {
|
|
t.Fatalf("append: %v", err)
|
|
}
|
|
got, _ = os.ReadFile(cfg)
|
|
if !strings.Contains(string(got), "[plex]") || !strings.Contains(string(got), "[ytmusic]") {
|
|
t.Fatalf("append: missing one of the sections: %q", got)
|
|
}
|
|
|
|
// 3. Replace the plex section in place.
|
|
if err := saveSection("plex", "url = \"http://NEW\"\ntoken = \"t2\""); err != nil {
|
|
t.Fatalf("replace: %v", err)
|
|
}
|
|
got, _ = os.ReadFile(cfg)
|
|
s := string(got)
|
|
if !strings.Contains(s, "http://NEW") {
|
|
t.Fatalf("replace did not write new value: %q", s)
|
|
}
|
|
if strings.Contains(s, "http://x") {
|
|
t.Fatalf("replace left old value: %q", s)
|
|
}
|
|
// Ytmusic must still be present.
|
|
if !strings.Contains(s, "[ytmusic]") {
|
|
t.Fatalf("replace clobbered ytmusic: %q", s)
|
|
}
|
|
}
|