From 476abbed0047be26cae6a1247bc7e8802abdbf33 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Tue, 19 May 2026 21:10:51 -0400 Subject: [PATCH] test(tui): add tier 1 unit tests for pure logic functions Cover search filtering, intent matching, card affordances, checklist parsing, template slot discovery/resolve, date grouping, and truncation. --- internal/tui/cards_test.go | 131 ++++++++++++++++++++++++++++++++++++ internal/tui/fill_test.go | 81 ++++++++++++++++++++++ internal/tui/list_test.go | 94 ++++++++++++++++++++++++++ internal/tui/run_test.go | 65 ++++++++++++++++++ internal/tui/search_test.go | 70 +++++++++++++++++++ 5 files changed, 441 insertions(+) create mode 100644 internal/tui/cards_test.go create mode 100644 internal/tui/fill_test.go create mode 100644 internal/tui/list_test.go create mode 100644 internal/tui/run_test.go create mode 100644 internal/tui/search_test.go diff --git a/internal/tui/cards_test.go b/internal/tui/cards_test.go new file mode 100644 index 0000000..30fd714 --- /dev/null +++ b/internal/tui/cards_test.go @@ -0,0 +1,131 @@ +package tui + +import ( + "testing" + + "github.com/lerko/nib/internal/db" +) + +func TestMatchesIntent(t *testing.T) { + snippet := db.CardSnippet + template := db.CardTemplate + checklist := db.CardChecklist + note := db.CardNote + link := db.CardLink + decision := db.CardDecision + + tests := []struct { + name string + cardType *db.CardType + intent intent + want bool + }{ + {"all matches nil", nil, intentAll, true}, + {"all matches snippet", &snippet, intentAll, true}, + {"all matches template", &template, intentAll, true}, + + {"grab matches nil", nil, intentGrab, true}, + {"grab matches snippet", &snippet, intentGrab, true}, + {"grab rejects template", &template, intentGrab, false}, + {"grab rejects note", ¬e, intentGrab, false}, + + {"read matches note", ¬e, intentRead, true}, + {"read matches link", &link, intentRead, true}, + {"read matches decision", &decision, intentRead, true}, + {"read rejects snippet", &snippet, intentRead, false}, + {"read rejects nil", nil, intentRead, false}, + + {"fill matches template", &template, intentFill, true}, + {"fill matches checklist", &checklist, intentFill, true}, + {"fill rejects snippet", &snippet, intentFill, false}, + {"fill rejects nil", nil, intentFill, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := &db.Entity{CardType: tt.cardType} + if got := matchesIntent(e, tt.intent); got != tt.want { + t.Fatalf("matchesIntent(%v, %v) = %v, want %v", tt.cardType, tt.intent, got, tt.want) + } + }) + } +} + +func TestDetectAffordance(t *testing.T) { + snippet := db.CardSnippet + template := db.CardTemplate + checklist := db.CardChecklist + decision := db.CardDecision + link := db.CardLink + note := db.CardNote + + tests := []struct { + name string + cardType *db.CardType + want string + }{ + {"nil", nil, ""}, + {"snippet", &snippet, "code"}, + {"template", &template, "fill"}, + {"checklist", &checklist, "steps"}, + {"decision", &decision, "decide"}, + {"link", &link, "link"}, + {"note", ¬e, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := &db.Entity{CardType: tt.cardType} + if got := detectAffordance(e); got != tt.want { + t.Fatalf("detectAffordance(%v) = %q, want %q", tt.cardType, got, tt.want) + } + }) + } +} + +func TestIntentCycle(t *testing.T) { + order := []intent{intentAll, intentGrab, intentRead, intentFill, intentAll} + for i := 0; i < len(order)-1; i++ { + got := order[i].next() + if got != order[i+1] { + t.Fatalf("%v.next() = %v, want %v", order[i], got, order[i+1]) + } + } +} + +func TestApplyFilter_PinnedFirst(t *testing.T) { + snippet := db.CardSnippet + c := newCardsModel() + c.setEntities([]*db.Entity{ + {ID: "1", Body: "a", CardType: &snippet}, + {ID: "2", Body: "b", Pinned: true, CardType: &snippet}, + {ID: "3", Body: "c", CardType: &snippet}, + }) + + if len(c.filtered) != 3 { + t.Fatalf("expected 3 filtered, got %d", len(c.filtered)) + } + if c.filtered[0].ID != "2" { + t.Fatalf("pinned entity should be first, got %s", c.filtered[0].ID) + } +} + +func TestApplyFilter_CursorClamps(t *testing.T) { + snippet := db.CardSnippet + template := db.CardTemplate + c := newCardsModel() + c.setEntities([]*db.Entity{ + {ID: "1", Body: "a", CardType: &snippet}, + {ID: "2", Body: "b", CardType: &snippet}, + {ID: "3", Body: "c", CardType: &template}, + }) + c.cursor = 2 + + c.setIntent(intentFill) + if len(c.filtered) != 1 { + t.Fatalf("expected 1 fill entity, got %d", len(c.filtered)) + } + if c.cursor != 0 { + t.Fatalf("cursor should clamp to 0, got %d", c.cursor) + } +} diff --git a/internal/tui/fill_test.go b/internal/tui/fill_test.go new file mode 100644 index 0000000..c0617df --- /dev/null +++ b/internal/tui/fill_test.go @@ -0,0 +1,81 @@ +package tui + +import ( + "testing" +) + +func TestDiscoverSlots(t *testing.T) { + tests := []struct { + name string + body string + wantNames []string + }{ + {"no slots", "plain text", nil}, + {"single slot", "Hello ${name}", []string{"name"}}, + {"multiple slots", "${greeting} ${name}, welcome to ${place}", []string{"greeting", "name", "place"}}, + {"duplicate slot deduped", "${x} and ${x} again", []string{"x"}}, + {"adjacent slots", "${a}${b}", []string{"a", "b"}}, + {"nested braces ignored", "${{bad}}", nil}, + {"empty body", "", nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := discoverSlots(tt.body) + if len(tt.wantNames) == 0 && len(got) == 0 { + return + } + if len(got) != len(tt.wantNames) { + t.Fatalf("got %d slots, want %d", len(got), len(tt.wantNames)) + } + for i, s := range got { + if s.Name != tt.wantNames[i] { + t.Fatalf("slot[%d].Name = %q, want %q", i, s.Name, tt.wantNames[i]) + } + } + }) + } +} + +func TestResolve(t *testing.T) { + tests := []struct { + name string + body string + slots []fillSlot + want string + }{ + { + "all filled", + "Hello ${name}, welcome to ${place}", + []fillSlot{{Name: "name", Value: "Alice"}, {Name: "place", Value: "Nib"}}, + "Hello Alice, welcome to Nib", + }, + { + "unfilled stays as placeholder", + "${greeting} ${name}", + []fillSlot{{Name: "greeting", Value: "Hi"}, {Name: "name"}}, + "Hi ${name}", + }, + { + "no slots", + "plain text", + nil, + "plain text", + }, + { + "repeated slot filled everywhere", + "${x} and ${x}", + []fillSlot{{Name: "x", Value: "Y"}}, + "Y and Y", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := fillModel{body: tt.body, slots: tt.slots} + if got := f.resolve(); got != tt.want { + t.Fatalf("resolve() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/tui/list_test.go b/internal/tui/list_test.go new file mode 100644 index 0000000..bd90954 --- /dev/null +++ b/internal/tui/list_test.go @@ -0,0 +1,94 @@ +package tui + +import ( + "testing" + "time" + + "github.com/lerko/nib/internal/db" +) + +func TestGroupByDate(t *testing.T) { + may19 := time.Date(2026, 5, 19, 10, 0, 0, 0, time.UTC) + may19b := time.Date(2026, 5, 19, 14, 0, 0, 0, time.UTC) + may18 := time.Date(2026, 5, 18, 9, 0, 0, 0, time.UTC) + + entities := []*db.Entity{ + {ID: "1", CreatedAt: may19, Body: "a"}, + {ID: "2", CreatedAt: may19b, Body: "b"}, + {ID: "3", CreatedAt: may18, Body: "c"}, + } + + groups := groupByDate(entities) + if len(groups) != 2 { + t.Fatalf("expected 2 groups, got %d", len(groups)) + } + if len(groups[0].entities) != 2 { + t.Fatalf("first group should have 2 entities, got %d", len(groups[0].entities)) + } + if len(groups[1].entities) != 1 { + t.Fatalf("second group should have 1 entity, got %d", len(groups[1].entities)) + } + if groups[0].label != "may 19" { + t.Fatalf("first group label = %q, want %q", groups[0].label, "may 19") + } +} + +func TestGroupByDate_Empty(t *testing.T) { + groups := groupByDate(nil) + if len(groups) != 0 { + t.Fatalf("expected 0 groups, got %d", len(groups)) + } +} + +func TestGroupByDate_SingleEntity(t *testing.T) { + e := []*db.Entity{{ID: "1", CreatedAt: time.Now(), Body: "solo"}} + groups := groupByDate(e) + if len(groups) != 1 || len(groups[0].entities) != 1 { + t.Fatal("single entity should produce 1 group with 1 entity") + } +} + +func TestTruncate(t *testing.T) { + tests := []struct { + name string + input string + maxLen int + want string + }{ + {"short enough", "hello", 10, "hello"}, + {"exact length", "hello", 5, "hello"}, + {"truncated", "hello world", 6, "hello…"}, + {"very short max", "hello", 3, "…"}, + {"unicode", "héllo wörld", 7, "héllo …"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncate(tt.input, tt.maxLen) + if got != tt.want { + t.Fatalf("truncate(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want) + } + }) + } +} + +func TestStripAnsi(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"no ansi", "hello", "hello"}, + {"with color", "\x1b[31mred\x1b[0m", "red"}, + {"empty", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stripAnsi(tt.input) + if got != tt.want { + t.Fatalf("stripAnsi(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} diff --git a/internal/tui/run_test.go b/internal/tui/run_test.go new file mode 100644 index 0000000..5edf803 --- /dev/null +++ b/internal/tui/run_test.go @@ -0,0 +1,65 @@ +package tui + +import ( + "encoding/json" + "testing" +) + +func TestParseChecklist(t *testing.T) { + tests := []struct { + name string + cardData *string + wantLen int + }{ + {"nil data", nil, 0}, + {"empty JSON", ptr("{}"), 0}, + {"malformed JSON", ptr("{bad"), 0}, + {"valid steps", ptr(`{"steps":[{"text":"step 1","done":false},{"text":"step 2","done":true}]}`), 2}, + {"empty steps array", ptr(`{"steps":[]}`), 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseChecklist(tt.cardData) + if len(got) != tt.wantLen { + t.Fatalf("parseChecklist() returned %d steps, want %d", len(got), tt.wantLen) + } + }) + } +} + +func TestParseChecklist_PreservesDoneState(t *testing.T) { + data := `{"steps":[{"text":"first","done":false},{"text":"second","done":true}]}` + steps := parseChecklist(&data) + if steps[0].Done { + t.Fatal("step 0 should not be done") + } + if !steps[1].Done { + t.Fatal("step 1 should be done") + } + if steps[0].Text != "first" || steps[1].Text != "second" { + t.Fatalf("texts wrong: %q, %q", steps[0].Text, steps[1].Text) + } +} + +func TestDoneCount(t *testing.T) { + r := newRunModel("id", ptr(`{"steps":[{"label":"a","done":true},{"label":"b","done":false},{"label":"c","done":true}]}`)) + if got := r.doneCount(); got != 2 { + t.Fatalf("doneCount() = %d, want 2", got) + } +} + +func TestStepsJSON_Roundtrip(t *testing.T) { + r := newRunModel("id", ptr(`{"steps":[{"text":"test","done":false}]}`)) + out := r.stepsJSON() + + var parsed struct { + Steps []runStep `json:"steps"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("stepsJSON() produced invalid JSON: %v", err) + } + if len(parsed.Steps) != 1 || parsed.Steps[0].Text != "test" { + t.Fatalf("roundtrip failed: %+v", parsed.Steps) + } +} diff --git a/internal/tui/search_test.go b/internal/tui/search_test.go new file mode 100644 index 0000000..d805c91 --- /dev/null +++ b/internal/tui/search_test.go @@ -0,0 +1,70 @@ +package tui + +import ( + "testing" + + "github.com/lerko/nib/internal/db" +) + +func ptr[T any](v T) *T { return &v } + +func TestFilterEntities(t *testing.T) { + entities := []*db.Entity{ + {ID: "1", Body: "buy groceries", Tags: []string{"errand", "food"}}, + {ID: "2", Body: "read chapter 5", Title: ptr("Go Book"), Tags: []string{"study"}}, + {ID: "3", Body: "fix login bug", Description: ptr("auth middleware broken"), Tags: []string{"work", "urgent"}}, + {ID: "4", Body: "empty tags"}, + } + + tests := []struct { + name string + query string + tags []string + wantIDs []string + }{ + {"no filter returns all", "", nil, []string{"1", "2", "3", "4"}}, + {"query matches body", "groceries", nil, []string{"1"}}, + {"query case insensitive", "GROCERIES", nil, []string{"1"}}, + {"query matches title", "go book", nil, []string{"2"}}, + {"query matches description", "middleware", nil, []string{"3"}}, + {"query no match", "nonexistent", nil, nil}, + {"single tag filter", "", []string{"study"}, []string{"2"}}, + {"multi tag filter all present", "", []string{"work", "urgent"}, []string{"3"}}, + {"multi tag filter partial miss", "", []string{"work", "food"}, nil}, + {"tag filter no match", "", []string{"missing"}, nil}, + {"query plus tag", "fix", []string{"work"}, []string{"3"}}, + {"query plus tag mismatch", "groceries", []string{"work"}, nil}, + {"entity with nil title and description", "empty", nil, []string{"4"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := filterEntities(entities, tt.query, tt.tags) + gotIDs := make([]string, len(got)) + for i, e := range got { + gotIDs[i] = e.ID + } + if len(tt.wantIDs) == 0 && len(gotIDs) == 0 { + return + } + if len(gotIDs) != len(tt.wantIDs) { + t.Fatalf("got %v, want %v", gotIDs, tt.wantIDs) + } + for i := range gotIDs { + if gotIDs[i] != tt.wantIDs[i] { + t.Fatalf("got %v, want %v", gotIDs, tt.wantIDs) + } + } + }) + } +} + +func TestMatchesSearch_NilFields(t *testing.T) { + e := &db.Entity{Body: "hello world"} + if !matchesSearch(e, "hello", nil) { + t.Fatal("should match body") + } + if matchesSearch(e, "title", nil) { + t.Fatal("should not match nil title") + } +}