282 lines
7.8 KiB
Go
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)
|
|
}
|
|
}
|