| 🛟 Updated. 28d5985 k33g 19h ago | 1 | package app |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | "github.com/gdamore/tcell/v2" |
| 10 | |
| 📦 Turbo Core f3ade8d k33g 12h ago | 11 | "rickub.com/turbo-editors/turbo-core/tools" |
| 12 | "rickub.com/turbo-editors/turbo-core/ui" |
| 🛟 Updated. 28d5985 k33g 19h ago | 13 | ) |
| 14 | |
| 15 | // parametersBox returns the parameters dialog on screen, failing the test when |
| 16 | // there is none. |
| 17 | func parametersBox(t *testing.T, a *App) *ui.Dialog { |
| 18 | t.Helper() |
| 19 | |
| 20 | dialog := a.TopModal() |
| 21 | if dialog == nil { |
| 22 | t.Fatal("no dialog opened; the command must not run before its values are given") |
| 23 | } |
| 24 | return dialog |
| 25 | } |
| 26 | |
| 27 | // typeInto sends a run of characters to the dialog in front. |
| 28 | func typeInto(a *App, text string) { |
| 29 | for _, r := range text { |
| 30 | a.handle(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone)) |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | // answer types a value into the first field and presses Enter on OK. |
| 35 | func answer(t *testing.T, a *App, value string) { |
| 36 | t.Helper() |
| 37 | |
| 38 | parametersBox(t, a) |
| 39 | typeInto(a, value) |
| 40 | a.handle(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) |
| 41 | } |
| 42 | |
| 43 | func TestAToolWithAPlaceholderAsksBeforeItRuns(t *testing.T) { |
| 44 | // The whole point: nothing is started until the value is given, so a |
| 45 | // half-formed command never reaches the shell. |
| 46 | a, _ := newToolsApp(t, "") |
| 47 | |
| 48 | a.RunTool(tools.Tool{Name: "~I~nit", Command: "touch {{file name}}", Output: tools.OutputPopup}) |
| 49 | |
| 50 | dialog := parametersBox(t, a) |
| 51 | if got := dialog.Title(); got != "Init" { |
| 52 | t.Errorf("the box is titled %q, want the tool's name without its hot-key markers", got) |
| 53 | } |
| 54 | if a.running != nil { |
| 55 | t.Error("the command started before the box was answered") |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | func TestAToolWithNoPlaceholderRunsStraightAway(t *testing.T) { |
| 60 | a, _ := newToolsApp(t, "") |
| 61 | |
| 62 | runAndDrain(t, a, tools.Tool{Name: "Echo", Command: "echo plain", Output: tools.OutputPopup}) |
| 63 | |
| 64 | if a.running == nil { |
| 65 | t.Fatal("an ordinary tool did not run") |
| 66 | } |
| 67 | if lines := a.running.run.Lines(); len(lines) == 0 || lines[0] != "plain" { |
| 68 | t.Errorf("the command printed %v, want plain", lines) |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | func TestAnsweringTheBoxRunsTheFilledCommand(t *testing.T) { |
| 73 | a, _ := newToolsApp(t, "") |
| 74 | |
| 75 | a.RunTool(tools.Tool{Name: "Init", Command: "echo {{message}}", Output: tools.OutputPopup}) |
| 76 | answer(t, a, "hello there") |
| 77 | |
| 78 | waitForLoopTurn(t, a, "the filled command to finish", func() bool { |
| 79 | return a.running != nil && a.runningFinished() |
| 80 | }) |
| 81 | |
| 82 | lines := a.running.run.Lines() |
| 83 | if len(lines) == 0 || lines[0] != "hello there" { |
| 84 | t.Fatalf("the command printed %v, want %q — the value did not reach it", lines, "hello there") |
| 85 | } |
| 86 | // Shell-quoted, so the space stayed inside one argument rather than |
| 87 | // becoming two. |
| 88 | if got := a.running.run.Command(); !strings.Contains(got, "'hello there'") { |
| 89 | t.Errorf("the command was %q, want the value quoted", got) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | func TestCancellingTheBoxRunsNothing(t *testing.T) { |
| 94 | a, _ := newToolsApp(t, "") |
| 95 | |
| 96 | a.RunTool(tools.Tool{Name: "Init", Command: "touch {{name}}", Output: tools.OutputPopup}) |
| 97 | |
| 98 | parametersBox(t, a) |
| 99 | typeInto(a, "cancelled.txt") |
| 100 | a.handle(tcell.NewEventKey(tcell.KeyEscape, 0, tcell.ModNone)) |
| 101 | |
| 102 | if a.Modals() != 0 { |
| 103 | t.Fatal("Escape left the box open") |
| 104 | } |
| 105 | if a.running != nil { |
| 106 | t.Error("the command ran although the box was cancelled") |
| 107 | } |
| 108 | if _, err := os.Stat("cancelled.txt"); err == nil { |
| 109 | t.Error("the cancelled command created its file") |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | func TestTheValuesAreRememberedForTheNextRun(t *testing.T) { |
| 114 | // Running the same parameterised command twice should not mean typing the |
| 115 | // same thing twice. |
| 116 | a, _ := newToolsApp(t, "") |
| 117 | tool := tools.Tool{Name: "Init", Command: "echo {{message}}", Output: tools.OutputPopup} |
| 118 | |
| 119 | a.RunTool(tool) |
| 120 | answer(t, a, "remembered") |
| 121 | waitForLoopTurn(t, a, "the first run to finish", func() bool { |
| 122 | return a.running != nil && a.runningFinished() |
| 123 | }) |
| 124 | a.finishRun() |
| 125 | |
| 126 | a.RunTool(tool) |
| 127 | |
| 128 | parametersBox(t, a) |
| 129 | if got := a.toolValues[tool.Name]["message"]; got != "remembered" { |
| 130 | t.Fatalf("the remembered value is %q, want %q", got, "remembered") |
| 131 | } |
| 132 | // Enter alone, with nothing typed, must re-run what was typed before. |
| 133 | a.handle(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) |
| 134 | waitForLoopTurn(t, a, "the second run to finish", func() bool { |
| 135 | return a.running != nil && a.runningFinished() |
| 136 | }) |
| 137 | if lines := a.running.run.Lines(); len(lines) == 0 || lines[0] != "remembered" { |
| 138 | t.Errorf("the second run printed %v, want the remembered value", lines) |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | func TestValuesAreRememberedPerToolAndNotShared(t *testing.T) { |
| 143 | a, _ := newToolsApp(t, "") |
| 144 | |
| 145 | a.RunTool(tools.Tool{Name: "First", Command: "echo {{message}}", Output: tools.OutputPopup}) |
| 146 | answer(t, a, "one") |
| 147 | waitForLoopTurn(t, a, "the first tool to finish", func() bool { return a.running != nil && a.runningFinished() }) |
| 148 | a.finishRun() |
| 149 | |
| 150 | a.RunTool(tools.Tool{Name: "Second", Command: "echo {{message}}", Output: tools.OutputPopup}) |
| 151 | |
| 152 | parametersBox(t, a) |
| 153 | if got := a.toolValues["Second"]["message"]; got != "" { |
| 154 | t.Errorf("the second tool starts with %q; a value belongs to the tool it was typed for", got) |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | func TestNothingIsWrittenToTheProjectDirectory(t *testing.T) { |
| 159 | // The values live for the session only. A value somebody typed this |
| 160 | // afternoon is not a decision the project made. |
| 161 | a, root := newToolsApp(t, "") |
| 162 | |
| 163 | a.RunTool(tools.Tool{Name: "Init", Command: "echo {{message}}", Output: tools.OutputPopup}) |
| 164 | answer(t, a, "not persisted") |
| 165 | waitForLoopTurn(t, a, "the command to finish", func() bool { return a.running != nil && a.runningFinished() }) |
| 166 | |
| 167 | if entries, err := os.ReadDir(filepath.Join(root, testProfile().ProjectDir())); err == nil { |
| 168 | for _, entry := range entries { |
| 169 | if entry.Name() != tools.FileName { |
| 170 | t.Errorf("running a parameterised tool left %q behind", entry.Name()) |
| 171 | } |
| 172 | } |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | func TestSeveralPlaceholdersGetAFieldEach(t *testing.T) { |
| 177 | a, _ := newToolsApp(t, "") |
| 178 | |
| 179 | a.RunTool(tools.Tool{Name: "Copy", Command: "echo {{from}} {{to}}", Output: tools.OutputPopup}) |
| 180 | |
| 181 | parametersBox(t, a) |
| 182 | // Tab moves between the fields; type into each in turn. |
| 183 | typeInto(a, "here") |
| 184 | a.handle(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)) |
| 185 | typeInto(a, "there") |
| 186 | a.handle(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) |
| 187 | |
| 188 | waitForLoopTurn(t, a, "the command to finish", func() bool { return a.running != nil && a.runningFinished() }) |
| 189 | |
| 190 | if lines := a.running.run.Lines(); len(lines) == 0 || lines[0] != "here there" { |
| 191 | t.Errorf("the command printed %v, want both values", lines) |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | func TestARawPlaceholderReachesTheShellUnquoted(t *testing.T) { |
| 196 | // The escape hatch, end to end: one field standing for several arguments. |
| 197 | a, _ := newToolsApp(t, "") |
| 198 | |
| 199 | a.RunTool(tools.Tool{Name: "Echo", Command: "echo {{words...}}", Output: tools.OutputPopup}) |
| 200 | answer(t, a, "a b c") |
| 201 | |
| 202 | waitForLoopTurn(t, a, "the command to finish", func() bool { return a.running != nil && a.runningFinished() }) |
| 203 | |
| 204 | if got := a.running.run.Command(); got != "echo a b c" { |
| 205 | t.Errorf("the command was %q, want the value verbatim", got) |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | func TestATerminalToolIsAlsoAskedFirst(t *testing.T) { |
| 210 | // The other output destination takes the same path, and the window's title |
| 211 | // is the filled command rather than the one with braces in it. |
| 212 | a, _ := newToolsApp(t, "") |
| 213 | |
| 214 | a.RunTool(tools.Tool{Name: "Run", Command: "echo {{message}}", Output: tools.OutputTerminal}) |
| 215 | parametersBox(t, a) |
| 216 | answer(t, a, "in a terminal") |
| 217 | |
| 218 | window := a.Desktop().Active() |
| 219 | if window == nil { |
| 220 | t.Fatal("no terminal window opened") |
| 221 | } |
| 222 | if got := window.Title(); !strings.Contains(got, "'in a terminal'") { |
| 223 | t.Errorf("the window is titled %q, want the filled command", got) |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | func TestATallDemandIsRefusedRatherThanShownOffScreen(t *testing.T) { |
| 228 | // A dialog whose OK button is below the bottom of the screen cannot be |
| 229 | // answered, which is worse than being told it will not fit. |
| 230 | a, _ := newToolsApp(t, "") |
| 231 | command := "echo" |
| 232 | for i := range MaxParameterFields(24) + 1 { |
| 233 | command += " {{value " + string(rune('a'+i)) + "}}" |
| 234 | } |
| 235 | |
| 236 | a.RunTool(tools.Tool{Name: "Too many", Command: command, Output: tools.OutputPopup}) |
| 237 | |
| 238 | dialog := a.TopModal() |
| 239 | if dialog == nil { |
| 240 | t.Fatal("nothing was shown at all") |
| 241 | } |
| 242 | if got := dialog.Title(); got != a.toolsMenuName() { |
| 243 | t.Errorf("the box is titled %q, want a message from the toolchain menu", got) |
| 244 | } |
| 245 | if a.running != nil { |
| 246 | t.Error("the command ran although its values were never given") |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | func TestMaxParameterFieldsGrowsWithTheScreen(t *testing.T) { |
| 251 | if got := MaxParameterFields(24); got != 9 { |
| 252 | t.Errorf("MaxParameterFields(24) = %d, want 9", got) |
| 253 | } |
| 254 | if MaxParameterFields(50) <= MaxParameterFields(24) { |
| 255 | t.Error("a taller screen must fit more fields") |
| 256 | } |
| 257 | // A screen too small for even one still offers one, because refusing every |
| 258 | // parameterised tool on a short terminal is worse than a clipped box. |
| 259 | if got := MaxParameterFields(4); got != 1 { |
| 260 | t.Errorf("MaxParameterFields(4) = %d, want 1", got) |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | func TestTheBoxStartsFromWhatWasRemembered(t *testing.T) { |
| 265 | box := NewParametersDialog("Init", []string{"module"}, map[string]string{"module": "example.com/mine"}, ui.Rect{W: 80, H: 24}) |
| 266 | |
| 267 | if got := box.Values()["module"]; got != "example.com/mine" { |
| 268 | t.Errorf("the field holds %q, want the remembered value", got) |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | func TestTheBoxAsksForEveryLabelItWasGiven(t *testing.T) { |
| 273 | box := NewParametersDialog("Copy", []string{"from", "to"}, nil, ui.Rect{W: 80, H: 24}) |
| 274 | |
| 275 | values := box.Values() |
| 276 | if len(values) != 2 { |
| 277 | t.Fatalf("Values() = %v, want one entry per label", values) |
| 278 | } |
| 279 | for _, label := range []string{"from", "to"} { |
| 280 | if _, asked := values[label]; !asked { |
| 281 | t.Errorf("Values() has no entry for %q", label) |
| 282 | } |
| 283 | } |
| 284 | } |