| 📦 Turbo Core — files rewritten outside reload into their windows; the agent window wraps what you type and keeps a long paste aside as a token (#28); configrepo fetches a shared .turbo-<slug> from a forge URL (#25) fe23288 k33g 10h ago | 1 | package acp_test |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "testing" |
| 6 | |
| 7 | "github.com/gdamore/tcell/v2" |
| 8 | |
| 9 | "rickub.com/turbo-editors/turbo-core/acp" |
| 10 | "rickub.com/turbo-editors/turbo-core/theme" |
| 11 | "rickub.com/turbo-editors/turbo-core/ui" |
| 12 | ) |
| 13 | |
| 14 | // drawWithCursor paints a focused view and returns the rows and where the |
| 15 | // terminal's cursor was put — or -1, -1 when it was hidden, which is what |
| 16 | // happened to a cursor that ran off the edge of the box. |
| 17 | func drawWithCursor(t *testing.T, view *acp.View, width, height int) (rows []string, x, y int) { |
| 18 | t.Helper() |
| 19 | |
| 20 | screen := tcell.NewSimulationScreen("UTF-8") |
| 21 | if err := screen.Init(); err != nil { |
| 22 | t.Fatalf("starting the screen: %v", err) |
| 23 | } |
| 24 | defer screen.Fini() |
| 25 | screen.SetSize(width, height) |
| 26 | |
| 27 | th, err := theme.Load(theme.DefaultName, "") |
| 28 | if err != nil { |
| 29 | t.Fatalf("loading the theme: %v", err) |
| 30 | } |
| 31 | |
| 32 | view.SetFocused(true) |
| 33 | view.Draw(ui.NewPainter(screen), th) |
| 34 | screen.Show() |
| 35 | |
| 36 | cells, w, h := screen.GetContents() |
| 37 | rows = make([]string, h) |
| 38 | for row := range h { |
| 39 | var text strings.Builder |
| 40 | for col := range w { |
| 41 | text.WriteString(string(cells[row*w+col].Runes)) |
| 42 | } |
| 43 | rows[row] = strings.TrimRight(text.String(), " ") |
| 44 | } |
| 45 | |
| 46 | x, y, visible := screen.GetCursor() |
| 47 | if !visible { |
| 48 | return rows, -1, -1 |
| 49 | } |
| 50 | return rows, x, y |
| 51 | } |
| 52 | |
| 53 | // inputRows returns the rows of the box you type in: everything below the |
| 54 | // rule. |
| 55 | func inputRows(rows []string) []string { |
| 56 | for i, row := range rows { |
| 57 | if strings.Contains(row, "─") { |
| 58 | return rows[i+1:] |
| 59 | } |
| 60 | } |
| 61 | return nil |
| 62 | } |
| 63 | |
| 64 | func typeInto(view *acp.View, text string) { |
| 65 | for _, r := range text { |
| 66 | view.HandleKey(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone)) |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | func TestALongPromptWrapsAtSpacesInsideTheBox(t *testing.T) { |
| 71 | // This is the feature: before, the line was cut off with an ellipsis at |
| 72 | // the edge and the rest of what you typed was invisible. |
| 73 | view, _ := newView(t, 30, 12) |
| 74 | typeInto(view, "please explain how the wrapping of a long prompt works") |
| 75 | |
| 76 | rows, _, _ := drawWithCursor(t, view, 30, 12) |
| 77 | box := inputRows(rows) |
| 78 | |
| 79 | if !strings.HasPrefix(box[0], "> please explain how the") || box[1] != " wrapping of a long prompt" || box[2] != " works" { |
| 80 | t.Errorf("the box is not wrapped at spaces:\n%s", strings.Join(box, "\n")) |
| 81 | } |
| 82 | if strings.Contains(strings.Join(box, "\n"), "…") { |
| 83 | t.Errorf("the box still cuts the prompt off:\n%s", strings.Join(box, "\n")) |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | func TestTheCursorFollowsTheTextOntoTheWrappedRow(t *testing.T) { |
| 88 | view, _ := newView(t, 30, 12) |
| 89 | typeInto(view, "please explain how the wrapping") |
| 90 | |
| 91 | _, x, y := drawWithCursor(t, view, 30, 12) |
| 92 | |
| 93 | // Row 0 of the box is screen row 9 (12 rows: 8 of conversation, the rule, |
| 94 | // then 3 of box); "wrapping" is 8 runes on the second row, after "> " and |
| 95 | // its own indent. |
| 96 | if x != 2+len("wrapping") || y != 10 { |
| 97 | t.Errorf("cursor at (%d, %d), want it after %q on the second row (%d, %d)", x, y, "wrapping", 2+len("wrapping"), 10) |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | func TestTheCursorAtTheEndOfAFullRowStaysInsideThePane(t *testing.T) { |
| 102 | // A row holds width-3 runes: prompt, space, and a column for exactly |
| 103 | // this cursor. Without it the cursor sat on the frame and tcell hid it. |
| 104 | view, _ := newView(t, 30, 12) |
| 105 | typeInto(view, strings.Repeat("x", 27)) |
| 106 | |
| 107 | _, x, y := drawWithCursor(t, view, 30, 12) |
| 108 | |
| 109 | if x != 29 || y != 9 { |
| 110 | t.Errorf("cursor at (%d, %d), want (29, 9): the last column of the pane, first row of the box", x, y) |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | func TestAWordLongerThanTheBoxBreaksAtTheEdgeRatherThanVanishing(t *testing.T) { |
| 115 | view, _ := newView(t, 30, 12) |
| 116 | typeInto(view, strings.Repeat("a", 40)) |
| 117 | |
| 118 | rows, _, _ := drawWithCursor(t, view, 30, 12) |
| 119 | box := inputRows(rows) |
| 120 | |
| 121 | if box[0] != "> "+strings.Repeat("a", 27) || box[1] != " "+strings.Repeat("a", 13) { |
| 122 | t.Errorf("a long word is not broken at the edge:\n%s", strings.Join(box, "\n")) |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | func TestUpAndDownMoveByRowInsideAWrappedPrompt(t *testing.T) { |
| 127 | // Three rows to the eye are three rows to the arrow keys. Up from the last |
| 128 | // row lands on the middle one, not on the conversation. |
| 129 | view, _ := newView(t, 30, 12) |
| 130 | typeInto(view, "please explain how the wrapping of a long prompt works") |
| 131 | |
| 132 | if !view.HandleKey(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone)) { |
| 133 | t.Fatal("Up was not taken by the box, though there is a row above the cursor") |
| 134 | } |
| 135 | _, x, y := drawWithCursor(t, view, 30, 12) |
| 136 | if y != 10 || x != 2+len("works") { |
| 137 | t.Errorf("after Up the cursor is at (%d, %d), want the middle row at the same column (%d, 10)", x, y, 2+len("works")) |
| 138 | } |
| 139 | |
| 140 | view.HandleKey(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone)) |
| 141 | view.HandleKey(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone)) |
| 142 | _, x, y = drawWithCursor(t, view, 30, 12) |
| 143 | if y != 10 || x != 2+len("works") { |
| 144 | t.Errorf("Up then Down did not come back: cursor at (%d, %d)", x, y) |
| 145 | } |
| 146 | |
| 147 | // The column is kept, not the word: five in on the middle row is between |
| 148 | // "wrapp" and "ing", which is where a key pressed now goes. |
| 149 | typeInto(view, "!") |
| 150 | if got := view.Input(); got != "please explain how the wrapp!ing of a long prompt works" { |
| 151 | t.Errorf("typing after the moves went to the wrong place: %q", got) |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | func TestUpFromTheFirstRowOfTheBoxIsLeftForTheConversation(t *testing.T) { |
| 156 | view, _ := newView(t, 30, 12) |
| 157 | typeInto(view, "short") |
| 158 | |
| 159 | if view.HandleKey(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone)) && view.Input() != "short" { |
| 160 | t.Error("Up on a one-row prompt changed the input") |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | func TestAPromptTallerThanTheBoxScrollsToKeepTheCursorInSight(t *testing.T) { |
| 165 | // Three rows of box, five rows of prompt: the first two go up under the |
| 166 | // rule, and the prompt marker goes with them. |
| 167 | view, _ := newView(t, 30, 12) |
| 168 | typeInto(view, "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty") |
| 169 | |
| 170 | rows, _, y := drawWithCursor(t, view, 30, 12) |
| 171 | box := inputRows(rows) |
| 172 | |
| 173 | if y != 11 { |
| 174 | t.Errorf("the cursor is on screen row %d, want the last row of the box, 11", y) |
| 175 | } |
| 176 | if strings.HasPrefix(box[0], ">") { |
| 177 | t.Errorf("the prompt marker is beside a row that is not the first:\n%s", strings.Join(box, "\n")) |
| 178 | } |
| 179 | if !strings.HasSuffix(box[2], "twenty") { |
| 180 | t.Errorf("the last row does not end with what was typed last:\n%s", strings.Join(box, "\n")) |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | func TestATypedTrailingSpaceAtTheEdgePutsTheCursorOnTheNextRow(t *testing.T) { |
| 185 | // 27 runes fill a row; the space after them is the break, not drawn, and |
| 186 | // the cursor is at the start of an empty row below rather than on the frame. |
| 187 | view, _ := newView(t, 30, 12) |
| 188 | typeInto(view, strings.Repeat("x", 27)+" ") |
| 189 | |
| 190 | _, x, y := drawWithCursor(t, view, 30, 12) |
| 191 | if x != 2 || y != 10 { |
| 192 | t.Errorf("cursor at (%d, %d), want (2, 10)", x, y) |
| 193 | } |
| 194 | } |