diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go index 3b46abd..e7fbb7d 100644 --- a/internal/policy/policy_test.go +++ b/internal/policy/policy_test.go @@ -1,6 +1,8 @@ package policy import ( + "os" + "path/filepath" "testing" "time" @@ -9,6 +11,50 @@ import ( "incredigo/internal/discover" ) +func TestLoad(t *testing.T) { + // Empty path → built-in default. + if p, err := Load(""); err != nil || p.Defaults.WarnAfter != 60 { + t.Fatalf("Load(\"\") = %+v, %v; want default", p, err) + } + + // Missing file → default (not an error). + miss := filepath.Join(t.TempDir(), "nope.yaml") + if p, err := Load(miss); err != nil || p.Defaults.StaleAfter != 90 { + t.Fatalf("Load(missing) = %+v, %v; want default", p, err) + } + + // Valid file → parsed onto the Default() base: a struct field absent from the + // file keeps its default (stale_after stays 90), while an override is applied. + f := filepath.Join(t.TempDir(), "policy.yaml") + if err := os.WriteFile(f, []byte("defaults:\n warn_after: 10d\noverrides:\n aws_key:\n warn_after: 5d\n stale_after: 45d\n"), 0o600); err != nil { + t.Fatal(err) + } + p, err := Load(f) + if err != nil { + t.Fatalf("Load(valid): %v", err) + } + if p.Defaults.WarnAfter != 10 || p.Defaults.StaleAfter != 90 { + t.Errorf("defaults = %+v, want {10 90} (stale kept from Default)", p.Defaults) + } + if got := p.Overrides[discover.KindAWSKey]; got.WarnAfter != 5 || got.StaleAfter != 45 { + t.Errorf("aws_key override = %+v, want {5 45}", got) + } + + // Malformed YAML → error. + bad := filepath.Join(t.TempDir(), "bad.yaml") + if err := os.WriteFile(bad, []byte("defaults: [this is not a mapping"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := Load(bad); err == nil { + t.Error("Load(malformed) returned no error") + } + + // Unreadable (a directory, not a regular file) → a non-IsNotExist error. + if _, err := Load(t.TempDir()); err == nil { + t.Error("Load(directory) returned no error") + } +} + func TestDaysUnmarshal(t *testing.T) { ok := map[string]Days{ "60": 60, diff --git a/internal/tui/guide_test.go b/internal/tui/guide_test.go index d4478db..61a828e 100644 --- a/internal/tui/guide_test.go +++ b/internal/tui/guide_test.go @@ -95,3 +95,101 @@ func TestEmptyModelView(t *testing.T) { t.Errorf("empty view = %q", got) } } + +func TestInitNoCmd(t *testing.T) { + if cmd := sampleModel().Init(); cmd != nil { + t.Errorf("Init returned a command, want nil") + } +} + +// TestUpdateIgnoresNonKeyMsg proves a non-keyboard message leaves the model +// unchanged (the walk-through only responds to key presses). +func TestUpdateIgnoresNonKeyMsg(t *testing.T) { + m := sampleModel() + nm, cmd := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + if cmd != nil { + t.Error("non-key msg produced a command") + } + if nm.(Model).cur != 0 { + t.Error("non-key msg moved the cursor") + } +} + +// TestOpenBranches covers "o": on an item with a URL it emits an open command; on an +// item with no rotation page it does nothing. +func TestOpenBranches(t *testing.T) { + m := sampleModel() // item0 (git) has a URL; item1 (ssh) has none + _, cmd := m.Update(key("o")) + if cmd == nil { + t.Error("o on an item with a URL returned no command") + } + nm, _ := m.Update(key("n")) // advance to the URL-less ssh item + m = nm.(Model) + if _, cmd := m.Update(key("o")); cmd != nil { + t.Error("o on an item with no URL returned a command") + } +} + +// TestSkipAndBoundaries covers "s" (skip → next) and the prev/next boundary guards. +func TestSkipAndBoundaries(t *testing.T) { + m := sampleModel() + // prev at the first item is a no-op. + nm, _ := m.Update(key("k")) + if nm.(Model).cur != 0 { + t.Error("k at index 0 moved the cursor") + } + m = nm.(Model) + // skip advances. + nm, _ = m.Update(key("s")) + m = nm.(Model) + if m.cur != 1 { + t.Errorf("s did not advance, cur=%d", m.cur) + } + // skip at the last item is a no-op. + nm, _ = m.Update(key("s")) + if nm.(Model).cur != 1 { + t.Error("s past end moved the cursor") + } +} + +// TestEnterAtLastItemStays proves enter marks the last item done without advancing +// past the end of the list. +func TestEnterAtLastItemStays(t *testing.T) { + m := sampleModel() + nm, _ := m.Update(key("n")) // to last item (index 1) + m = nm.(Model) + nm, _ = m.Update(key("enter")) + m = nm.(Model) + if !m.done[1] { + t.Error("enter did not mark the last item done") + } + if m.cur != 1 { + t.Errorf("enter advanced past the last item, cur=%d", m.cur) + } +} + +// TestQuitVariants covers the esc and ctrl+c quit keys alongside q. +func TestQuitVariants(t *testing.T) { + for _, k := range []tea.KeyMsg{{Type: tea.KeyEsc}, {Type: tea.KeyCtrlC}} { + nm, cmd := sampleModel().Update(k) + if !nm.(Model).quit || cmd == nil { + t.Errorf("%v did not quit", k) + } + } +} + +// TestOpenURLCmd invokes the command returned by openURL: it is best-effort (the +// launcher may be absent on a headless host) and must return a nil message. +func TestOpenURLCmd(t *testing.T) { + if msg := openURL("https://example.com")(); msg != nil { + t.Errorf("openURL cmd returned %v, want nil", msg) + } +} + +// TestRunEmptyReturnsNil covers Run's headless-safe path: no items → prints a notice +// and returns without launching the (TTY-requiring) Bubble Tea program. +func TestRunEmptyReturnsNil(t *testing.T) { + if err := Run(nil); err != nil { + t.Errorf("Run(nil) = %v, want nil", err) + } +}