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

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")
}
}