| 1 | package guards |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "regexp" |
| 7 | "sort" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | ) |
| 11 | |
| 12 | // The menu bar and the toolbar are markup in one file and behaviour in |
| 13 | // another. `data-cmd="bold"` in sheet.html means nothing on its own — |
| 14 | // commands.js has to carry a `bold` in its dispatch table for the |
| 15 | // button to do anything, and if it does not, the control renders |
| 16 | // perfectly, takes the click, and silently does nothing. |
| 17 | // |
| 18 | // That is not hypothetical. The Docs session shipped exactly this shape |
| 19 | // and lost four menu items for a review cycle: their command table was |
| 20 | // merged for two readers and only one reader was changed to use it, so |
| 21 | // the items were permanently disabled and no test saw it because the |
| 22 | // module had none. Every failure on this branch of the same family — |
| 23 | // a CSS rule matching no element, a warning flag invisible on the cells |
| 24 | // it flags, an alignment attribute the browser outranked — was invisible |
| 25 | // for the same reason: nothing tied the two halves together. |
| 26 | // |
| 27 | // This is that tie, in both directions. |
| 28 | |
| 29 | var ( |
| 30 | dataCmd = regexp.MustCompile(`data-cmd="([a-z-]+)"`) |
| 31 | cmdKey = regexp.MustCompile(`(?m)^\s{6}(?:'([a-z-]+)'|([a-z-]+)):\s*function`) |
| 32 | shortcut = regexp.MustCompile(`(?m)^\s*var KEYS = \{([^}]*)\}`) |
| 33 | keyValue = regexp.MustCompile(`'([a-z-]+)'`) |
| 34 | ) |
| 35 | |
| 36 | const ( |
| 37 | sheetTemplate = "internal/sheets/templates/sheet.html" |
| 38 | commandsJS = "internal/sheets/static/commands.js" |
| 39 | ) |
| 40 | |
| 41 | func readRepoFile(t *testing.T, root, rel string) string { |
| 42 | t.Helper() |
| 43 | body, err := os.ReadFile(filepath.Join(root, rel)) |
| 44 | if err != nil { |
| 45 | t.Fatalf("guards: read %s: %v", rel, err) |
| 46 | } |
| 47 | return string(body) |
| 48 | } |
| 49 | |
| 50 | // commandTable is the set of names commands.js can actually run. |
| 51 | func commandTable(t *testing.T, root string) map[string]bool { |
| 52 | t.Helper() |
| 53 | out := map[string]bool{} |
| 54 | for _, m := range cmdKey.FindAllStringSubmatch(readRepoFile(t, root, commandsJS), -1) { |
| 55 | name := m[1] |
| 56 | if name == "" { |
| 57 | name = m[2] |
| 58 | } |
| 59 | out[name] = true |
| 60 | } |
| 61 | if len(out) == 0 { |
| 62 | t.Fatalf("guards: parsed no commands out of %s — this test is checking nothing, "+ |
| 63 | "which is the failure it exists to prevent", commandsJS) |
| 64 | } |
| 65 | return out |
| 66 | } |
| 67 | |
| 68 | // TestEveryMenuCommandIsImplemented walks the markup for commands |
| 69 | // nothing can run. |
| 70 | func TestEveryMenuCommandIsImplemented(t *testing.T) { |
| 71 | root := repoRoot(t) |
| 72 | table := commandTable(t, root) |
| 73 | |
| 74 | markup := readRepoFile(t, root, sheetTemplate) |
| 75 | found := map[string]bool{} |
| 76 | for _, m := range dataCmd.FindAllStringSubmatch(markup, -1) { |
| 77 | found[m[1]] = true |
| 78 | } |
| 79 | if len(found) == 0 { |
| 80 | t.Fatalf("guards: %s carries no data-cmd at all", sheetTemplate) |
| 81 | } |
| 82 | |
| 83 | for _, name := range sorted(found) { |
| 84 | if !table[name] { |
| 85 | t.Errorf("%s offers data-cmd=%q and %s has no such command — the control "+ |
| 86 | "renders, takes the click and does nothing, which is the one failure "+ |
| 87 | "a person has no reason to look twice at", |
| 88 | sheetTemplate, name, commandsJS) |
| 89 | } |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | // TestEveryImplementedCommandIsReachable is the other direction: a |
| 94 | // command nothing can invoke. |
| 95 | // |
| 96 | // Dead in the ordinary way — it costs nothing at runtime — but it is |
| 97 | // the shape a rename leaves behind, where the markup moves to a new |
| 98 | // name and the old implementation stays looking live. Reachable means |
| 99 | // named by the markup or bound to a keyboard shortcut. |
| 100 | func TestEveryImplementedCommandIsReachable(t *testing.T) { |
| 101 | root := repoRoot(t) |
| 102 | table := commandTable(t, root) |
| 103 | |
| 104 | markup := readRepoFile(t, root, sheetTemplate) |
| 105 | reachable := map[string]bool{} |
| 106 | for _, m := range dataCmd.FindAllStringSubmatch(markup, -1) { |
| 107 | reachable[m[1]] = true |
| 108 | } |
| 109 | if block := shortcut.FindStringSubmatch(readRepoFile(t, root, commandsJS)); block != nil { |
| 110 | for _, m := range keyValue.FindAllStringSubmatch(block[1], -1) { |
| 111 | reachable[m[1]] = true |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | for _, name := range sorted(table) { |
| 116 | if !reachable[name] { |
| 117 | t.Errorf("%s implements %q and nothing invokes it — no data-cmd in %s and "+ |
| 118 | "no keyboard shortcut. Either the markup was renamed and this was left, "+ |
| 119 | "or a control was never added.", commandsJS, name, sheetTemplate) |
| 120 | } |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | func sorted(m map[string]bool) []string { |
| 125 | out := make([]string, 0, len(m)) |
| 126 | for k := range m { |
| 127 | out = append(out, k) |
| 128 | } |
| 129 | sort.Strings(out) |
| 130 | return out |
| 131 | } |
| 132 | |
| 133 | // TestTheCommandTableIsParsedAsWritten guards the regexes above. |
| 134 | // |
| 135 | // Both tests read JavaScript with a pattern, which is exactly the kind |
| 136 | // of check that quietly stops matching after a reformat and then passes |
| 137 | // forever. This asserts a few names it must find, so a parse that has |
| 138 | // silently gone blind fails here rather than fifty lines up. |
| 139 | func TestTheCommandTableIsParsedAsWritten(t *testing.T) { |
| 140 | table := commandTable(t, repoRoot(t)) |
| 141 | for _, want := range []string{"bold", "italic", "align", "fill", "ink", "undo"} { |
| 142 | if !table[want] { |
| 143 | t.Errorf("the command table parser did not find %q — it has stopped "+ |
| 144 | "reading %s and both tests above are now vacuous", want, commandsJS) |
| 145 | } |
| 146 | } |
| 147 | if len(table) < 10 { |
| 148 | t.Errorf("parsed only %d commands (%s); the table is larger than that", |
| 149 | len(table), strings.Join(sorted(table), ", ")) |
| 150 | } |
| 151 | } |
| 152 | |