283 lines
9.2 KiB
Go
283 lines
9.2 KiB
Go
package tui
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
"charm.land/lipgloss/v2"
|
|
"superwork-tui/internal/issue"
|
|
"superwork-tui/internal/store"
|
|
)
|
|
|
|
func TestDetailBody(t *testing.T) {
|
|
autoReview := true
|
|
iss := issue.Issue{
|
|
Number: 42,
|
|
Title: "Implement feature X",
|
|
Column: issue.ColumnInProgress,
|
|
SpecFile: "/specs/42.md",
|
|
PlanFile: "/plans/42.md",
|
|
PrDiffFile: "/diffs/42.diff",
|
|
PR: "65",
|
|
PrMerged: true,
|
|
Branch: "feature/42-implement-x",
|
|
WorktreePath: "/worktrees/42",
|
|
WorktreeExists: true,
|
|
SessionID: "sess-abc",
|
|
ImplementSessionID: "sess-impl",
|
|
ReviewSessionID: "sess-review",
|
|
TestSessionID: "sess-test",
|
|
ProfilePath: "/profiles/offical.json",
|
|
Prerequisite: 10,
|
|
AutoReview: &autoReview,
|
|
}
|
|
|
|
out := Model{}.detailBody(iss, 80)
|
|
|
|
checks := []string{
|
|
"#42",
|
|
"Implement feature X",
|
|
"等待 #10 完成", // prerequisite badge
|
|
"STATE JSON",
|
|
"15 项", // field count
|
|
"状态", "进行中", // column label + zh status
|
|
"sess-abc", "sess-impl", "sess-review", "sess-test",
|
|
"自动审查", "✓ True", // boolean
|
|
"颜色", // color row label
|
|
"实施配置文件", "offical", // profile basename
|
|
"测试配置文件", "(默认)", // empty profile
|
|
"/specs/42.md", "/plans/42.md", "/diffs/42.diff",
|
|
"合并请求", "#65", "已合并", // PR
|
|
"feature/42-implement-x", // branch
|
|
"/worktrees/42", // worktree
|
|
"[b] 头脑风暴", // action hint
|
|
}
|
|
for _, want := range checks {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("detailBody output missing %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDetailView_RendersBoardAbovePropertyPanel verifies the detail view keeps
|
|
// the kanban board visible above a bottom property panel, matching VSCode.
|
|
func TestDetailView_RendersBoardAbovePropertyPanel(t *testing.T) {
|
|
var issues []issue.Issue
|
|
for i := 0; i < 11; i++ {
|
|
issues = append(issues, issue.Issue{Number: 238 - i, Title: "支持部署时使用客户自定义域名", Column: issue.ColumnTodo})
|
|
}
|
|
for i := 0; i < 101; i++ {
|
|
issues = append(issues, issue.Issue{Number: 130 - i, Title: "历史工单", Column: issue.ColumnDone})
|
|
}
|
|
sel := issues[0]
|
|
m := Model{
|
|
state: stateDetail,
|
|
issues: issues,
|
|
buckets: bucketize(issues),
|
|
detailIss: &sel,
|
|
width: 160,
|
|
height: 44,
|
|
}
|
|
out := m.detailView()
|
|
|
|
if !strings.Contains(out, "#238") {
|
|
t.Errorf("detail title missing")
|
|
}
|
|
if !strings.Contains(out, "STATE JSON") {
|
|
t.Errorf("STATE JSON section missing")
|
|
}
|
|
if !strings.Contains(out, "todo (11)") {
|
|
t.Errorf("board header not visible above detail panel")
|
|
}
|
|
if !strings.Contains(out, "╭") {
|
|
t.Errorf("detail panel rounded border missing")
|
|
}
|
|
boardIdx := strings.Index(out, "todo (11)")
|
|
panelIdx := strings.Index(out, "STATE JSON")
|
|
if boardIdx == -1 || panelIdx == -1 || boardIdx > panelIdx {
|
|
t.Errorf("detail view should render board before property panel")
|
|
}
|
|
if h := lipgloss.Height(out); h > m.height {
|
|
t.Errorf("detail split height %d exceeds terminal height %d", h, m.height)
|
|
}
|
|
}
|
|
|
|
func TestBoardView_DefaultDetailPanelFollowsSelection(t *testing.T) {
|
|
issues := []issue.Issue{
|
|
{Number: 1, Title: "First issue", Column: issue.ColumnTodo},
|
|
{Number: 2, Title: "Second issue", Column: issue.ColumnTodo},
|
|
}
|
|
m := makeLoadedModel(issues, 0, 0)
|
|
|
|
out := m.boardView()
|
|
if !strings.Contains(out, "STATE JSON") || !strings.Contains(out, "First issue") {
|
|
t.Fatalf("default board view should show selected issue properties, got: %q", out)
|
|
}
|
|
|
|
m2, _ := sendKey(m, "j")
|
|
if m2.state != stateLoaded {
|
|
t.Fatalf("moving selection should stay in board mode, got state %v", m2.state)
|
|
}
|
|
out = m2.boardView()
|
|
if !strings.Contains(out, "Second issue") {
|
|
t.Fatalf("detail panel should follow moved selection, got: %q", out)
|
|
}
|
|
|
|
m3, _ := sendKey(m2, "enter")
|
|
if m3.state != stateLoaded {
|
|
t.Fatalf("enter should not steal keyboard focus, got state %v", m3.state)
|
|
}
|
|
}
|
|
|
|
func TestBoardView_BottomTabsSwitchWithoutStealingBoardFocus(t *testing.T) {
|
|
issues := []issue.Issue{
|
|
{Number: 1, Title: "First issue", Column: issue.ColumnTodo},
|
|
{Number: 2, Title: "Second issue", Column: issue.ColumnTodo},
|
|
}
|
|
m := makeLoadedModel(issues, 0, 0)
|
|
m.profileGridData = store.ProfilesData{Profiles: []string{"dev"}, Rows: []store.ProfileRow{{Key: "model", Values: map[string]string{"dev": "sonnet"}}}}
|
|
m.managedSessions = store.ManagedSessionsData{Sessions: []store.ManagedSession{{ID: "abcdef123456", Name: "daily", CreatedAt: 1}}}
|
|
|
|
out := m.boardView()
|
|
for _, want := range []string{"1 工单", "2 Profile", "3 会话", "4 提交"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Fatalf("bottom tabs missing %q: %q", want, out)
|
|
}
|
|
}
|
|
|
|
m2, _ := sendKey(m, "2")
|
|
if m2.state != stateLoaded || m2.bottomTab != bottomTabProfile {
|
|
t.Fatalf("profile tab should stay in board mode, state=%v tab=%v", m2.state, m2.bottomTab)
|
|
}
|
|
if out := m2.boardView(); !strings.Contains(out, "Profile") || !strings.Contains(out, "model") {
|
|
t.Fatalf("profile tab body missing expected content: %q", out)
|
|
}
|
|
|
|
m3, _ := sendKey(m2, "j")
|
|
if m3.state != stateLoaded || m3.rowIdx != 1 {
|
|
t.Fatalf("board selection should still move while tab is active, state=%v row=%d", m3.state, m3.rowIdx)
|
|
}
|
|
|
|
m4, _ := sendKey(m3, "3")
|
|
if out := m4.boardView(); !strings.Contains(out, "会话") || !strings.Contains(out, "daily") {
|
|
t.Fatalf("sessions tab body missing expected content: %q", out)
|
|
}
|
|
|
|
m5, _ := sendKey(m4, "tab")
|
|
if m5.focusPane != focusPanel || m5.bottomTab != bottomTabSessions {
|
|
t.Fatalf("tab should focus panel without changing tab, focus=%v tab=%v", m5.focusPane, m5.bottomTab)
|
|
}
|
|
m6, _ := sendKey(m5, "j")
|
|
if m6.focusPane != focusPanel || m6.bottomTab != bottomTabCommits {
|
|
t.Fatalf("panel-focused j on tabs should cycle to commits, focus=%v tab=%v", m6.focusPane, m6.bottomTab)
|
|
}
|
|
m7, _ := sendKey(m6, "tab")
|
|
if m7.focusPane != focusBoard {
|
|
t.Fatalf("second tab should focus board, got %v", m7.focusPane)
|
|
}
|
|
}
|
|
|
|
func TestBoardView_PanelActionBarKeyboardAndMouse(t *testing.T) {
|
|
issues := []issue.Issue{
|
|
{Number: 1, Title: "First issue", Column: issue.ColumnTodo},
|
|
}
|
|
m := makeLoadedModel(issues, 0, 0)
|
|
m.height = 40
|
|
|
|
m2, _ := sendKey(m, "tab")
|
|
m3, _ := sendKey(m2, "j")
|
|
if m3.panelFocus != panelFocusActions {
|
|
t.Fatalf("down in focused issue panel should enter action row, got %v", m3.panelFocus)
|
|
}
|
|
m4, _ := sendKey(m3, "l")
|
|
if m4.actionIdx != 1 {
|
|
t.Fatalf("right should move action selection, got %d", m4.actionIdx)
|
|
}
|
|
m5, cmd := sendKey(m4, "enter")
|
|
if cmd != nil {
|
|
t.Fatalf("implement action without plan should not start command")
|
|
}
|
|
if !strings.Contains(m5.statusMsg, "无计划文件") {
|
|
t.Fatalf("enter should trigger selected action, status=%q", m5.statusMsg)
|
|
}
|
|
|
|
panelY, ok := m.panelStartY()
|
|
if !ok {
|
|
t.Fatal("expected panel start")
|
|
}
|
|
clickImplement := tea.MouseClickMsg(tea.Mouse{X: 18, Y: panelY + 4, Button: tea.MouseLeft})
|
|
next, cmd := m.Update(clickImplement)
|
|
m6 := next.(Model)
|
|
if cmd != nil {
|
|
t.Fatalf("mouse implement action without plan should not start command")
|
|
}
|
|
if m6.focusPane != focusPanel || m6.panelFocus != panelFocusActions || m6.actionIdx != 1 {
|
|
t.Fatalf("mouse click should focus action, focus=%v panel=%v action=%d", m6.focusPane, m6.panelFocus, m6.actionIdx)
|
|
}
|
|
if !strings.Contains(m6.statusMsg, "无计划文件") {
|
|
t.Fatalf("mouse click should trigger action, status=%q", m6.statusMsg)
|
|
}
|
|
}
|
|
|
|
func TestBoardView_PanelActionBarMouseHover(t *testing.T) {
|
|
issues := []issue.Issue{
|
|
{Number: 1, Title: "First issue", Column: issue.ColumnTodo},
|
|
}
|
|
m := makeLoadedModel(issues, 0, 0)
|
|
m.height = 40
|
|
|
|
panelY, ok := m.panelStartY()
|
|
if !ok {
|
|
t.Fatal("expected panel start")
|
|
}
|
|
|
|
hoverImplement := tea.MouseMotionMsg(tea.Mouse{X: 18, Y: panelY + 4})
|
|
next, _ := m.Update(hoverImplement)
|
|
m2 := next.(Model)
|
|
if !m2.hoverActionOK || m2.hoverActionIdx != 1 {
|
|
t.Fatalf("hover should select implement action, ok=%v idx=%d", m2.hoverActionOK, m2.hoverActionIdx)
|
|
}
|
|
|
|
gapBetweenActions := tea.MouseMotionMsg(tea.Mouse{X: 15, Y: panelY + 4})
|
|
next, _ = m2.Update(gapBetweenActions)
|
|
mGap := next.(Model)
|
|
if mGap.hoverActionOK {
|
|
t.Fatalf("hover should not select gap between actions")
|
|
}
|
|
|
|
leave := tea.MouseMotionMsg(tea.Mouse{X: 2, Y: 1})
|
|
next, _ = mGap.Update(leave)
|
|
m3 := next.(Model)
|
|
if m3.hoverActionOK {
|
|
t.Fatalf("hover should clear after leaving action row")
|
|
}
|
|
}
|
|
|
|
func TestBoardView_MouseClickChangesFocusedPaneAndTab(t *testing.T) {
|
|
issues := []issue.Issue{
|
|
{Number: 1, Title: "First issue", Column: issue.ColumnTodo},
|
|
}
|
|
m := makeLoadedModel(issues, 0, 0)
|
|
m.height = 40
|
|
|
|
panelY, ok := m.panelStartY()
|
|
if !ok {
|
|
t.Fatal("expected panel start")
|
|
}
|
|
|
|
clickPanel := tea.MouseClickMsg(tea.Mouse{X: 22, Y: panelY, Button: tea.MouseLeft})
|
|
next, _ := m.Update(clickPanel)
|
|
m2 := next.(Model)
|
|
if m2.focusPane != focusPanel || m2.bottomTab != bottomTabSessions {
|
|
t.Fatalf("clicking sessions tab should focus panel and switch tab, focus=%v tab=%v", m2.focusPane, m2.bottomTab)
|
|
}
|
|
|
|
clickBoard := tea.MouseClickMsg(tea.Mouse{X: 2, Y: 1, Button: tea.MouseLeft})
|
|
next, _ = m2.Update(clickBoard)
|
|
m3 := next.(Model)
|
|
if m3.focusPane != focusBoard {
|
|
t.Fatalf("clicking board should focus board, got %v", m3.focusPane)
|
|
}
|
|
}
|