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