Files
superwork/tui/internal/tui/detail_test.go
T
2026-06-24 02:32:22 +08:00

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