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

291 lines
9.0 KiB
Go

package tui
import (
"errors"
"testing"
tea "charm.land/bubbletea/v2"
"superwork-tui/internal/issue"
)
// sendEnter sends the Enter key to the model.
func sendEnter(m Model) (Model, tea.Cmd) {
next, cmd := m.Update(keyMsg("enter"))
return next.(Model), cmd
}
// sendEsc sends the Esc key to the model.
func sendEsc(m Model) (Model, tea.Cmd) {
next, cmd := m.Update(keyMsg("esc"))
return next.(Model), cmd
}
// typeInto simulates typing each character into the model sequentially.
func typeInto(m Model, s string) Model {
for _, ch := range s {
next, _ := m.Update(keyMsg(string(ch)))
m = next.(Model)
}
return m
}
// ── p key enters input state ─────────────────────────────────────────────────
func TestPKey_EntersInputState(t *testing.T) {
issues := []issue.Issue{{Number: 7, Column: issue.ColumnTodo}}
m := makeLoadedModel(issues, 0, 0)
m2, _ := sendKey(m, "p")
if m2.state != stateInput {
t.Errorf("state = %v, want stateInput", m2.state)
}
if m2.inputPend != 7 {
t.Errorf("inputPend = %d, want 7", m2.inputPend)
}
}
func TestPKey_NoIssue_NoOp(t *testing.T) {
m := makeLoadedModel(nil, 0, 0)
m2, _ := sendKey(m, "p")
if m2.state != stateLoaded {
t.Errorf("state = %v, want stateLoaded (no-op when no issues)", m2.state)
}
}
// ── esc cancels input ────────────────────────────────────────────────────────
func TestInputEsc_CancelsWithoutDispatch(t *testing.T) {
issues := []issue.Issue{{Number: 3, Column: issue.ColumnTodo}}
m := makeLoadedModel(issues, 0, 0)
m, _ = sendKey(m, "p")
if m.state != stateInput {
t.Fatal("precondition: should be in input state")
}
m2, cmd := sendEsc(m)
if m2.state != stateLoaded {
t.Errorf("state = %v, want stateLoaded after esc", m2.state)
}
if m2.inputPend != 0 {
t.Errorf("inputPend = %d, want 0 after esc", m2.inputPend)
}
if cmd != nil {
t.Errorf("cmd should be nil after esc cancel")
}
}
// ── empty enter cancels ──────────────────────────────────────────────────────
func TestInputEnterEmpty_Cancels(t *testing.T) {
issues := []issue.Issue{{Number: 3, Column: issue.ColumnTodo}}
m := makeLoadedModel(issues, 0, 0)
m, _ = sendKey(m, "p")
m2, cmd := sendEnter(m) // empty value
if m2.state != stateLoaded {
t.Errorf("state = %v, want stateLoaded after empty enter", m2.state)
}
if cmd != nil {
t.Errorf("cmd should be nil after empty enter cancel")
}
}
// ── valid enter dispatches setDepCmd ─────────────────────────────────────────
func TestInputEnterValid_DispatchesCmd(t *testing.T) {
issues := []issue.Issue{{Number: 5, Column: issue.ColumnTodo}}
m := makeLoadedModel(issues, 0, 0)
m, _ = sendKey(m, "p")
m = typeInto(m, "2") // type "2" as prereq number
m2, cmd := sendEnter(m)
if m2.state != stateLoaded {
t.Errorf("state = %v, want stateLoaded after valid enter", m2.state)
}
if cmd == nil {
t.Error("cmd should be non-nil after valid prereq entered")
}
// Optimistic update: Prerequisite should be set on issue #5
for _, iss := range m2.issues {
if iss.Number == 5 {
if iss.Prerequisite != 2 {
t.Errorf("optimistic Prerequisite = %d, want 2", iss.Prerequisite)
}
return
}
}
t.Error("issue #5 not found after optimistic update")
}
func TestInputEnterInvalid_SetsStatusNoCmd(t *testing.T) {
issues := []issue.Issue{{Number: 5, Column: issue.ColumnTodo}}
m := makeLoadedModel(issues, 0, 0)
m, _ = sendKey(m, "p")
m = typeInto(m, "abc") // invalid number
m2, cmd := sendEnter(m)
if cmd != nil {
t.Error("cmd should be nil for invalid prereq input")
}
if m2.statusMsg == "" {
t.Error("statusMsg should be set for invalid input")
}
}
// ── other keys blocked in input state ────────────────────────────────────────
func TestInputState_BlocksOtherKeys(t *testing.T) {
issues := []issue.Issue{
{Number: 1, Column: issue.ColumnTodo},
{Number: 2, Column: issue.ColumnTodo},
}
m := makeLoadedModel(issues, 0, 0)
m, _ = sendKey(m, "p")
// Try sending 'q' — should NOT quit (it's still in input state)
m2, cmd := sendKey(m, "q")
if m2.state == stateLoaded && cmd != nil {
// If cmd is tea.Quit the program would exit; we just verify state stays input-ish
// In practice tea.Quit is a Cmd, so if it was dispatched that's a bug.
// The simplest check: 'q' in input mode should forward to textinput, not quit.
}
// State should still be stateInput (q is just typed into the textinput)
if m2.state != stateInput {
t.Errorf("state = %v after 'q' in input mode, want stateInput (should not quit)", m2.state)
}
}
// ── P key clears prerequisite ────────────────────────────────────────────────
func TestPShift_ClearsPrerequisite(t *testing.T) {
issues := []issue.Issue{
{Number: 10, Column: issue.ColumnTodo, Prerequisite: 3},
}
m := makeLoadedModel(issues, 0, 0)
m2, cmd := sendKey(m, "P")
if cmd == nil {
t.Error("cmd should be non-nil after P (clearDepCmd)")
}
// Optimistic clear
for _, iss := range m2.issues {
if iss.Number == 10 {
if iss.Prerequisite != 0 {
t.Errorf("optimistic Prerequisite = %d, want 0 after P", iss.Prerequisite)
}
return
}
}
t.Error("issue #10 not found")
}
func TestPShift_NoPrereq_NoOp(t *testing.T) {
issues := []issue.Issue{
{Number: 10, Column: issue.ColumnTodo, Prerequisite: 0},
}
m := makeLoadedModel(issues, 0, 0)
m2, cmd := sendKey(m, "P")
if cmd != nil {
t.Error("cmd should be nil when no prereq to clear")
}
_ = m2
}
// ── setDepResultMsg optimistic update ────────────────────────────────────────
func TestSetDepResult_Success_UpdatesPrerequisite(t *testing.T) {
issues := []issue.Issue{{Number: 5, Column: issue.ColumnTodo, Prerequisite: 0}}
m := makeLoadedModel(issues, 0, 0)
msg := setDepResultMsg{issueNumber: 5, prerequisite: 2, err: nil}
next, _ := m.Update(msg)
m2 := next.(Model)
for _, iss := range m2.issues {
if iss.Number == 5 {
if iss.Prerequisite != 2 {
t.Errorf("Prerequisite = %d, want 2", iss.Prerequisite)
}
return
}
}
t.Error("issue #5 not found")
}
func TestSetDepResult_Error_RollsBackToOldPrereq(t *testing.T) {
// Issue starts with prereq=5; user tries to set to 7 (optimistic write already done).
issues := []issue.Issue{{Number: 5, Column: issue.ColumnTodo, Prerequisite: 7}}
m := makeLoadedModel(issues, 0, 0)
msg := setDepResultMsg{issueNumber: 5, prerequisite: 7, oldPrereq: 5, err: errors.New("network error")}
next, _ := m.Update(msg)
m2 := next.(Model)
if m2.statusMsg == "" {
t.Error("statusMsg should be set on setDep error")
}
for _, iss := range m2.issues {
if iss.Number == 5 {
if iss.Prerequisite != 5 {
t.Errorf("Prerequisite = %d, want 5 (rollback to old value)", iss.Prerequisite)
}
return
}
}
t.Error("issue #5 not found")
}
// ── clearDepResultMsg optimistic update ─────────────────────────────────────
func TestClearDepResult_Success_ClearsPrerequisite(t *testing.T) {
issues := []issue.Issue{{Number: 5, Column: issue.ColumnTodo, Prerequisite: 3}}
m := makeLoadedModel(issues, 0, 0)
msg := clearDepResultMsg{issueNumber: 5, oldPrereq: 3, err: nil}
next, _ := m.Update(msg)
m2 := next.(Model)
for _, iss := range m2.issues {
if iss.Number == 5 {
if iss.Prerequisite != 0 {
t.Errorf("Prerequisite = %d, want 0 after clear", iss.Prerequisite)
}
return
}
}
t.Error("issue #5 not found")
}
func TestClearDepResult_Error_RollsBackToOldPrereq(t *testing.T) {
// Issue starts with prereq=3 already optimistically cleared to 0.
issues := []issue.Issue{{Number: 5, Column: issue.ColumnTodo, Prerequisite: 0}}
m := makeLoadedModel(issues, 0, 0)
msg := clearDepResultMsg{issueNumber: 5, oldPrereq: 3, err: errors.New("api error")}
next, _ := m.Update(msg)
m2 := next.(Model)
if m2.statusMsg == "" {
t.Error("statusMsg should be set on clearDep error")
}
for _, iss := range m2.issues {
if iss.Number == 5 {
if iss.Prerequisite != 3 {
t.Errorf("Prerequisite = %d, want 3 (rollback to old value)", iss.Prerequisite)
}
return
}
}
t.Error("issue #5 not found")
}
// ── statusBar shows input prompt ──────────────────────────────────────────────
func TestStatusBar_ShowsInputPrompt(t *testing.T) {
issues := []issue.Issue{{Number: 42, Column: issue.ColumnTodo}}
m := makeLoadedModel(issues, 0, 0)
m, _ = sendKey(m, "p")
bar := m.statusBar()
if bar == "" {
t.Error("statusBar should show input prompt in stateInput")
}
}