Make dashboard updateable
This commit is contained in:
361
tui_test.go
361
tui_test.go
@@ -2,6 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -21,6 +24,36 @@ type recordingService struct {
|
||||
writeResolved bool
|
||||
}
|
||||
|
||||
type recordingPRService struct {
|
||||
recordingService
|
||||
updateID string
|
||||
update PullRequestMetadata
|
||||
updateErr error
|
||||
branches []RepositoryBranch
|
||||
branchErr error
|
||||
}
|
||||
|
||||
func (s *recordingPRService) UpdatePullRequest(
|
||||
_ context.Context,
|
||||
id string,
|
||||
update PullRequestMetadata,
|
||||
) (PullRequestMetadata, error) {
|
||||
s.updateID, s.update = id, update
|
||||
if s.updateErr != nil {
|
||||
return PullRequestMetadata{}, s.updateErr
|
||||
}
|
||||
update.Mergeable = "UNKNOWN"
|
||||
update.MergeState = "UNKNOWN"
|
||||
update.UpdatedAt = time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
|
||||
return update, nil
|
||||
}
|
||||
|
||||
func (s *recordingPRService) ListBranches(
|
||||
_ context.Context, _, _ string,
|
||||
) ([]RepositoryBranch, error) {
|
||||
return append([]RepositoryBranch(nil), s.branches...), s.branchErr
|
||||
}
|
||||
|
||||
func (s *recordingService) SetThreadResolved(
|
||||
_ context.Context, threadID string, resolved bool,
|
||||
) (ReviewThread, error) {
|
||||
@@ -513,6 +546,334 @@ func TestWriteCapabilityGateExplainsCachedAndPermissionStates(t *testing.T) {
|
||||
if !live[0].enabled || live[1].enabled || live[2].enabled {
|
||||
t.Fatalf("live capabilities = %#v", live)
|
||||
}
|
||||
updatable := writeCapabilities(PRDetails{Permissions: ViewerPermissions{CanUpdatePR: true}}, thread)
|
||||
if !updatable[3].enabled || updatable[3].name != "update pull request" {
|
||||
t.Fatalf("pull request update capability = %#v", updatable[3])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardEditorUpdatesTitleBodyAndBaseBranch(t *testing.T) {
|
||||
service := &recordingPRService{}
|
||||
m := NewApp(service, "o", "r", false, 50, time.Second)
|
||||
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 80, 30
|
||||
m.details = PRDetails{
|
||||
PullRequest: PullRequest{
|
||||
ID: "pr", Owner: "o", Repository: "r", RepoWithOwner: "o/r",
|
||||
Number: 1, Title: "Old title",
|
||||
},
|
||||
Body: "- [ ] first\n- [ ] second", BaseRef: "main",
|
||||
Permissions: ViewerPermissions{CanUpdatePR: true},
|
||||
}
|
||||
|
||||
send := func(key tea.KeyMsg) tea.Cmd {
|
||||
updated, command := m.Update(key)
|
||||
m = updated.(App)
|
||||
return command
|
||||
}
|
||||
send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")})
|
||||
if m.writeMode != writePREdit || m.prEditField != prEditBodyField {
|
||||
t.Fatalf("edit key produced mode=%d field=%d", m.writeMode, m.prEditField)
|
||||
}
|
||||
editor := ansi.Strip(strings.Join(m.dashboardLines(), "\n"))
|
||||
if !strings.Contains(editor, "EDITING") || !strings.Contains(editor, "- [ ] first") {
|
||||
t.Fatalf("dashboard editor does not show the raw description:\n%s", editor)
|
||||
}
|
||||
|
||||
send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("f")})
|
||||
send(tea.KeyMsg{Type: tea.KeySpace, Runes: []rune(" ")})
|
||||
send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(";")})
|
||||
send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("i")})
|
||||
send(tea.KeyMsg{Type: tea.KeyDelete})
|
||||
send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")})
|
||||
send(tea.KeyMsg{Type: tea.KeyEsc})
|
||||
|
||||
send(tea.KeyMsg{Type: tea.KeyShiftTab})
|
||||
send(tea.KeyMsg{Type: tea.KeyHome})
|
||||
for range len("main") {
|
||||
send(tea.KeyMsg{Type: tea.KeyDelete})
|
||||
}
|
||||
send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("release")})
|
||||
|
||||
send(tea.KeyMsg{Type: tea.KeyShiftTab})
|
||||
send(tea.KeyMsg{Type: tea.KeyHome})
|
||||
for range len("Old title") {
|
||||
send(tea.KeyMsg{Type: tea.KeyDelete})
|
||||
}
|
||||
send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("New title")})
|
||||
|
||||
send(tea.KeyMsg{Type: tea.KeyCtrlS})
|
||||
if m.writeMode != writePREditConfirm {
|
||||
t.Fatalf("ctrl-s produced mode=%d, error=%v", m.writeMode, m.err)
|
||||
}
|
||||
command := send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")})
|
||||
if command == nil || m.writeMode != writePREditBusy {
|
||||
t.Fatalf("confirmation produced mode=%d command=%v", m.writeMode, command)
|
||||
}
|
||||
updated, refresh := m.Update(command())
|
||||
m = updated.(App)
|
||||
if refresh == nil || service.updateID != "pr" {
|
||||
t.Fatalf("update did not submit and refresh: id=%q refresh=%v", service.updateID, refresh)
|
||||
}
|
||||
if service.update.Title != "New title" || service.update.BaseRef != "release" ||
|
||||
service.update.Body != "- [x] first\n- [ ] second" {
|
||||
t.Fatalf("submitted metadata = %#v", service.update)
|
||||
}
|
||||
if m.writeMode != writeNone || m.details.Title != "New title" ||
|
||||
m.details.BaseRef != "release" || m.details.Body != service.update.Body {
|
||||
t.Fatalf("local metadata was not updated: mode=%d details=%#v", m.writeMode, m.details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardEditorPreservesDraftAfterMutationFailure(t *testing.T) {
|
||||
service := &recordingPRService{updateErr: errors.New("base branch does not exist")}
|
||||
m := NewApp(service, "o", "r", false, 50, time.Second)
|
||||
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 80, 30
|
||||
m.details = PRDetails{
|
||||
PullRequest: PullRequest{
|
||||
ID: "pr", Owner: "o", Repository: "r", RepoWithOwner: "o/r",
|
||||
Number: 1, Title: "Title",
|
||||
},
|
||||
Body: "description", BaseRef: "main",
|
||||
Permissions: ViewerPermissions{CanUpdatePR: true},
|
||||
}
|
||||
m.startPREdit()
|
||||
m.prEditEditors[prEditBodyField].Text = "changed description"
|
||||
m.prEditEditors[prEditBodyField].Cursor = len([]rune("changed description"))
|
||||
m.writeMode = writePREditBusy
|
||||
message := m.submitPREdit()()
|
||||
updated, _ := m.Update(message)
|
||||
m = updated.(App)
|
||||
if m.writeMode != writePREdit || m.prEditEditors[prEditBodyField].Text != "changed description" ||
|
||||
m.err == nil || !strings.Contains(m.err.Error(), "base branch does not exist") {
|
||||
t.Fatalf("failed mutation lost editor state: mode=%d body=%q err=%v",
|
||||
m.writeMode, m.prEditEditors[prEditBodyField].Text, m.err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardEditorRejectsStaleMetadata(t *testing.T) {
|
||||
service := &recordingPRService{}
|
||||
m := NewApp(service, "o", "r", false, 50, time.Second)
|
||||
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 80, 30
|
||||
m.details = PRDetails{
|
||||
PullRequest: PullRequest{ID: "pr", Title: "Title"},
|
||||
Body: "original", BaseRef: "main",
|
||||
Permissions: ViewerPermissions{CanUpdatePR: true},
|
||||
}
|
||||
m.startPREdit()
|
||||
m.prEditEditors[prEditBodyField].Text = "my edit"
|
||||
m.details.Body = "remote edit"
|
||||
updated, command := m.updatePREditInput(tea.KeyMsg{Type: tea.KeyCtrlS})
|
||||
m = updated.(App)
|
||||
if command != nil || m.writeMode != writePREdit || m.err == nil ||
|
||||
!strings.Contains(m.err.Error(), "changed while editing") {
|
||||
t.Fatalf("stale metadata was not blocked: mode=%d command=%v err=%v", m.writeMode, command, m.err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardDescriptionCanUseStandardEditingMode(t *testing.T) {
|
||||
settings := defaultAppSettings()
|
||||
settings.EditorMode = "standard"
|
||||
m := NewAppWithSettings(&recordingPRService{}, "o", "r", false, 50, time.Second, settings)
|
||||
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 80, 30
|
||||
m.details = PRDetails{
|
||||
PullRequest: PullRequest{ID: "pr", Title: "Title"},
|
||||
Body: "body", BaseRef: "main",
|
||||
Permissions: ViewerPermissions{CanUpdatePR: true},
|
||||
}
|
||||
m.startPREdit()
|
||||
editor := m.prEditEditors[prEditBodyField]
|
||||
if editor.Modal || editor.Mode != textEditorInsert || editor.Cursor != len([]rune("body")) {
|
||||
t.Fatalf("standard description editor = %#v", editor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardVimEscapeReturnsToNormalBeforeClosingEditor(t *testing.T) {
|
||||
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
|
||||
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 80, 30
|
||||
m.details = PRDetails{
|
||||
PullRequest: PullRequest{ID: "pr", Title: "Title"},
|
||||
Body: "body", BaseRef: "main",
|
||||
Permissions: ViewerPermissions{CanUpdatePR: true},
|
||||
}
|
||||
m.startPREdit()
|
||||
|
||||
updated, _ := m.updatePREditInput(runeKey("i"))
|
||||
m = updated.(App)
|
||||
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyEsc})
|
||||
m = updated.(App)
|
||||
if m.writeMode != writePREdit || m.prEditEditors[prEditBodyField].Mode != textEditorNormal {
|
||||
t.Fatalf("insert escape closed editor: write=%d mode=%s", m.writeMode, m.prEditEditors[prEditBodyField].Mode)
|
||||
}
|
||||
|
||||
updated, _ = m.updatePREditInput(runeKey("v"))
|
||||
m = updated.(App)
|
||||
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyEsc})
|
||||
m = updated.(App)
|
||||
if m.writeMode != writePREdit || m.prEditEditors[prEditBodyField].Mode != textEditorNormal {
|
||||
t.Fatalf("visual escape closed editor: write=%d mode=%s", m.writeMode, m.prEditEditors[prEditBodyField].Mode)
|
||||
}
|
||||
|
||||
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyEsc})
|
||||
m = updated.(App)
|
||||
if m.writeMode != writeNone {
|
||||
t.Fatalf("normal escape did not close editor: write=%d", m.writeMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardPositionsHardwareCursorAtInsertBoundary(t *testing.T) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "cursor-output")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
|
||||
m.cursorOutput = newTerminalCursorOutput(file)
|
||||
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 40, 20
|
||||
m.details = PRDetails{
|
||||
PullRequest: PullRequest{ID: "pr", Title: "Title"},
|
||||
Body: "abcdefghij", BaseRef: "main",
|
||||
Permissions: ViewerPermissions{CanUpdatePR: true},
|
||||
}
|
||||
m.startPREdit()
|
||||
m.prEditEditors[prEditBodyField].Cursor = 4
|
||||
m.prEditEditors[prEditBodyField].Mode = textEditorInsert
|
||||
m.positionPREditHardwareCursor(0, m.dashboardViewportHeight())
|
||||
|
||||
m.cursorOutput.mu.Lock()
|
||||
visible, column, row := m.cursorOutput.visible, m.cursorOutput.column, m.cursorOutput.row
|
||||
m.cursorOutput.mu.Unlock()
|
||||
if !visible || column != 7 || row <= 0 {
|
||||
t.Fatalf("hardware cursor visible=%v column=%d row=%d", visible, column, row)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardReturningToTitleRestoresTopAndFieldLabel(t *testing.T) {
|
||||
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
|
||||
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 50, 12
|
||||
m.details = PRDetails{
|
||||
PullRequest: PullRequest{ID: "pr", Title: "Title"},
|
||||
Body: strings.Repeat("description line\n", 30), BaseRef: "main",
|
||||
Permissions: ViewerPermissions{CanUpdatePR: true},
|
||||
}
|
||||
m.startPREdit()
|
||||
m.scroll = 30
|
||||
|
||||
updated, _ := m.updatePREditInput(tea.KeyMsg{Type: tea.KeyTab})
|
||||
m = updated.(App)
|
||||
if m.prEditField != prEditTitleField || m.scroll != 0 {
|
||||
t.Fatalf("title navigation field=%d scroll=%d", m.prEditField, m.scroll)
|
||||
}
|
||||
view := ansi.Strip(m.viewDashboard())
|
||||
if !strings.Contains(view, "Edit pull request") || !strings.Contains(view, "title") {
|
||||
t.Fatalf("title context is not visible:\n%s", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardEditorHalfPageMotionsMoveCursorAndViewport(t *testing.T) {
|
||||
var body strings.Builder
|
||||
for index := range 30 {
|
||||
fmt.Fprintf(&body, "line %02d\n", index)
|
||||
}
|
||||
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
|
||||
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 50, 12
|
||||
m.details = PRDetails{
|
||||
PullRequest: PullRequest{ID: "pr", Title: "Title"},
|
||||
Body: body.String(), BaseRef: "main",
|
||||
Permissions: ViewerPermissions{CanUpdatePR: true},
|
||||
}
|
||||
m.startPREdit()
|
||||
startCursor := m.prEditEditors[prEditBodyField].Cursor
|
||||
startScroll := m.scroll
|
||||
|
||||
updated, _ := m.updatePREditInput(tea.KeyMsg{Type: tea.KeyCtrlD})
|
||||
m = updated.(App)
|
||||
if m.prEditEditors[prEditBodyField].Cursor <= startCursor || m.scroll <= startScroll {
|
||||
t.Fatalf("ctrl-d cursor=%d scroll=%d", m.prEditEditors[prEditBodyField].Cursor, m.scroll)
|
||||
}
|
||||
|
||||
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyCtrlU})
|
||||
m = updated.(App)
|
||||
if m.prEditEditors[prEditBodyField].Cursor != startCursor || m.scroll != startScroll {
|
||||
t.Fatalf(
|
||||
"ctrl-u cursor=%d want=%d scroll=%d want=%d",
|
||||
m.prEditEditors[prEditBodyField].Cursor, startCursor, m.scroll, startScroll,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardEditorHalfPageMotionExtendsVisualSelection(t *testing.T) {
|
||||
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
|
||||
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 30, 12
|
||||
m.details = PRDetails{
|
||||
PullRequest: PullRequest{ID: "pr", Title: "Title"},
|
||||
Body: strings.Repeat("abcdefghij", 20), BaseRef: "main",
|
||||
Permissions: ViewerPermissions{CanUpdatePR: true},
|
||||
}
|
||||
m.startPREdit()
|
||||
updated, _ := m.updatePREditInput(runeKey("v"))
|
||||
m = updated.(App)
|
||||
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyCtrlD})
|
||||
m = updated.(App)
|
||||
editor := m.prEditEditors[prEditBodyField]
|
||||
start, end, selected := editor.selectionBounds(m.prEditEditorWidth())
|
||||
if editor.Mode != textEditorVisual || !selected || end-start <= 1 {
|
||||
t.Fatalf("visual ctrl-d mode=%s selection=%d:%d selected=%v", editor.Mode, start, end, selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardHardwareCursorMovementChangesZeroWidthFrameMarker(t *testing.T) {
|
||||
file, err := os.CreateTemp(t.TempDir(), "cursor-output")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
|
||||
m.cursorOutput = newTerminalCursorOutput(file)
|
||||
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 40, 20
|
||||
m.details = PRDetails{
|
||||
PullRequest: PullRequest{ID: "pr", Title: "Title"},
|
||||
Body: "abcdef", BaseRef: "main",
|
||||
Permissions: ViewerPermissions{CanUpdatePR: true},
|
||||
}
|
||||
m.startPREdit()
|
||||
m.prEditEditors[prEditBodyField].Mode = textEditorInsert
|
||||
first := m.viewDashboard()
|
||||
m.prEditEditors[prEditBodyField].Cursor++
|
||||
second := m.viewDashboard()
|
||||
if first == second {
|
||||
t.Fatal("hardware-cursor-only movement produced an identical frame")
|
||||
}
|
||||
if ansi.Strip(first) != ansi.Strip(second) {
|
||||
t.Fatal("hardware cursor marker changed visible frame content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardEditorNormalizesMixedLineEndingsWithoutCreatingAnEdit(t *testing.T) {
|
||||
const remoteBody = "first\nsecond\r\nthird\r"
|
||||
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
|
||||
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 80, 30
|
||||
m.details = PRDetails{
|
||||
PullRequest: PullRequest{ID: "pr", Title: "Title"},
|
||||
Body: remoteBody, BaseRef: "main",
|
||||
Permissions: ViewerPermissions{CanUpdatePR: true},
|
||||
}
|
||||
m.startPREdit()
|
||||
if got := m.prEditEditors[prEditBodyField].Text; got != "first\nsecond\nthird\n" {
|
||||
t.Fatalf("editor body = %q", got)
|
||||
}
|
||||
if got := m.prEditMetadata().Body; got != remoteBody {
|
||||
t.Fatalf("unchanged payload body = %q, want original %q", got, remoteBody)
|
||||
}
|
||||
if err := m.validatePREdit(); err == nil || !strings.Contains(err.Error(), "unchanged") {
|
||||
t.Fatalf("line-ending normalization counted as an edit: %v", err)
|
||||
}
|
||||
|
||||
m.prEditEditors[prEditBodyField].Text += "changed"
|
||||
if got := m.prEditMetadata().Body; strings.ContainsRune(got, '\r') {
|
||||
t.Fatalf("edited payload retained carriage returns: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplyComposerConfirmsAndAddsReturnedComment(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user