| 🛟 Updated. 28d5985 k33g 17h ago | 1 | package app |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "slices" |
| 6 | "testing" |
| 7 | |
| 📦 Turbo Core f3ade8d k33g 9h ago | 8 | "rickub.com/turbo-editors/turbo-core/tools" |
| 9 | "rickub.com/turbo-editors/turbo-core/ui" |
| 🛟 Updated. 28d5985 k33g 17h ago | 10 | ) |
| 11 | |
| 12 | // barMenuOf returns the menu with the given plain label, its items filled in. |
| 13 | func barMenuOf(t *testing.T, a *App, plain string) *ui.Menu { |
| 14 | t.Helper() |
| 15 | |
| 16 | for _, menu := range a.menu.Menus() { |
| 17 | if ui.PlainLabel(menu.Label) != plain { |
| 18 | continue |
| 19 | } |
| 20 | if menu.OnOpen != nil { |
| 21 | menu.OnOpen() |
| 22 | } |
| 23 | return menu |
| 24 | } |
| 25 | t.Fatalf("there is no %q menu on the bar: %v", plain, barLabels(a)) |
| 26 | return nil |
| 27 | } |
| 28 | |
| 29 | // barLabels returns the bar's menus as plain names, for failure messages. |
| 30 | func barLabels(a *App) []string { |
| 31 | var names []string |
| 32 | for _, menu := range a.menu.Menus() { |
| 33 | names = append(names, ui.PlainLabel(menu.Label)) |
| 34 | } |
| 35 | return names |
| 36 | } |
| 37 | |
| 38 | func TestAToolCanAskForAMenuOfItsOwn(t *testing.T) { |
| 39 | a, _ := newToolsApp(t, ` |
| 40 | [[tool]] |
| 41 | name = "~E~cho" |
| 42 | command = "echo TADA" |
| 43 | menu = "Tools" |
| 44 | `) |
| 45 | |
| 46 | menu := barMenuOf(t, a, "Tools") |
| 47 | if len(menu.Items) != 1 || ui.PlainLabel(menu.Items[0].Label) != "Echo" { |
| 48 | t.Errorf("the Tools menu holds %v", labels(menu.Items)) |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | func TestAToolInItsOwnMenuIsNotAlsoInTheToolchainMenu(t *testing.T) { |
| 53 | // A tool showing up twice would be worse than one showing up in the wrong |
| 54 | // place: nobody would know which line does what. |
| 55 | a, _ := newToolsApp(t, ` |
| 56 | [[tool]] |
| 57 | name = "Echo" |
| 58 | command = "echo TADA" |
| 59 | menu = "Tools" |
| 60 | `) |
| 61 | |
| 62 | for _, item := range barMenuOf(t, a, a.toolsMenuName()).Items { |
| 63 | if ui.PlainLabel(item.Label) == "Echo" { |
| 64 | t.Errorf("Echo is in the %s menu as well as its own", a.toolsMenuName()) |
| 65 | } |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | func TestAToolThatNamesNoMenuStaysInTheToolchainMenu(t *testing.T) { |
| 70 | a, _ := newToolsApp(t, ` |
| 71 | [[tool]] |
| 72 | name = "Test" |
| 73 | command = "go test ./..." |
| 74 | |
| 75 | [[tool]] |
| 76 | name = "Echo" |
| 77 | command = "echo TADA" |
| 78 | menu = "Tools" |
| 79 | `) |
| 80 | |
| 81 | names := labels(barMenuOf(t, a, a.toolsMenuName()).Items) |
| 82 | if len(names) == 0 || names[0] != "Test" { |
| 83 | t.Errorf("the %s menu holds %v, wanted Test first", a.toolsMenuName(), names) |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | func TestCreatedMenusFollowTheOrderOfTheFile(t *testing.T) { |
| 88 | a, _ := newToolsApp(t, ` |
| 89 | [[tool]] |
| 90 | name = "Up" |
| 91 | command = "docker compose up" |
| 92 | menu = "Docker" |
| 93 | |
| 94 | [[tool]] |
| 95 | name = "Echo" |
| 96 | command = "echo x" |
| 97 | menu = "Tools" |
| 98 | `) |
| 99 | |
| 100 | got := barLabels(a) |
| 101 | docker, echoMenu := positionOf(got, "Docker"), positionOf(got, "Tools") |
| 102 | switch { |
| 103 | case docker < 0 || echoMenu < 0: |
| 104 | t.Fatalf("the bar is %v", got) |
| 105 | case docker > echoMenu: |
| 106 | t.Errorf("Docker comes after Tools on the bar: %v", got) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | func TestCreatedMenusSitBetweenTheToolchainMenuAndHelp(t *testing.T) { |
| 111 | // Help is where Turbo C left it, and where a reader looks for it. |
| 112 | a, _ := newToolsApp(t, "[[tool]]\nname = \"Echo\"\ncommand = \"echo x\"\nmenu = \"Tools\"\n") |
| 113 | |
| 114 | got := barLabels(a) |
| 115 | toolchain := positionOf(got, a.toolsMenuName()) |
| 116 | if created, help := positionOf(got, "Tools"), positionOf(got, "Help"); !(toolchain < created && created < help) { |
| 117 | t.Errorf("the bar is %v", got) |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | func TestNoCreatedMenuClashesWithAFixedOne(t *testing.T) { |
| 122 | // The bar answers the first match, so a clash makes one of the two menus |
| 123 | // unreachable from the keyboard — silently, and with every test passing. |
| 124 | a, _ := newToolsApp(t, ` |
| 125 | [[tool]] |
| 126 | name = "a" |
| 127 | command = "true" |
| 128 | menu = "File" |
| 129 | |
| 130 | [[tool]] |
| 131 | name = "b" |
| 132 | command = "true" |
| 133 | menu = "Search" |
| 134 | |
| 135 | [[tool]] |
| 136 | name = "c" |
| 137 | command = "true" |
| 138 | menu = "Help" |
| 139 | `) |
| 140 | |
| 141 | seen := map[rune]string{} |
| 142 | for _, menu := range a.menu.Menus() { |
| 143 | label, hot, _ := ui.SplitHotKey(menu.Label) |
| 144 | if hot == 0 { |
| 145 | continue // a name with no free letter left; F10 still reaches it |
| 146 | } |
| 147 | if other, clash := seen[hot]; clash { |
| 148 | t.Errorf("%q and %q both answer to Alt-%c", other, label, hot) |
| 149 | } |
| 150 | seen[hot] = label |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | func TestACreatedMenuTakesTheFirstFreeLetterOfItsName(t *testing.T) { |
| 155 | a, _ := newToolsApp(t, "[[tool]]\nname = \"a\"\ncommand = \"true\"\nmenu = \"Format\"\n") |
| 156 | |
| 157 | // F is File's, o is Options', r is Run's — m is the first letter free. |
| 158 | if label := barMenuOf(t, a, "Format").Label; label != "For~m~at" { |
| 159 | t.Errorf("Format got the label %q, want For~m~at", label) |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | func TestAMenuNameMayChooseItsOwnHotKey(t *testing.T) { |
| 164 | // A free letter written into the name is kept. One that is already taken |
| 165 | // is not — TestHotKeyLabelGivesUpAChosenLetterThatIsTaken covers that. |
| 166 | a, _ := newToolsApp(t, "[[tool]]\nname = \"a\"\ncommand = \"true\"\nmenu = \"Doc~k~er\"\n") |
| 167 | |
| 168 | if label := barMenuOf(t, a, "Docker").Label; label != "Doc~k~er" { |
| 169 | t.Errorf("the chosen hot key was not kept: %q", label) |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | func TestHotKeyLabelFallsBackWhenEveryLetterIsTaken(t *testing.T) { |
| 174 | taken := map[rune]bool{'a': true, 'b': true} |
| 175 | |
| 176 | // No hot key beats one that opens somebody else's menu. |
| 177 | if got := hotKeyLabel("Ab", taken); got != "Ab" { |
| 178 | t.Errorf("hotKeyLabel = %q, want the name unmarked", got) |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | func TestHotKeyLabelGivesUpAChosenLetterThatIsTaken(t *testing.T) { |
| 183 | taken := map[rune]bool{'f': true} |
| 184 | |
| 185 | if got := hotKeyLabel("~F~oo", taken); got != "F~o~o" { |
| 186 | t.Errorf("hotKeyLabel = %q, want F~o~o", got) |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | func TestHotKeyLabelReservesWhatItHandsOut(t *testing.T) { |
| 191 | taken := map[rune]bool{} |
| 192 | |
| 193 | first := hotKeyLabel("Tools", taken) |
| 194 | second := hotKeyLabel("Test", taken) |
| 195 | if first == second { |
| 196 | t.Fatalf("both menus got %q", first) |
| 197 | } |
| 198 | if second != "T~e~st" { |
| 199 | t.Errorf("the second menu got %q, want T~e~st", second) |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | func TestTheBarPicksUpAMenuAddedWhileRunning(t *testing.T) { |
| 204 | // The set of menus comes from the file, so editing the file has to change |
| 205 | // the bar; Menu.OnOpen only refills the items inside one. |
| 206 | a, root := newToolsApp(t, "[[tool]]\nname = \"Test\"\ncommand = \"go test\"\n") |
| 207 | if positionOf(barLabels(a), "Docker") >= 0 { |
| 208 | t.Fatal("there is a Docker menu before the file asks for one") |
| 209 | } |
| 210 | |
| 211 | writeTools(t, root, "[[tool]]\nname = \"Up\"\ncommand = \"docker compose up\"\nmenu = \"Docker\"\n") |
| 212 | a.tick() |
| 213 | |
| 214 | if positionOf(barLabels(a), "Docker") < 0 { |
| 215 | t.Errorf("the bar is still %v", barLabels(a)) |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | func TestTheBarDropsAMenuTheFileNoLongerAsksFor(t *testing.T) { |
| 220 | a, root := newToolsApp(t, "[[tool]]\nname = \"Up\"\ncommand = \"docker compose up\"\nmenu = \"Docker\"\n") |
| 221 | if positionOf(barLabels(a), "Docker") < 0 { |
| 222 | t.Fatalf("the bar starts as %v", barLabels(a)) |
| 223 | } |
| 224 | |
| 225 | writeTools(t, root, "[[tool]]\nname = \"Test\"\ncommand = \"go test ./...\"\n") |
| 226 | a.tick() |
| 227 | |
| 228 | if positionOf(barLabels(a), "Docker") >= 0 { |
| 229 | t.Errorf("the bar is still %v", barLabels(a)) |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | func TestAnUnchangedToolsFileLeavesTheBarAlone(t *testing.T) { |
| 234 | // Rebuilding on every turn of the loop would close a menu under whoever |
| 235 | // was reading it, and throw away the OnOpen closures each turn. |
| 236 | a, _ := newToolsApp(t, "[[tool]]\nname = \"Echo\"\ncommand = \"echo x\"\nmenu = \"Tools\"\n") |
| 237 | before := a.menu.Menus() |
| 238 | |
| 239 | a.tick() |
| 240 | |
| 241 | if after := a.menu.Menus(); &before[0] != &after[0] { |
| 242 | t.Error("the bar was rebuilt although the tools file had not changed") |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | func TestAnUnreadableToolsFileCreatesNoMenus(t *testing.T) { |
| 247 | // The Go menu already says the file cannot be read; saying it again once |
| 248 | // per menu the file was about to create would be noise. |
| 249 | a, _ := newToolsApp(t, "[[tool]\nname = broken\n") |
| 250 | |
| 251 | // Named rather than counted: a count says nothing about *which* menu |
| 252 | // appeared, and this list is also the one place the whole fixed bar is |
| 253 | // written down, which a tutorial's "press → five times" depends on. |
| 254 | want := []string{"File", "Edit", "Search", "Run", "Code", "Options", "Window", "Snippets", "Agent", "Test", "Help"} |
| 255 | if got := barLabels(a); !slices.Equal(got, want) { |
| 256 | t.Errorf("the bar is %v, want %v", got, want) |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | // writeTools replaces a project's tools file. |
| 261 | func writeTools(t *testing.T, root, contents string) { |
| 262 | t.Helper() |
| 263 | |
| 264 | if err := os.WriteFile(tools.Path(testProfile(), root), []byte(contents), 0o644); err != nil { |
| 265 | t.Fatalf("writing the tools file: %v", err) |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | // positionOf returns where a name sits in a list, or -1 when it is absent. |
| 270 | func positionOf(names []string, want string) int { |
| 271 | for i, name := range names { |
| 272 | if name == want { |
| 273 | return i |
| 274 | } |
| 275 | } |
| 276 | return -1 |
| 277 | } |