365 lines
11 KiB
Go
365 lines
11 KiB
Go
package tui
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
"superwork-tui/internal/issue"
|
|
)
|
|
|
|
func makeLoadedModel(issues []issue.Issue, colIdx, rowIdx int) Model {
|
|
m := Model{
|
|
state: stateLoaded,
|
|
issues: issues,
|
|
width: 120,
|
|
height: 40,
|
|
}
|
|
m.buckets = bucketize(issues)
|
|
m.colIdx = colIdx
|
|
m.rowIdx = rowIdx
|
|
return m
|
|
}
|
|
|
|
func sendKey(m Model, s string) (Model, tea.Cmd) {
|
|
var msg tea.KeyMsg
|
|
if s == "esc" {
|
|
msg = keyMsg("esc")
|
|
} else {
|
|
msg = keyMsg(s)
|
|
}
|
|
next, cmd := m.Update(msg)
|
|
return next.(Model), cmd
|
|
}
|
|
|
|
// ── move boundary tests ──────────────────────────────────────────────────────
|
|
|
|
func TestMoveLeft_AtLeftBoundary_NoOp(t *testing.T) {
|
|
issues := []issue.Issue{
|
|
{Number: 1, Column: issue.ColumnTodo},
|
|
{Number: 2, Column: issue.ColumnInProgress},
|
|
}
|
|
m := makeLoadedModel(issues, 0, 0) // colIdx=0 (todo), already leftmost
|
|
m2, _ := sendKey(m, "H")
|
|
if m2.colIdx != 0 {
|
|
t.Errorf("colIdx = %d, want 0 (no-op at left boundary)", m2.colIdx)
|
|
}
|
|
if m2.buckets[0][0].Number != 1 {
|
|
t.Errorf("issue #1 should still be in todo bucket")
|
|
}
|
|
}
|
|
|
|
func TestMoveRight_AtRightBoundary_NoOp(t *testing.T) {
|
|
issues := []issue.Issue{
|
|
{Number: 1, Column: issue.ColumnDone},
|
|
{Number: 2, Column: issue.ColumnInProgress},
|
|
}
|
|
m := makeLoadedModel(issues, 3, 0) // colIdx=3 (done), already rightmost
|
|
m2, _ := sendKey(m, "L")
|
|
if m2.colIdx != 3 {
|
|
t.Errorf("colIdx = %d, want 3 (no-op at right boundary)", m2.colIdx)
|
|
}
|
|
if m2.buckets[3][0].Number != 1 {
|
|
t.Errorf("issue #1 should still be in done bucket")
|
|
}
|
|
}
|
|
|
|
func TestMoveLeft_FromReview_CardMovesToInProgress(t *testing.T) {
|
|
issues := []issue.Issue{
|
|
{Number: 10, Column: issue.ColumnReview},
|
|
{Number: 20, Column: issue.ColumnInProgress},
|
|
}
|
|
// colIdx=2 (review), rowIdx=0 → issue #10 selected
|
|
m := makeLoadedModel(issues, 2, 0)
|
|
m2, cmd := sendKey(m, "H")
|
|
|
|
// cursor should now be in in-progress (colIdx=1)
|
|
if m2.colIdx != 1 {
|
|
t.Errorf("colIdx = %d, want 1 (in-progress)", m2.colIdx)
|
|
}
|
|
// issue #10 should now be in in-progress bucket
|
|
found := false
|
|
for _, iss := range m2.buckets[1] {
|
|
if iss.Number == 10 {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("issue #10 not found in in-progress bucket after move left")
|
|
}
|
|
// review bucket should be empty
|
|
if len(m2.buckets[2]) != 0 {
|
|
t.Errorf("review bucket should be empty after moving only card left, got %d cards", len(m2.buckets[2]))
|
|
}
|
|
// cmd should be non-nil (async persist)
|
|
if cmd == nil {
|
|
t.Errorf("expected non-nil cmd for async persist after move")
|
|
}
|
|
}
|
|
|
|
func TestRebucketizeAfterMove_CursorFollows(t *testing.T) {
|
|
// Two issues in review; move rowIdx=1 (second card) left → cursor follows to in-progress
|
|
issues := []issue.Issue{
|
|
{Number: 5, Column: issue.ColumnReview},
|
|
{Number: 6, Column: issue.ColumnReview},
|
|
}
|
|
m := makeLoadedModel(issues, 2, 1) // select second card (#6) in review
|
|
m2, _ := sendKey(m, "H")
|
|
|
|
// Issue #6 should now be in in-progress (colIdx=1)
|
|
if m2.colIdx != 1 {
|
|
t.Errorf("colIdx = %d, want 1", m2.colIdx)
|
|
}
|
|
if len(m2.buckets[1]) != 1 || m2.buckets[1][0].Number != 6 {
|
|
t.Errorf("in-progress bucket = %v, want [#6]", m2.buckets[1])
|
|
}
|
|
// rowIdx should point to issue #6 in the new column
|
|
if m2.rowIdx != 0 {
|
|
t.Errorf("rowIdx = %d, want 0 (only card in in-progress)", m2.rowIdx)
|
|
}
|
|
}
|
|
|
|
// ── confirm state transitions ─────────────────────────────────────────────────
|
|
|
|
func TestConfirmState_CEntersClose(t *testing.T) {
|
|
issues := []issue.Issue{{Number: 42, Column: issue.ColumnTodo}}
|
|
m := makeLoadedModel(issues, 0, 0)
|
|
m2, _ := sendKey(m, "c")
|
|
if m2.confirmPend == nil {
|
|
t.Fatal("confirmPend should be non-nil after 'c'")
|
|
}
|
|
if m2.confirmPend.action != confirmClose {
|
|
t.Errorf("action = %v, want confirmClose", m2.confirmPend.action)
|
|
}
|
|
if m2.confirmPend.issue.Number != 42 {
|
|
t.Errorf("issue number = %d, want 42", m2.confirmPend.issue.Number)
|
|
}
|
|
}
|
|
|
|
func TestConfirmState_DEntersDelete(t *testing.T) {
|
|
issues := []issue.Issue{{Number: 99, Column: issue.ColumnInProgress}}
|
|
m := makeLoadedModel(issues, 1, 0)
|
|
m2, _ := sendKey(m, "d")
|
|
if m2.confirmPend == nil {
|
|
t.Fatal("confirmPend should be non-nil after 'd'")
|
|
}
|
|
if m2.confirmPend.action != confirmDelete {
|
|
t.Errorf("action = %v, want confirmDelete", m2.confirmPend.action)
|
|
}
|
|
}
|
|
|
|
func TestConfirmState_NCancels(t *testing.T) {
|
|
issues := []issue.Issue{{Number: 1, Column: issue.ColumnTodo}}
|
|
m := makeLoadedModel(issues, 0, 0)
|
|
m, _ = sendKey(m, "c")
|
|
if m.confirmPend == nil {
|
|
t.Fatal("precondition: confirmPend should be set")
|
|
}
|
|
m2, _ := sendKey(m, "n")
|
|
if m2.confirmPend != nil {
|
|
t.Errorf("confirmPend should be nil after 'n' cancel")
|
|
}
|
|
}
|
|
|
|
func TestConfirmState_EscCancels(t *testing.T) {
|
|
issues := []issue.Issue{{Number: 1, Column: issue.ColumnTodo}}
|
|
m := makeLoadedModel(issues, 0, 0)
|
|
m, _ = sendKey(m, "c")
|
|
if m.confirmPend == nil {
|
|
t.Fatal("precondition: confirmPend should be set")
|
|
}
|
|
// send esc
|
|
next, _ := m.Update(keyMsg("esc"))
|
|
m2 := next.(Model)
|
|
if m2.confirmPend != nil {
|
|
t.Errorf("confirmPend should be nil after esc cancel")
|
|
}
|
|
}
|
|
|
|
func TestConfirmState_YTriggersCmd(t *testing.T) {
|
|
// Confirming close with 'y' should return a non-nil cmd (the closeCmd)
|
|
// We can't execute the cmd (needs real gitea) but verify it's returned.
|
|
issues := []issue.Issue{{Number: 7, Column: issue.ColumnTodo}}
|
|
m := makeLoadedModel(issues, 0, 0)
|
|
m, _ = sendKey(m, "c")
|
|
if m.confirmPend == nil {
|
|
t.Fatal("precondition: confirmPend should be set")
|
|
}
|
|
m2, cmd := sendKey(m, "y")
|
|
if m2.confirmPend != nil {
|
|
t.Errorf("confirmPend should be cleared after 'y'")
|
|
}
|
|
if cmd == nil {
|
|
t.Errorf("expected non-nil cmd after confirming action")
|
|
}
|
|
}
|
|
|
|
// ── rollback on move failure ──────────────────────────────────────────────────
|
|
|
|
func TestMoveRollback_OnFailure(t *testing.T) {
|
|
issues := []issue.Issue{
|
|
{Number: 3, Column: issue.ColumnReview},
|
|
}
|
|
// Optimistically move #3 from review→in-progress
|
|
m := makeLoadedModel(issues, 2, 0)
|
|
m2, _ := sendKey(m, "H")
|
|
// confirm optimistic: #3 now in in-progress
|
|
if m2.buckets[1] == nil || len(m2.buckets[1]) == 0 || m2.buckets[1][0].Number != 3 {
|
|
t.Fatalf("precondition: optimistic move failed, bucket[1]=%v", m2.buckets[1])
|
|
}
|
|
|
|
// Inject failure result
|
|
failMsg := moveResultMsg{
|
|
issueNumber: 3,
|
|
newCol: issue.ColumnInProgress,
|
|
origCol: issue.ColumnReview,
|
|
err: errors.New("network error"),
|
|
}
|
|
next, _ := m2.Update(failMsg)
|
|
m3 := next.(Model)
|
|
|
|
// Issue #3 should be back in review
|
|
if len(m3.buckets[2]) != 1 || m3.buckets[2][0].Number != 3 {
|
|
t.Errorf("rollback failed: review bucket = %v, want [#3]", m3.buckets[2])
|
|
}
|
|
if len(m3.buckets[1]) != 0 {
|
|
t.Errorf("in-progress bucket should be empty after rollback, got %v", m3.buckets[1])
|
|
}
|
|
if m3.statusMsg == "" {
|
|
t.Errorf("statusMsg should be set after rollback failure")
|
|
}
|
|
}
|
|
|
|
// ── close/delete result messages ─────────────────────────────────────────────
|
|
|
|
func TestCloseResult_RemovesCard(t *testing.T) {
|
|
issues := []issue.Issue{
|
|
{Number: 10, Column: issue.ColumnTodo},
|
|
{Number: 11, Column: issue.ColumnTodo},
|
|
}
|
|
m := makeLoadedModel(issues, 0, 0)
|
|
msg := closeResultMsg{issueNumber: 10, err: nil}
|
|
next, _ := m.Update(msg)
|
|
m2 := next.(Model)
|
|
|
|
for _, iss := range m2.issues {
|
|
if iss.Number == 10 {
|
|
t.Errorf("issue #10 should be removed after closeResultMsg")
|
|
}
|
|
}
|
|
if m2.statusMsg == "" {
|
|
t.Errorf("statusMsg should be set after close success")
|
|
}
|
|
}
|
|
|
|
func TestDeleteResult_RemovesCard(t *testing.T) {
|
|
issues := []issue.Issue{
|
|
{Number: 20, Column: issue.ColumnInProgress},
|
|
}
|
|
m := makeLoadedModel(issues, 1, 0)
|
|
msg := deleteResultMsg{issueNumber: 20, err: nil}
|
|
next, _ := m.Update(msg)
|
|
m2 := next.(Model)
|
|
|
|
if len(m2.issues) != 0 {
|
|
t.Errorf("issues should be empty after delete, got %v", m2.issues)
|
|
}
|
|
if m2.statusMsg == "" {
|
|
t.Errorf("statusMsg should be set after delete success")
|
|
}
|
|
}
|
|
|
|
func TestCloseResult_ErrorSetsStatus(t *testing.T) {
|
|
issues := []issue.Issue{{Number: 5, Column: issue.ColumnTodo}}
|
|
m := makeLoadedModel(issues, 0, 0)
|
|
msg := closeResultMsg{issueNumber: 5, err: errors.New("gitea 500")}
|
|
next, _ := m.Update(msg)
|
|
m2 := next.(Model)
|
|
|
|
if len(m2.issues) != 1 {
|
|
t.Errorf("issue should NOT be removed on close error")
|
|
}
|
|
if m2.statusMsg == "" {
|
|
t.Errorf("statusMsg should be set on close error")
|
|
}
|
|
}
|
|
|
|
// ── statusBar view ────────────────────────────────────────────────────────────
|
|
|
|
func TestStatusBar_ShowsConfirm(t *testing.T) {
|
|
issues := []issue.Issue{{Number: 55, Column: issue.ColumnTodo}}
|
|
m := makeLoadedModel(issues, 0, 0)
|
|
m2, _ := sendKey(m, "c")
|
|
|
|
bar := m2.statusBar()
|
|
if bar == "" {
|
|
t.Error("statusBar should be non-empty during confirm")
|
|
}
|
|
}
|
|
|
|
func TestStatusBar_ShowsStatusMsg(t *testing.T) {
|
|
issues := []issue.Issue{{Number: 1, Column: issue.ColumnTodo}}
|
|
m := makeLoadedModel(issues, 0, 0)
|
|
m.statusMsg = "操作成功"
|
|
bar := m.statusBar()
|
|
if bar == "" {
|
|
t.Error("statusBar should show statusMsg when set")
|
|
}
|
|
}
|
|
|
|
// ── I1: global keybinding guard ───────────────────────────────────────────────
|
|
|
|
func makeStateModel(state boardState) Model {
|
|
m := Model{
|
|
state: state,
|
|
width: 120,
|
|
height: 40,
|
|
}
|
|
m.buckets = bucketize(nil)
|
|
return m
|
|
}
|
|
|
|
// globalKeys lists the keys that must be no-ops outside stateLoaded.
|
|
var globalKeysList = []string{"S", "E", ",", "G", "m"}
|
|
|
|
func TestGlobalKeys_NoOpInStateLoading(t *testing.T) {
|
|
for _, key := range globalKeysList {
|
|
m := makeStateModel(stateLoading)
|
|
_, cmd := sendKey(m, key)
|
|
if cmd != nil {
|
|
t.Errorf("key %q in stateLoading: expected nil cmd, got non-nil", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGlobalKeys_NoOpInStateError(t *testing.T) {
|
|
for _, key := range globalKeysList {
|
|
m := makeStateModel(stateError)
|
|
_, cmd := sendKey(m, key)
|
|
if cmd != nil {
|
|
t.Errorf("key %q in stateError: expected nil cmd, got non-nil", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCtrlL_NoOpInStateLoading(t *testing.T) {
|
|
m := makeStateModel(stateLoading)
|
|
msg := keyMsg("ctrl+l")
|
|
next, cmd := m.Update(msg)
|
|
_ = next
|
|
if cmd != nil {
|
|
t.Errorf("ctrl+l in stateLoading: expected nil cmd, got non-nil")
|
|
}
|
|
}
|
|
|
|
func TestCtrlL_NoOpInStateError(t *testing.T) {
|
|
m := makeStateModel(stateError)
|
|
msg := keyMsg("ctrl+l")
|
|
next, cmd := m.Update(msg)
|
|
_ = next
|
|
if cmd != nil {
|
|
t.Errorf("ctrl+l in stateError: expected nil cmd, got non-nil")
|
|
}
|
|
}
|