| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "testing" |
| 6 | |
| 7 | tea "charm.land/bubbletea/v2" |
| 8 | |
| 9 | "reasonix/internal/control" |
| 10 | "reasonix/internal/event" |
| 11 | ) |
| 12 | |
| 13 | // TestWorkingLineRowsWrapAccounting pins the pure wrap-count helper shared by |
| 14 | // the composer cursor offset (rowsAboveBox) and the bottomRows height budget: |
| 15 | // the working (spinner) line above the input box occupies more than one |
| 16 | // terminal row on narrow terminals, and the helper must report the wrapped |
| 17 | // count, not 1. |
| 18 | func TestWorkingLineRowsWrapAccounting(t *testing.T) { |
| 19 | cases := []struct { |
| 20 | name string |
| 21 | text string |
| 22 | width int |
| 23 | want int |
| 24 | }{ |
| 25 | {name: "hidden line", text: "", width: 30, want: 0}, |
| 26 | {name: "no width", text: "Thinking…", width: 0, want: 0}, |
| 27 | {name: "fits one row", text: " Thinking… 5s", width: 30, want: 1}, |
| 28 | {name: "exact fit stays one row", text: strings.Repeat("x", 30), width: 30, want: 1}, |
| 29 | {name: "wraps to two rows", text: strings.Repeat("x", 60), width: 30, want: 2}, |
| 30 | {name: "wraps to three rows", text: strings.Repeat("x", 61), width: 30, want: 3}, |
| 31 | } |
| 32 | for _, tc := range cases { |
| 33 | t.Run(tc.name, func(t *testing.T) { |
| 34 | if got := workingLineRows(tc.text, tc.width); got != tc.want { |
| 35 | t.Fatalf("workingLineRows(%q, %d) = %d, want %d", tc.text, tc.width, got, tc.want) |
| 36 | } |
| 37 | }) |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | // TestWorkingLineWrapKeepsComposerCursorInBox is the #7537 regression test: on |
| 42 | // a narrow terminal the working (spinner) line above the composer wraps to |
| 43 | // multiple terminal rows, and View() must still anchor the real terminal |
| 44 | // cursor inside the composer box instead of drifting above it. |
| 45 | func TestWorkingLineWrapKeepsComposerCursorInBox(t *testing.T) { |
| 46 | ctrl := control.New(control.Options{}) |
| 47 | m := newChatTUI(ctrl, "", make(chan event.Event, 1), 24) |
| 48 | |
| 49 | m0, _ := m.Update(tea.WindowSizeMsg{Width: 24, Height: 12}) |
| 50 | m = m0.(chatTUI) |
| 51 | m.state = tuiRunning |
| 52 | m.elapsed = 1000000 |
| 53 | m.turnTokens = 9999999 |
| 54 | |
| 55 | // Precondition: the working line really wraps at this width, otherwise this |
| 56 | // test would not exercise the bug. |
| 57 | working := m.runningWorkingLine(false, false) |
| 58 | wrappedRows := workingLineRows(working, m.width) |
| 59 | if wrappedRows <= 1 { |
| 60 | t.Fatalf("test setup: working line %q at width %d must wrap, got %d row(s)", working, m.width, wrappedRows) |
| 61 | } |
| 62 | |
| 63 | v := m.View() |
| 64 | if v.Cursor == nil { |
| 65 | t.Fatal("composer cursor should be set while the composer is visible") |
| 66 | } |
| 67 | // The composer box starts below the viewport and the wrapped working line; |
| 68 | // its top border sits one row above the textarea rows and the bottom border |
| 69 | // one row below them. |
| 70 | boxTop := m.viewport.Height() + wrappedRows |
| 71 | boxBottom := boxTop + m.input.Height() + 2 |
| 72 | if v.Cursor.Y < boxTop+1 || v.Cursor.Y >= boxBottom { |
| 73 | t.Fatalf("composer cursor Y=%d outside composer box rows [%d,%d): viewport=%d wrappedWorkingRows=%d inputHeight=%d", |
| 74 | v.Cursor.Y, boxTop+1, boxBottom, m.viewport.Height(), wrappedRows, m.input.Height()) |
| 75 | } |
| 76 | } |
| 77 |