package config import ( "errors" "log/slog" "path/filepath" "testing" ) // newViewTestConfig builds a Config backed by a temp file, which is all // SetViewVisible needs: it saves and emits, and the emit is a no-op // without a running app. func newViewTestConfig(t *testing.T) *Config { t.Helper() c := &Config{ logger: slog.Default(), filePath: filepath.Join(t.TempDir(), "config.toml"), } // Load a file that is not there: that is what marks the config // loaded, without which Save refuses on the *second* write. if err := c.Load(); err != nil { t.Fatalf("Load() error: %v", err) } return c } // A view the config says nothing about takes its own default, which is // what makes this need no migration in either direction: an existing // install gets Autotag hidden without a key, and a view added later // gets its own answer rather than the list's. func TestViewVisibilityDefaults(t *testing.T) { t.Parallel() general := &GeneralConfig{} general.ApplyDefaults() resolved := general.ResolvedViewVisibility() if len(resolved) != len(Views) { t.Fatalf("resolved %d views, want %d", len(resolved), len(Views)) } if resolved[string(ViewAutotag)] { t.Error("autotag should be hidden by default") } for _, v := range Views { if v.ID == ViewAutotag { continue } if !resolved[string(v.ID)] { t.Errorf("%s should be visible by default", v.ID) } } } // A stored answer wins over the default, in both directions -- turning // Autotag on is the whole user-facing point. func TestViewVisibilityStoredWins(t *testing.T) { t.Parallel() general := &GeneralConfig{ ViewVisibility: map[string]bool{ string(ViewAutotag): true, string(ViewExplore): false, }, } general.ApplyDefaults() resolved := general.ResolvedViewVisibility() if !resolved[string(ViewAutotag)] { t.Error("autotag was switched on and should be visible") } if resolved[string(ViewExplore)] { t.Error("explore was switched off and should be hidden") } } // A key for a view that no longer exists is discarded rather than // migrated. This is the property the #25-before-#27 ordering rests on: // when Jobs folds into Settings, `jobs = true` in somebody's config is // a key nothing asks about, not a cleanup task. func TestValidateDropsUnknownAndUnhideableViews(t *testing.T) { t.Parallel() general := &GeneralConfig{ ViewVisibility: map[string]bool{ "a-view-that-was-removed": true, string(ViewSettings): false, string(ViewAutotag): true, }, } if err := general.Validate(); err != nil { t.Fatalf("Validate() error: %v", err) } if _, ok := general.ViewVisibility["a-view-that-was-removed"]; ok { t.Error("an unknown view id should be dropped on load") } if _, ok := general.ViewVisibility[string(ViewSettings)]; ok { t.Error("settings is not hideable and should not be stored") } if !general.ResolvedViewVisibility()[string(ViewSettings)] { t.Error("settings must resolve visible whatever the file said") } } // On load there is nobody to tell, so a launch page hidden by a // hand-edited file is un-hidden rather than the launch page being // reset to something the user did not choose. func TestValidateRevealsAHiddenLaunchPage(t *testing.T) { t.Parallel() general := &GeneralConfig{ DefaultPage: ViewAutotag, ViewVisibility: map[string]bool{ string(ViewAutotag): false, }, } if err := general.Validate(); err != nil { t.Fatalf("Validate() error: %v", err) } if !general.ResolvedViewVisibility()[string(ViewAutotag)] { t.Error("the launch page must be visible") } } // A launch page naming a view that no longer exists resets to the // default instead of failing validation, which on the load path would // mean the app refusing to start for whoever had it selected. // // This is the one shape #25's storage decision does *not* make free: a // visibility entry is a key and an unknown key is dropped, but a launch // page is a value. func TestARetiredLaunchPageFallsBackToTheDefault(t *testing.T) { t.Parallel() general := &GeneralConfig{DefaultPage: "jobs"} if err := general.Validate(); err != nil { t.Fatalf("Validate() error: %v", err) } if general.DefaultPage != DefaultDefaultPage { t.Errorf("DefaultPage = %q, want %q", general.DefaultPage, DefaultDefaultPage) } } // A name that is merely wrong is still an error: that is a typo, and // saying so is more useful than ignoring it. func TestAnUnknownLaunchPageIsStillAnError(t *testing.T) { t.Parallel() general := &GeneralConfig{DefaultPage: "nonsense"} if err := general.Validate(); !errors.Is(err, errUnknownDefaultPage) { t.Fatalf("Validate() error = %v, want errUnknownDefaultPage", err) } } // A retired view is not a view, so nothing offers it and nothing // resolves it -- the visibility map included. func TestARetiredViewIsGone(t *testing.T) { t.Parallel() for id := range RetiredViews { if _, ok := LookupView(string(id)); ok { t.Errorf("%s is retired but still in Views", id) } general := &GeneralConfig{} general.ApplyDefaults() if _, ok := general.ResolvedViewVisibility()[string(id)]; ok { t.Errorf("%s is retired but still resolves a visibility", id) } } } // Settings may not be the launch page, which is the shape the old // DefaultPage enum had and is now read off the same table. func TestValidateRejectsAnUnlaunchablePage(t *testing.T) { t.Parallel() general := &GeneralConfig{DefaultPage: ViewSettings} err := general.Validate() if !errors.Is(err, errViewCannotLaunch) { t.Fatalf("Validate() error = %v, want errViewCannotLaunch", err) } } // At the setter the user is present and can act, so the two states // they could not get out of are refused rather than repaired. func TestSetViewVisibleRefusals(t *testing.T) { t.Parallel() tests := []struct { name string view string visible bool want error }{ {"settings is never hideable", string(ViewSettings), false, errViewNotHideable}, {"the launch page is not hideable", string(ViewHome), false, errViewIsLaunchPage}, {"an unknown view is not a setting", "nonsense", false, errUnknownView}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() c := newViewTestConfig(t) err := c.SetViewVisible(tt.view, tt.visible) if !errors.Is(err, tt.want) { t.Fatalf("SetViewVisible() error = %v, want %v", err, tt.want) } }) } } // Showing a view is never refused, including Settings and the launch // page -- there is no state to be stuck in. func TestSetViewVisibleShowsAnything(t *testing.T) { t.Parallel() c := newViewTestConfig(t) for _, v := range Views { if err := c.SetViewVisible(string(v.ID), true); err != nil { t.Fatalf("SetViewVisible(%q, true) error: %v", v.ID, err) } } if !c.GetViewVisibility()[string(ViewAutotag)] { t.Error("autotag was switched on and should be visible") } } // The stored map survives a save/load round trip, which is what a // map-valued TOML key is worth checking for. func TestViewVisibilityRoundTrips(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "config.toml") original := &Config{logger: slog.Default(), filePath: path} if err := original.Load(); err != nil { t.Fatalf("Load() error: %v", err) } if err := original.SetViewVisible(string(ViewAutotag), true); err != nil { t.Fatalf("SetViewVisible() error: %v", err) } if err := original.SetViewVisible(string(ViewExplore), false); err != nil { t.Fatalf("SetViewVisible() error: %v", err) } loaded := &Config{logger: slog.Default(), filePath: path} if err := loaded.Load(); err != nil { t.Fatalf("Load() error: %v", err) } resolved := loaded.GetViewVisibility() if !resolved[string(ViewAutotag)] { t.Error("autotag should have loaded as visible") } if resolved[string(ViewExplore)] { t.Error("explore should have loaded as hidden") } } // Every view the shell can launch into is a view the sidebar can show, // or an install could land on a page with no nav item and no setting // pointing at it. func TestEveryLaunchableViewIsAView(t *testing.T) { t.Parallel() for _, v := range Views { if !v.CanLaunch { continue } if !v.Hideable { continue } if _, ok := LookupView(string(v.ID)); !ok { t.Errorf("%s is launchable but not a known view", v.ID) } } }