| 1 | package docs |
| 2 | |
| 3 | import ( |
| 4 | "net/http/httptest" |
| 5 | "os" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | // The menu bar is a second surface onto the toolbar's command table, |
| 11 | // and these tests exist because "second surface" is exactly the shape |
| 12 | // that drifts: a command renamed in one place and not the other fails |
| 13 | // silently in front of a person, as a menu item that does nothing when |
| 14 | // clicked. |
| 15 | |
| 16 | func newMenuViewForTest() menuView { |
| 17 | return newMenuViewFor(true) |
| 18 | } |
| 19 | |
| 20 | // newMenuViewFor builds either variant. The guest's File menu is one item |
| 21 | // shorter, and the structure rules below (no leading divider, every form |
| 22 | // carries an action) have to hold for it too — a shorter list is exactly |
| 23 | // where a leading divider would appear. |
| 24 | func newMenuViewFor(canCreate bool) menuView { |
| 25 | return newMenuView(httptest.NewRequest("GET", "/d/abc123", nil), "abc123", canCreate) |
| 26 | } |
| 27 | |
| 28 | // Every command the menu names is implemented by one of the two modules |
| 29 | // that implement commands — unless it is flagged as not built yet. |
| 30 | // |
| 31 | // This is the Go-model half of the same property |
| 32 | // TestToolbarOptionValuesMatchTheBundle checks against the template. |
| 33 | // Both are worth having: that one catches a command in the markup with |
| 34 | // no implementation, this one catches a command in menu.go that never |
| 35 | // reaches the markup at all, which is the case a template-only test |
| 36 | // cannot see. |
| 37 | func TestMenuCommandsExistInTheBundle(t *testing.T) { |
| 38 | toolbar, err := os.ReadFile("../../fixtures/toolbar.mjs") |
| 39 | if err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | menu, err := os.ReadFile("../../fixtures/menu.mjs") |
| 43 | if err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | // A third place a menu command can be implemented, and the reason |
| 47 | // is worth stating rather than looking like a widened net. |
| 48 | // |
| 49 | // toolbar.mjs holds commands that act on the editor's state; menu.mjs |
| 50 | // holds the ones that do not need it at all (Print). The View menu's |
| 51 | // paged/infinite pair is neither: it changes how the document is |
| 52 | // DRAWN, which is page-level state that editor-entry.js owns — it |
| 53 | // holds the mode, writes the cookie and configures the pagination |
| 54 | // plugin. Putting the pair in toolbar.mjs would mean the formatting |
| 55 | // bar's command table reaching up into the page's own view state. |
| 56 | entry, err := os.ReadFile("../../fixtures/editor-entry.js") |
| 57 | if err != nil { |
| 58 | t.Fatal(err) |
| 59 | } |
| 60 | |
| 61 | v := newMenuViewForTest() |
| 62 | seen := 0 |
| 63 | for _, name := range menuCommandNames(v) { |
| 64 | seen++ |
| 65 | if strings.Contains(string(toolbar), name) || |
| 66 | strings.Contains(string(menu), name) || |
| 67 | strings.Contains(string(entry), name) { |
| 68 | continue |
| 69 | } |
| 70 | t.Errorf("menu.go offers %q, which none of fixtures/toolbar.mjs, fixtures/menu.mjs or fixtures/editor-entry.js implements", name) |
| 71 | } |
| 72 | |
| 73 | // A guard on the guard. If the menu offered no commands at all the |
| 74 | // loop above would check nothing and report success. This repo has found |
| 75 | // several assertions unable to fail against the thing they name; |
| 76 | // the shape is always a check that cannot distinguish "no" from "I |
| 77 | // looked at nothing". |
| 78 | if seen == 0 { |
| 79 | t.Fatal("no menu commands to check, so this test verified nothing") |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | // The bar's shape. Each rule here is one way a menu item can be |
| 84 | // malformed in a manner the Go compiler is happy with and a person is |
| 85 | // not. |
| 86 | func TestMenuStructureIsWellFormed(t *testing.T) { |
| 87 | // Both variants. The guest's File menu is one item shorter, and a |
| 88 | // shorter list is exactly where a leading divider appears — so the |
| 89 | // rules below have to hold for it, not only for a member's. |
| 90 | // |
| 91 | // The previous commit added newMenuViewFor and its comment saying so, |
| 92 | // and then never called it with false. The claim sat in a commit |
| 93 | // message for one round. (Re-review 4, finding 3.) |
| 94 | for _, canCreate := range []bool{true, false} { |
| 95 | t.Run(map[bool]string{true: "member", false: "guest"}[canCreate], func(t *testing.T) { |
| 96 | assertMenuWellFormed(t, newMenuViewFor(canCreate)) |
| 97 | }) |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | func assertMenuWellFormed(t *testing.T, v menuView) { |
| 102 | t.Helper() |
| 103 | |
| 104 | if len(v.Menus) == 0 { |
| 105 | t.Fatal("the menu bar has no menus") |
| 106 | } |
| 107 | if v.Label == "" { |
| 108 | t.Error("the menu bar has no accessible name") |
| 109 | } |
| 110 | |
| 111 | for _, m := range v.Menus { |
| 112 | if m.ID == "" || m.Title == "" { |
| 113 | t.Errorf("menu %q has no id or no title", m.ID) |
| 114 | } |
| 115 | if len(m.Items) == 0 { |
| 116 | t.Errorf("menu %q is empty — an empty menu is a title that opens onto nothing", m.ID) |
| 117 | } |
| 118 | // A menu that opens on a divider, or ends on one, has a rule |
| 119 | // against the panel's own edge where it separates nothing. |
| 120 | if m.Items[0].Kind == menuDivider { |
| 121 | t.Errorf("menu %q opens with a divider", m.ID) |
| 122 | } |
| 123 | if m.Items[len(m.Items)-1].Kind == menuDivider { |
| 124 | t.Errorf("menu %q ends with a divider", m.ID) |
| 125 | } |
| 126 | |
| 127 | prevDivider := false |
| 128 | for _, it := range m.Items { |
| 129 | if it.Kind == menuDivider { |
| 130 | if prevDivider { |
| 131 | t.Errorf("menu %q has two dividers in a row", m.ID) |
| 132 | } |
| 133 | prevDivider = true |
| 134 | continue |
| 135 | } |
| 136 | prevDivider = false |
| 137 | |
| 138 | if it.Label == "" { |
| 139 | t.Errorf("menu %q has an item with no label", m.ID) |
| 140 | } |
| 141 | switch it.Kind { |
| 142 | case menuCommand: |
| 143 | if it.Command == "" { |
| 144 | t.Errorf("menu %q: item %q is a command with no command name", m.ID, it.Label) |
| 145 | } |
| 146 | case menuLink: |
| 147 | if it.Href == "" { |
| 148 | t.Errorf("menu %q: item %q is a link with no href", m.ID, it.Label) |
| 149 | } |
| 150 | case menuForm: |
| 151 | if it.Action == "" { |
| 152 | t.Errorf("menu %q: item %q is a form with no action", m.ID, it.Label) |
| 153 | } |
| 154 | default: |
| 155 | t.Errorf("menu %q: item %q has unknown kind %q", m.ID, it.Label, it.Kind) |
| 156 | } |
| 157 | } |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | // The exports moved from two buttons pinned to the formatting bar into |
| 162 | // the File menu, and the whole argument for <details> rather than a |
| 163 | // scripted menubar was that they keep working with no JavaScript. This |
| 164 | // checks the half of that claim the server owns: the links are still |
| 165 | // rendered, still point at the same routes, and are still plain links |
| 166 | // with a download attribute rather than anything a script has to |
| 167 | // activate. |
| 168 | func TestExportsAreScriptlessLinksInTheFileMenu(t *testing.T) { |
| 169 | v := newMenuViewForTest() |
| 170 | |
| 171 | var file *menu |
| 172 | for i := range v.Menus { |
| 173 | if v.Menus[i].ID == "file" { |
| 174 | file = &v.Menus[i] |
| 175 | } |
| 176 | } |
| 177 | if file == nil { |
| 178 | t.Fatal("there is no File menu") |
| 179 | } |
| 180 | |
| 181 | want := map[string]bool{ |
| 182 | "/d/abc123/export.pdf": false, |
| 183 | "/d/abc123/export.md": false, |
| 184 | } |
| 185 | for _, it := range file.Items { |
| 186 | if _, ok := want[it.Href]; !ok { |
| 187 | continue |
| 188 | } |
| 189 | want[it.Href] = true |
| 190 | if it.Kind != menuLink { |
| 191 | t.Errorf("%s is a %q item; it must be a plain link or it stops working without JavaScript", it.Href, it.Kind) |
| 192 | } |
| 193 | if !it.Download { |
| 194 | t.Errorf("%s is missing the download attribute", it.Href) |
| 195 | } |
| 196 | } |
| 197 | for href, found := range want { |
| 198 | if !found { |
| 199 | t.Errorf("the File menu does not offer %s", href) |
| 200 | } |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | // Every label the bar renders came out of the catalog, not out of |
| 205 | // menu.go as a literal. AGENTS.md: twelve base locales, and a |
| 206 | // hardcoded English string is a bug. |
| 207 | // |
| 208 | // It works by rendering the view through a request with no locale |
| 209 | // middleware, where rastrillo.T returns its key verbatim (documented |
| 210 | // behaviour, and what a direct App.Handler() call in a test already |
| 211 | // sees). So every label should come back looking like a key. A literal |
| 212 | // would come back looking like English. |
| 213 | func TestEveryMenuLabelIsACatalogKey(t *testing.T) { |
| 214 | v := newMenuViewForTest() |
| 215 | |
| 216 | check := func(where, s string) { |
| 217 | if s == "" { |
| 218 | return |
| 219 | } |
| 220 | if !strings.HasPrefix(s, "docs.") { |
| 221 | t.Errorf("%s is the literal %q rather than a catalog key", where, s) |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | check("the menu bar's label", v.Label) |
| 226 | for _, m := range v.Menus { |
| 227 | check("menu "+m.ID+"'s title", m.Title) |
| 228 | for _, it := range m.Items { |
| 229 | if it.Kind == menuDivider { |
| 230 | continue |
| 231 | } |
| 232 | check("menu "+m.ID+"'s item", it.Label) |
| 233 | } |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | // Every catalog key the menu resolves is actually in the catalog. |
| 238 | // |
| 239 | // The test above proves the labels are keys; it cannot prove they are |
| 240 | // keys that resolve, because an unresolved key returns itself and looks |
| 241 | // exactly like a resolved one in that setting. A typo in a key is |
| 242 | // therefore invisible to it — and visible to a person as raw |
| 243 | // "docs.editor.menu.file.new" in their menu bar. |
| 244 | func TestEveryMenuKeyIsInTheCatalog(t *testing.T) { |
| 245 | v := newMenuViewForTest() |
| 246 | |
| 247 | check := func(key string) { |
| 248 | if key == "" { |
| 249 | return |
| 250 | } |
| 251 | if _, ok := Catalog[key]; !ok { |
| 252 | t.Errorf("the menu resolves %q, which is not in Catalog", key) |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | check(v.Label) |
| 257 | for _, m := range v.Menus { |
| 258 | check(m.Title) |
| 259 | for _, it := range m.Items { |
| 260 | if it.Kind == menuDivider { |
| 261 | continue |
| 262 | } |
| 263 | check(it.Label) |
| 264 | } |
| 265 | } |
| 266 | } |
| 267 | |