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.
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user