Files
superwork/tui/internal/tui/board_test.go
T
2026-06-23 05:02:15 +08:00

282 lines
7.8 KiB
Go

package tui
import (
"fmt"
"strings"
"testing"
"superwork-tui/internal/issue"
)
// ── bucketize ────────────────────────────────────────────────────────────────
func TestBucketize_basic(t *testing.T) {
issues := []issue.Issue{
{Number: 1, Column: issue.ColumnTodo},
{Number: 2, Column: issue.ColumnInProgress},
{Number: 3, Column: issue.ColumnReview},
{Number: 4, Column: issue.ColumnDone},
{Number: 5, Column: issue.ColumnTodo},
}
b := bucketize(issues)
if len(b[0]) != 2 {
t.Errorf("todo: want 2, got %d", len(b[0]))
}
if len(b[1]) != 1 {
t.Errorf("in-progress: want 1, got %d", len(b[1]))
}
if len(b[2]) != 1 {
t.Errorf("review: want 1, got %d", len(b[2]))
}
if len(b[3]) != 1 {
t.Errorf("done: want 1, got %d", len(b[3]))
}
}
func TestBucketize_emptyColumns(t *testing.T) {
issues := []issue.Issue{
{Number: 10, Column: issue.ColumnDone},
}
b := bucketize(issues)
for i := 0; i < 3; i++ {
if len(b[i]) != 0 {
t.Errorf("col %d: want 0, got %d", i, len(b[i]))
}
}
if len(b[3]) != 1 {
t.Errorf("done: want 1, got %d", len(b[3]))
}
}
func TestBucketize_unknownColumnFallsToTodo(t *testing.T) {
issues := []issue.Issue{
{Number: 99, Column: issue.Column("unknown")},
}
b := bucketize(issues)
if len(b[0]) != 1 {
t.Errorf("want unknown to land in todo (idx 0), got len=%d", len(b[0]))
}
}
// ── color ────────────────────────────────────────────────────────────────────
func TestColorFor_deterministic(t *testing.T) {
iss := issue.Issue{Number: 7}
c1 := ColorFor(iss)
c2 := ColorFor(iss)
if c1 != c2 {
t.Errorf("ColorFor must be deterministic: got %v then %v", c1, c2)
}
}
func TestColorFor_differentNumbers(t *testing.T) {
// Numbers spaced by palette length should yield the same color; adjacent
// numbers (within same palette size) should differ (at least 11 out of 12 will).
n := len(palette)
c1 := ColorFor(issue.Issue{Number: 0})
c2 := ColorFor(issue.Issue{Number: n})
if c1 != c2 {
t.Errorf("issue 0 and issue %d should map to same color (same mod), got %v vs %v", n, c1, c2)
}
}
func TestColorFor_keyMapping(t *testing.T) {
// When Color field contains a known palette key, that key's color is used.
iss := issue.Issue{Number: 999, Color: "terminal.ansiRed"}
got := ColorFor(iss)
want := keyToColor["terminal.ansiRed"]
if got != want {
t.Errorf("want %v for terminal.ansiRed, got %v", want, got)
}
}
func TestColorFor_unknownKeyFallsBackToHash(t *testing.T) {
// Unknown key falls back to number-based hash, same as no key.
iss := issue.Issue{Number: 3, Color: "terminal.something.unknown"}
got := ColorFor(iss)
want := ColorFor(issue.Issue{Number: 3})
if got != want {
t.Errorf("unknown key should fall back to hash; want %v, got %v", want, got)
}
}
// ── cardBadges ───────────────────────────────────────────────────────────────
func TestCardBadges_empty(t *testing.T) {
got := cardBadges(issue.Issue{Number: 1, Title: "test"})
if got != "" {
t.Errorf("expected empty badges, got %q", got)
}
}
func TestCardBadges_allBadges(t *testing.T) {
iss := issue.Issue{
Number: 1,
SpecFile: "docs/spec/foo.md",
PlanFile: "docs/plan/foo.md",
PR: "7",
Branch: "feat/foo",
ImplementStatus: "running",
Prerequisite: 42,
}
got := cardBadges(iss)
for _, want := range []string{"S", "P", "PR#7", "B", ">", "dep#42"} {
if !strings.Contains(got, want) {
t.Errorf("badges %q missing %q", got, want)
}
}
}
func TestCardBadges_partialBadges(t *testing.T) {
iss := issue.Issue{Number: 5, PR: "12", Branch: "fix/bar"}
got := cardBadges(iss)
if !strings.Contains(got, "PR#12") {
t.Errorf("expected PR#12 in %q", got)
}
if !strings.Contains(got, "B") {
t.Errorf("expected B in %q", got)
}
// "S" (spec) must not appear; "P " with trailing space is the plan badge
// but PR#12 starts with P so we check for standalone " P " or leading "P ".
if strings.Contains(got, "S") {
t.Errorf("unexpected S (spec) badge in %q", got)
}
// Plan badge "P" only appears as a standalone token, not as part of "PR#..."
tokens := strings.Fields(got)
for _, tok := range tokens {
if tok == "P" {
t.Errorf("unexpected standalone P (plan) badge in %q", got)
}
}
}
// ── nav clamping / column crossing ──────────────────────────────────────────
func makeBuckets(sizes [4]int) [4][]issue.Issue {
var b [4][]issue.Issue
for i, n := range sizes {
for j := 0; j < n; j++ {
b[i] = append(b[i], issue.Issue{Number: i*100 + j})
}
}
return b
}
func TestClampCursor_emptyCol(t *testing.T) {
m := Model{
buckets: makeBuckets([4]int{0, 3, 0, 0}),
colIdx: 1,
rowIdx: 5, // out of bounds
}
m.clampCursor()
if m.rowIdx != 2 {
t.Errorf("want rowIdx clamped to 2, got %d", m.rowIdx)
}
}
func TestClampCursor_rowBelowZero(t *testing.T) {
m := Model{
buckets: makeBuckets([4]int{3, 0, 0, 0}),
colIdx: 0,
rowIdx: -1,
}
m.clampCursor()
if m.rowIdx != 0 {
t.Errorf("want rowIdx 0, got %d", m.rowIdx)
}
}
func TestNextNonEmpty_skipsEmpty(t *testing.T) {
// col 0 has items, cols 1 and 2 empty, col 3 has items; from col 0 → col 3
b := makeBuckets([4]int{2, 0, 0, 1})
got := nextNonEmpty(b, 0)
if got != 3 {
t.Errorf("want 3, got %d", got)
}
}
func TestPrevNonEmpty_skipsEmpty(t *testing.T) {
b := makeBuckets([4]int{1, 0, 0, 2})
got := prevNonEmpty(b, 3)
if got != 0 {
t.Errorf("want 0, got %d", got)
}
}
func TestNextNonEmpty_allEmpty_returnsCurrent(t *testing.T) {
b := makeBuckets([4]int{0, 0, 0, 0})
got := nextNonEmpty(b, 2)
if got != 2 {
t.Errorf("want 2 (current) when all empty, got %d", got)
}
}
func TestPrevNonEmpty_wrapsAround(t *testing.T) {
// Only col 3 has items; from col 0 going left should wrap to col 3
b := makeBuckets([4]int{0, 0, 0, 1})
got := prevNonEmpty(b, 0)
if got != 3 {
t.Errorf("want 3 (wrap), got %d", got)
}
}
// ── View substring test ──────────────────────────────────────────────────────
func TestView_loadedSubstrings(t *testing.T) {
issues := []issue.Issue{
{Number: 123, Title: "Fix the bug", Column: issue.ColumnTodo, SpecFile: "docs/spec/fix.md"},
{Number: 124, Title: "Add feature", Column: issue.ColumnInProgress, PR: "7", Branch: "feat/x"},
{Number: 125, Title: "Review me", Column: issue.ColumnReview},
{Number: 120, Title: "Done task", Column: issue.ColumnDone},
}
m := Model{
state: stateLoaded,
issues: issues,
buckets: bucketize(issues),
width: 120,
height: 40,
colIdx: 0,
rowIdx: 0,
}
v := m.viewString()
for _, want := range []string{
"#123", "Fix the bug",
"#124", "Add feature",
"#125", "Review me",
"#120", "Done task",
"todo", "in-progress", "review", "done",
"S", // spec badge for #123
"PR#7", // pr badge for #124
"B", // branch badge for #124
"刷新", // help line
} {
if !strings.Contains(v, want) {
t.Errorf("View() missing %q", want)
}
}
}
func TestView_errorState(t *testing.T) {
m := Model{
state: stateError,
loadErr: fmt.Errorf("connection refused"),
}
v := m.viewString()
if !strings.Contains(v, "connection refused") {
t.Errorf("error view missing error text; got: %q", v)
}
if !strings.Contains(v, "重试") {
t.Errorf("error view missing retry hint; got: %q", v)
}
}
func TestView_loadingState(t *testing.T) {
m := New()
v := m.viewString()
if !strings.Contains(v, "加载") {
t.Errorf("loading view missing '加载'; got: %q", v)
}
}