cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

main
012 / internal / ui / docs_test.go
docs_test.go 7.1K
1package ui23import (4 "io/fs"5 "os"6 "path/filepath"7 "regexp"8 "strings"9 "testing"10 "unicode"1112 "src.rest/cat/012/internal/ui/nbview"13 "src.rest/cat/012/internal/ui/theme"14)1516// The docs name menus, commands and keys; these tests keep what they name17// true of the registry. scripts/doccheck checks links, the index and media.1819// docPages lists the Markdown pages under docs/, in every folder.20func docPages(t *testing.T) []string {21 t.Helper()22 var paths []string23 err := filepath.WalkDir("../../docs", func(p string, d fs.DirEntry, err error) error {24 if err == nil && !d.IsDir() && filepath.Ext(p) == ".md" {25 paths = append(paths, p)26 }27 return err28 })29 if err != nil {30 t.Fatal(err)31 }32 return paths33}3435// userDocs reads README.md and the docs with fenced code blocks and36// inline code removed and each paragraph on one line, so a menu path37// wrapped across lines reads whole. The generated function reference is38// left out: it names no menus.39func userDocs(t *testing.T) map[string]string {40 t.Helper()41 paths := append(docPages(t), "../../README.md")42 out := map[string]string{}43 for _, p := range paths {44 if filepath.Base(p) == "functions.md" {45 continue46 }47 data, err := os.ReadFile(p)48 if err != nil {49 t.Fatal(err)50 }51 var b strings.Builder52 fenced := false53 for l := range strings.SplitSeq(string(data), "\n") {54 if strings.HasPrefix(strings.TrimSpace(l), "```") {55 fenced = !fenced56 continue57 }58 if !fenced {59 b.WriteString(l)60 b.WriteByte(' ')61 }62 }63 out[strings.TrimPrefix(p, "../../")] = strings.Join(strings.Fields(b.String()), " ")64 }65 return out66}6768var menuPath = regexp.MustCompile(`\b(File|Edit|View|Insert|Format|Data|Help) > `)6970// TestDocsMenuPaths checks every menu path the docs name ("Data > Macros >71// Record macro") leads to an item of 012's menu bar. A path after a72// possessive ("Sheets' Insert > Pivot table") is another program's.73func TestDocsMenuPaths(t *testing.T) {74 for name, text := range userDocs(t) {75 for _, loc := range menuPath.FindAllStringSubmatchIndex(text, -1) {76 before := strings.TrimRight(text[:loc[0]], " *")77 if strings.HasSuffix(before, "'") || strings.HasSuffix(before, "'s") || strings.HasSuffix(before, "’") {78 continue79 }80 title := text[loc[2]:loc[3]]81 if err := resolveMenuPath(title, text[loc[1]:]); err != "" {82 end := min(len(text), loc[1]+40)83 t.Errorf("%s: %q: %s", name, text[loc[0]:end], err)84 }85 }86 }87}8889func TestResolveMenuPath(t *testing.T) {90 for _, c := range []struct{ title, rest, err string }{91 {"Data", "Macros > Record macro, then", ""},92 {"File", "Settings > Vim keys turns", ""},93 {"Data", "Frequency table (column stats), or", ""},94 {"Data", "Frequency table, or", ""},95 {"Data", "Pivots > Edit", "no such item in the Data menu"},96 {"Data", "Macros > Play macro", "no such item in the Macros menu"},97 {"Insert", "Chart > Pie", "Chart has no submenu"},98 } {99 if got := resolveMenuPath(c.title, c.rest); got != c.err {100 t.Errorf("%s > %s: %q, want %q", c.title, c.rest, got, c.err)101 }102 }103}104105// resolveMenuPath follows rest, the text after "Title > ", through the106// menu's items, and says what it couldn't find.107func resolveMenuPath(title, rest string) string {108 var items []menuItem109 for _, d := range menuBar {110 if d.title == title {111 items = visibleItems(d.items)112 }113 }114 for {115 found := menuItemAt(items, rest)116 if found == nil {117 return "no such item in the " + title + " menu"118 }119 label := found.label()120 if !labelPrefix(rest, label) {121 label, _, _ = strings.Cut(label, " (")122 }123 rest = rest[len(label):]124 if !strings.HasPrefix(rest, " > ") {125 return ""126 }127 if found.items == nil {128 return label + " has no submenu"129 }130 title, items, rest = label, found.items, rest[len(" > "):]131 }132}133134// menuItemAt is the item of items whose label, or its part before a135// parenthesis, text starts with; the longest when several do.136func menuItemAt(items []menuItem, text string) *menuItem {137 var found *menuItem138 for i, it := range items {139 if it.sep {140 continue141 }142 label := it.label()143 short, _, _ := strings.Cut(label, " (")144 for _, l := range []string{label, short} {145 if labelPrefix(text, l) && (found == nil || len(l) > len(found.label())) {146 found = &items[i]147 }148 }149 }150 return found151}152153// labelPrefix reports whether text starts with label as whole words.154func labelPrefix(text, label string) bool {155 if !strings.HasPrefix(strings.ToLower(text), strings.ToLower(label)) {156 return false157 }158 rest := text[len(label):]159 return rest == "" || !unicode.IsLetter(rune(rest[0])) && !unicode.IsDigit(rune(rest[0]))160}161162var (163 colonCommand = regexp.MustCompile("`:([a-z_]+\\.[a-z_.0-9]+)`")164 runCommandID = regexp.MustCompile(`run\("([a-z_.0-9]+)"`)165)166167// TestDocsCommandIDs checks the command ids the docs name, on the vim168// command line (`:settings.vim`) or in scripts (run("format.bold")),169// are registered.170func TestDocsCommandIDs(t *testing.T) {171 for _, p := range []string{"../../docs/reference/keys.md", "../../docs/reference/macro-api.md"} {172 data, err := os.ReadFile(p)173 if err != nil {174 t.Fatal(err)175 }176 for _, re := range []*regexp.Regexp{colonCommand, runCommandID} {177 for _, m := range re.FindAllStringSubmatch(string(data), -1) {178 if commands[m[1]] == nil {179 t.Errorf("%s names command %q, which isn't registered", p, m[1])180 }181 }182 }183 }184}185186// TestKeysDocCoversKeymap checks docs/reference/keys.md lists every key bound to a187// command, written as the menus and help show it.188func TestKeysDocCoversKeymap(t *testing.T) {189 data, err := os.ReadFile("../../docs/reference/keys.md")190 if err != nil {191 t.Fatal(err)192 }193 doc := string(data)194 for k, id := range keymap {195 if commands[id] == nil {196 continue197 }198 label := keyLabel(k)199 re := regexp.MustCompile(`(^|[\s,|(/])` + regexp.QuoteMeta(label) + `($|[\s,|)/.;:])`)200 if !re.MatchString(doc) {201 t.Errorf("docs/reference/keys.md doesn't list %s (%s, %s)", label, id, commands[id].title)202 }203 }204 var vimKeys []string205 for _, table := range []map[string]vimBinding{vimNormal, vimVisual} {206 for k := range table {207 vimKeys = append(vimKeys, k)208 }209 }210 for k := range vimMotions {211 if !isMoveKey(k) { // Sheets' own keys, listed above212 vimKeys = append(vimKeys, k)213 }214 }215 for _, k := range vimKeys {216 if strings.Contains(k, "+") || len(k) > 2 {217 if !strings.Contains(doc, keyLabel(k)) && !strings.Contains(doc, theme.KeyLabel(k)) {218 t.Errorf("docs/reference/keys.md doesn't list the vim key %s", k)219 }220 } else if !strings.Contains(doc, "`"+k+"`") {221 t.Errorf("docs/reference/keys.md doesn't list the vim key `%s`", k)222 }223 }224}225226// TestKeysDocCoversNotebookKeys checks docs/reference/keys.md lists the227// notebook's keys, as the notebook shows them.228func TestKeysDocCoversNotebookKeys(t *testing.T) {229 data, err := os.ReadFile("../../docs/reference/keys.md")230 if err != nil {231 t.Fatal(err)232 }233 for _, keys := range []map[string]string{nbKeys, nbEditKeys} {234 for k, id := range keys {235 label := nbview.KeyLabel(k)236 re := regexp.MustCompile("(^|[\\s,|(/`])" + regexp.QuoteMeta(label) + "($|[\\s,|)/.;:`])")237 if commands[id] == nil || !re.MatchString(string(data)) {238 t.Errorf("docs/reference/keys.md doesn't list the notebook's %s (%s)", label, id)239 }240 }241 }242}