package uoscconf import ( "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // sampleConf is a representative excerpt of the real default uosc.conf // shipped by uOSC 5.12.0 (github.com/tomasklaen/uosc). const sampleConf = `# Display style of current position. available: line, bar timeline_style=line # Timeline size when fully expanded, in pixels, 0 to disable timeline_size=40 # Render cache indicators for streaming content timeline_cache=yes # Where to display volume controls: none, left, right volume=right volume_size=40 # Playback speed widget: mouse drag or wheel to change, click to reset speed_step=0.1 speed_step_is_factor=no # Scale the interface by this factor scale=1 # Scale in fullscreen scale_fullscreen=1.3 # Top bar with window controls and media title # Can be: never, no-border, always top_bar=no-border ` // writeSample writes sampleConf to a temp file and returns its path. func writeSample(t *testing.T) string { t.Helper() path := filepath.Join(t.TempDir(), "uosc.conf") if err := os.WriteFile(path, []byte(sampleConf), 0o644); err != nil { t.Fatalf("failed to write sample conf: %v", err) } return path } func TestParseUOSCConf(t *testing.T) { conf, err := ParseUOSCConf(writeSample(t)) if err != nil { t.Fatalf("ParseUOSCConf failed: %v", err) } // Explicitly set values if !conf.IsSet("scale_fullscreen") { t.Error("expected scale_fullscreen to be set") } if got := conf.GetValue("scale_fullscreen"); got != "1.3" { t.Errorf("GetValue(scale_fullscreen) = %q, want %q", got, "1.3") } if got := conf.GetValue("timeline_size"); got != "40" { t.Errorf("GetValue(timeline_size) = %q, want %q", got, "40") } if got := conf.GetValue("top_bar"); got != "no-border" { t.Errorf("GetValue(top_bar) = %q, want %q", got, "no-border") } // Unset known option falls back to its default if conf.IsSet("shuffle") { t.Error("expected shuffle to not be set") } if got := conf.GetValue("shuffle"); got != "no" { t.Errorf("GetValue(shuffle) = %q, want default %q", got, "no") } // Unknown option if got := conf.GetValue("not_a_real_option"); got != "" { t.Errorf("GetValue(unknown) = %q, want %q", got, "") } } func TestParseUOSCConfMissingFile(t *testing.T) { path := filepath.Join(t.TempDir(), "script-opts", "uosc.conf") conf, err := ParseUOSCConf(path) if err != nil { t.Fatalf("missing file should not be an error, got: %v", err) } // Populated from defaults if got := conf.GetValue("timeline_style"); got != "line" { t.Errorf("GetValue(timeline_style) = %q, want default %q", got, "line") } if conf.IsSet("timeline_style") { t.Error("expected timeline_style to not be set") } // Set + Write must create the missing parent directory if err := conf.SetValue("volume", "left"); err != nil { t.Fatalf("SetValue failed: %v", err) } if err := conf.Write(); err != nil { t.Fatalf("Write failed: %v", err) } reloaded, err := ParseUOSCConf(path) if err != nil { t.Fatalf("re-parse failed: %v", err) } if got := reloaded.GetValue("volume"); got != "left" { t.Errorf("GetValue(volume) = %q, want %q", got, "left") } } func TestSetResetWriteRoundTrip(t *testing.T) { path := writeSample(t) conf, err := ParseUOSCConf(path) if err != nil { t.Fatalf("ParseUOSCConf failed: %v", err) } // Update an existing option and add a previously unset one if err := conf.SetValue("scale_fullscreen", "1.5"); err != nil { t.Fatalf("SetValue(scale_fullscreen) failed: %v", err) } if err := conf.SetValue("shuffle", "yes"); err != nil { t.Fatalf("SetValue(shuffle) failed: %v", err) } if err := conf.Write(); err != nil { t.Fatalf("Write failed: %v", err) } reloaded, err := ParseUOSCConf(path) if err != nil { t.Fatalf("re-parse failed: %v", err) } if got := reloaded.GetValue("scale_fullscreen"); got != "1.5" { t.Errorf("GetValue(scale_fullscreen) = %q, want %q", got, "1.5") } if got := reloaded.GetValue("shuffle"); got != "yes" { t.Errorf("GetValue(shuffle) = %q, want %q", got, "yes") } if !reloaded.IsSet("shuffle") { t.Error("expected shuffle to be set after write") } // Reset reverts to the default and drops the explicit line if err := reloaded.ResetValue("shuffle"); err != nil { t.Fatalf("ResetValue failed: %v", err) } if err := reloaded.Write(); err != nil { t.Fatalf("second Write failed: %v", err) } final, err := ParseUOSCConf(path) if err != nil { t.Fatalf("final parse failed: %v", err) } if final.IsSet("shuffle") { t.Error("expected shuffle to be unset after reset") } if got := final.GetValue("shuffle"); got != "no" { t.Errorf("GetValue(shuffle) = %q, want default %q", got, "no") } if got := final.GetValue("scale_fullscreen"); got != "1.5" { t.Errorf("GetValue(scale_fullscreen) = %q, want %q", got, "1.5") } } func TestSetValueRejectsInvalid(t *testing.T) { conf, err := ParseUOSCConf(writeSample(t)) if err != nil { t.Fatalf("ParseUOSCConf failed: %v", err) } if err := conf.SetValue("scale_fullscreen", "99"); err == nil { t.Error("expected out-of-range SetValue to fail") } if err := conf.SetValue("bogus_key", "x"); err == nil { t.Error("expected unknown-key SetValue to fail") } if got := conf.GetValue("scale_fullscreen"); got != "1.3" { t.Errorf("failed SetValue mutated conf: GetValue = %q, want %q", got, "1.3") } if err := conf.ResetValue("bogus_key"); err == nil { t.Error("expected unknown-key ResetValue to fail") } } func TestCommentAndStructurePreservation(t *testing.T) { path := writeSample(t) conf, err := ParseUOSCConf(path) if err != nil { t.Fatalf("ParseUOSCConf failed: %v", err) } if err := conf.SetValue("timeline_size", "60"); err != nil { t.Fatalf("SetValue failed: %v", err) } if err := conf.Write(); err != nil { t.Fatalf("Write failed: %v", err) } data, err := os.ReadFile(path) if err != nil { t.Fatalf("failed to read written conf: %v", err) } content := string(data) // Comments and untouched lines survive verbatim for _, want := range []string{ "# Display style of current position. available: line, bar", "# Timeline size when fully expanded, in pixels, 0 to disable", "# Can be: never, no-border, always", "timeline_style=line", "volume_size=40", "speed_step_is_factor=no", "scale_fullscreen=1.3", } { if !strings.Contains(content, want) { t.Errorf("written conf lost line %q", want) } } // The edited line is replaced, nothing else changes if !strings.Contains(content, "timeline_size=60") { t.Error("written conf missing updated timeline_size=60") } if strings.Contains(content, "timeline_size=40") { t.Error("written conf still contains old timeline_size=40") } if got, want := len(strings.Split(content, "\n")), len(strings.Split(sampleConf, "\n")); got != want { t.Errorf("line count changed: got %d, want %d", got, want) } } func TestBackupCreatedOnSecondWrite(t *testing.T) { path := writeSample(t) conf, err := ParseUOSCConf(path) if err != nil { t.Fatalf("ParseUOSCConf failed: %v", err) } if err := conf.SetValue("timeline_size", "60"); err != nil { t.Fatalf("SetValue failed: %v", err) } if err := conf.Write(); err != nil { t.Fatalf("first Write failed: %v", err) } if err := conf.SetValue("timeline_size", "80"); err != nil { t.Fatalf("SetValue failed: %v", err) } if err := conf.Write(); err != nil { t.Fatalf("second Write failed: %v", err) } backups, err := filepath.Glob(path + "-uosc.conf.bak" + ".*") if err != nil { t.Fatalf("backup glob failed: %v", err) } if len(backups) != 2 { t.Fatalf("expected every rapid write to preserve a distinct backup, got %d", len(backups)) } priorValues := map[string]bool{"timeline_size=40": false, "timeline_size=60": false} for _, backupPath := range backups { backup, err := os.ReadFile(backupPath) if err != nil { t.Fatalf("failed to read backup: %v", err) } backupContent := string(backup) for value := range priorValues { if strings.Contains(backupContent, value) { priorValues[value] = true } } if strings.Contains(backupContent, "timeline_size=80") { t.Error("backup contains the final content instead of a prior state") } } for value, found := range priorValues { if !found { t.Errorf("distinct backups did not preserve %s", value) } } } func TestValidateValue(t *testing.T) { tests := []struct { name string key string value string wantErr bool }{ {"bool yes", "shuffle", "yes", false}, {"bool no", "shuffle", "no", false}, {"bool true rejected", "shuffle", "true", true}, {"bool 1 rejected", "shuffle", "1", true}, {"int valid", "timeline_size", "40", false}, {"int min bound ok", "timeline_size", "0", false}, {"int below min", "timeline_size", "-1", true}, {"int above max", "timeline_size", "201", true}, {"int not a number", "timeline_size", "abc", true}, {"int float rejected", "timeline_size", "1.5", true}, {"float valid", "scale_fullscreen", "2.5", false}, {"float default ok", "scale", "1", false}, {"float below min", "scale_fullscreen", "0.05", true}, {"float above max", "scale_fullscreen", "5.1", true}, {"float not a number", "scale_fullscreen", "big", true}, {"float nan rejected", "scale_fullscreen", "NaN", true}, {"choice valid", "timeline_style", "bar", false}, {"choice invalid", "timeline_style", "weird", true}, {"choice case sensitive", "top_bar", "Always", true}, {"string anything", "color", "foreground=ffffff", false}, {"string empty ok", "opacity", "", false}, {"string exact seek suffix", "timeline_step", "5!", false}, {"unknown key", "definitely_not_real", "x", true}, {"newline injection", "color", "a=fff\nhacked=1", true}, {"crlf injection", "opacity", "timeline=1\r\nhacked=1", true}, {"newline in unknown key value", "definitely_not_real", "a\nb", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := ValidateValue(tt.key, tt.value) if tt.wantErr && err == nil { t.Errorf("ValidateValue(%q, %q) = nil, want error", tt.key, tt.value) } if !tt.wantErr && err != nil { t.Errorf("ValidateValue(%q, %q) = %v, want nil", tt.key, tt.value, err) } }) } } func TestKnownOptionsDefaultsValid(t *testing.T) { opts := KnownOptions() if len(opts) == 0 { t.Fatal("KnownOptions returned no options") } for _, opt := range opts { if opt.Key == "" || opt.Description == "" { t.Errorf("option %+v missing key or description", opt) } if err := ValidateValue(opt.Key, opt.Default); err != nil { t.Errorf("default %q for %q fails validation: %v", opt.Default, opt.Key, err) } if opt.ValueType == TypeChoice && len(opt.Choices) == 0 { t.Errorf("choice option %q has no choices", opt.Key) } } } func TestKnownOptionsReturnsDeepCopy(t *testing.T) { first := KnownOptions() require.NotEmpty(t, first) require.NotEmpty(t, first[0].Choices) original := first[0].Choices[0] first[0].Choices[0] = "mutated" second := KnownOptions() assert.Equal(t, original, second[0].Choices[0]) } func TestGetUOSCConfPath(t *testing.T) { path, err := GetUOSCConfPath() if err != nil { t.Skipf("home directory not available: %v", err) } wantSuffix := filepath.Join("script-opts", "uosc.conf") if !strings.HasSuffix(path, wantSuffix) { t.Errorf("GetUOSCConfPath() = %q, want suffix %q", path, wantSuffix) } }