git/orchard/main
tui/internal/config/config_test.go
213 lines5 KB last change raw · history
package config

import (
	"encoding/json"
	"os"
	"path/filepath"
	"testing"
)

func tempConfig(t *testing.T) (*Config, string) {
	t.Helper()
	dir := t.TempDir()
	os.Setenv("XDG_CONFIG_HOME", dir)
	t.Cleanup(func() { os.Unsetenv("XDG_CONFIG_HOME") })
	c := Load()
	// use the path the package actually resolves rather than guessing the layout
	return c, c.PathOf()
}

func TestIgnoredRoundTrips(t *testing.T) {
	c, path := tempConfig(t)
	if c.IsIgnored("linux") {
		t.Fatal("nothing should be ignored initially")
	}
	if !c.Toggle("linux") {
		t.Error("Toggle should report the new state as ignored")
	}
	if !c.Toggle("mesa") {
		t.Error("Toggle should add a second package")
	}
	if c.Toggle("linux") {
		t.Error("toggling twice should unignore")
	}
	if !c.Toggle("linux") {
		t.Error("and back again")
	}
	if err := c.Save(); err != nil {
		t.Fatalf("save: %v", err)
	}
	if _, err := os.Stat(path); err != nil {
		t.Fatalf("config not written to the resolved path %s: %v", path, err)
	}
	if filepath.Base(filepath.Dir(path)) != "updater" {
		t.Errorf("config should live under an app-named directory, got %s", path)
	}

	again := Load()
	if !again.IsIgnored("linux") || !again.IsIgnored("mesa") {
		t.Errorf("ignore list did not survive a round trip: %v", again.Ignored)
	}
	if again.Ignored[0] != "linux" {
		t.Errorf("ignore list should stay sorted: %v", again.Ignored)
	}
}

func TestUnknownKeysArePreserved(t *testing.T) {
	// the Python side owns the schema; saving here must not delete its fields
	dir := t.TempDir()
	os.Setenv("XDG_CONFIG_HOME", dir)
	defer os.Unsetenv("XDG_CONFIG_HOME")
	path := filepath.Join(dir, "updater", "config.json")
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		t.Fatal(err)
	}
	os.WriteFile(path, []byte(`{"color_accent":"13","something_python_added":{"a":1}}`), 0o644)

	c := Load()
	if c.AccentSlot() != "13" {
		t.Errorf("AccentSlot = %q, want 13", c.AccentSlot())
	}
	c.Toggle("vim")
	if err := c.Save(); err != nil {
		t.Fatalf("save: %v", err)
	}

	body, _ := os.ReadFile(path)
	var raw map[string]any
	if err := json.Unmarshal(body, &raw); err != nil {
		t.Fatalf("config is no longer valid json: %v", err)
	}
	if raw["color_accent"] != "13" {
		t.Errorf("color_accent was clobbered: %v", raw["color_accent"])
	}
	if _, ok := raw["something_python_added"]; !ok {
		t.Error("an unrecognised key was dropped on save")
	}
}

func TestCorruptConfigFallsBackToDefaults(t *testing.T) {
	dir := t.TempDir()
	os.Setenv("XDG_CONFIG_HOME", dir)
	defer os.Unsetenv("XDG_CONFIG_HOME")
	path := filepath.Join(dir, "updater", "config.json")
	os.MkdirAll(filepath.Dir(path), 0o755)
	os.WriteFile(path, []byte("{ not json"), 0o644)

	c := Load()
	if len(c.Ignored) != 0 {
		t.Errorf("expected an empty ignore list, got %v", c.Ignored)
	}
	if c.AccentSlot() != "10" {
		t.Errorf("expected the default accent, got %q", c.AccentSlot())
	}
}

func TestDefaultsAreSane(t *testing.T) {
	c, _ := tempConfig(t)
	if c.CursorIcon() == "" {
		t.Error("cursor icon should never be empty")
	}
	if c.ExpandThreshold() < 1 {
		t.Errorf("expand threshold = %d", c.ExpandThreshold())
	}
	if c.HideHints() {
		t.Error("hints should be on by default")
	}
}

func TestSaveIsAtomic(t *testing.T) {
	c, path := tempConfig(t)
	c.Toggle("x")
	if err := c.Save(); err != nil {
		t.Fatal(err)
	}
	if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
		t.Error("a temp file was left behind")
	}
}

func TestDirtyTracksChanges(t *testing.T) {
	c, _ := tempConfig(t)
	if c.Dirty() {
		t.Error("a freshly loaded config is not dirty")
	}
	c.Toggle("a")
	if !c.Dirty() {
		t.Error("toggling should mark it dirty")
	}
	c.Save()
	if c.Dirty() {
		t.Error("saving should clear the dirty flag")
	}
}

func TestAcknowledgementsRoundTrip(t *testing.T) {
	c, path := tempConfig(t)
	if len(c.AckedList()) != 0 {
		t.Fatal("nothing should be acknowledged initially")
	}
	c.Ack("AVG-1")
	c.Ack("AVG-2")
	c.Ack("AVG-1") // idempotent
	if err := c.Save(); err != nil {
		t.Fatalf("save: %v", err)
	}
	again := Load()
	got := again.AckedList()
	if len(got) != 2 || got[0] != "AVG-1" || got[1] != "AVG-2" {
		t.Errorf("acks did not survive: %v", got)
	}
	if _, err := os.Stat(path); err != nil {
		t.Fatalf("config not written: %v", err)
	}
}

func TestClearingAcknowledgementsReportsTheCount(t *testing.T) {
	c, _ := tempConfig(t)
	c.Ack("AVG-1")
	c.Ack("AVG-2")
	if n := c.ClearAcks(); n != 2 {
		t.Errorf("ClearAcks returned %d, want 2", n)
	}
	if len(c.AckedList()) != 0 {
		t.Error("acks were not cleared")
	}
	if err := c.Save(); err != nil {
		t.Fatal(err)
	}
	if len(Load().AckedList()) != 0 {
		t.Error("cleared acks came back after a reload")
	}
}

func TestAcksAndIgnoresCoexistWithoutClobbering(t *testing.T) {
	// both live in the same file and are written by different code paths
	c, _ := tempConfig(t)
	c.Toggle("linux")
	c.Ack("AVG-1")
	c.Ack("AVG-2")
	if err := c.Save(); err != nil {
		t.Fatal(err)
	}
	again := Load()
	if !again.IsIgnored("linux") {
		t.Error("the ignore list was lost when acks were written")
	}
	if len(again.AckedList()) != 2 {
		t.Errorf("acks lost when the ignore list was written: %v", again.AckedList())
	}
}

func TestAGarbageAckListIsIgnored(t *testing.T) {
	c, _ := tempConfig(t)
	c.raw["acked"] = "not a list"
	if got := c.AckedList(); got != nil {
		t.Errorf("expected nil, got %v", got)
	}
	c.Ack("AVG-1")
	if got := c.AckedList(); len(got) != 1 {
		t.Errorf("recovering from a bad value failed: %v", got)
	}
}