screens_test.go (622 lines) keeps TestScreens and the gallery; the list of golden screens and their light variants moves to screenlist_test.go and the fixtures they share to screensetup_test.go. renderHTML draws each cell with cellHTML and joins runs with a spanWriter; session.keys hands named keys and typed text to a helper each.
- commit
- a949f436586ca1f9cfd891cb5328b917b501de97
- parent
- f1d433a8f642e15bad354c6e9274f66bcabbb834
- author
- Dami <dami.etoile@gmail.com>
| M | e2e/ | 82 | +++-- |
| M | e2e/ | 125 | +++++--- |
| A | e2e/ | 390 | ++++++++++++++++++++++++ |
| M | e2e/ | 489 | ------------------------------ |
| A | e2e/ | 113 | +++++++ |
@@ -250,44 +250,54 @@ func (s *session) keys(keys ...string) {
250250 s.t.Helper()251251 for _, k := range keys {252252 if name, ok := strings.CutPrefix(k, "<"); ok {253- parts := strings.Split(strings.TrimSuffix(name, ">"), "+")254- var mods ghostty.Mods255- for _, p := range parts[:len(parts)-1] {256- switch p {257- case "shift":258- mods |= ghostty.ModShift259- case "ctrl":260- mods |= ghostty.ModCtrl261- case "alt":262- mods |= ghostty.ModAlt263- }264- }265- base := parts[len(parts)-1]266- if key, ok := namedKeys[base]; ok {267- var cp rune268- if base == "space" {269- cp = ' ' // the encoder needs the key's codepoint270- }271- s.press(key, mods, "", cp)272- continue273- }274- if ck, ok := charKeys[rune(base[0])]; ok && len(base) == 1 {275- s.press(ck.key, mods|ck.mods, "", rune(base[0]))276- continue277- }278- s.t.Fatalf("unknown key %s", k)253+ s.namedKey(strings.TrimSuffix(name, ">"))254+ } else {255+ s.typeText(k)279256 }280- for _, r := range k {281- key, ok := charKeys[r]282- if !ok {283- s.t.Fatalf("no key mapping for %q", r)284- }285- unshifted := key.base286- if key.mods&ghostty.ModShift == 0 {287- unshifted = r288- }289- s.press(key.key, key.mods, string(r), unshifted)257+ }258+}259+260+// modNames are the modifiers a named key can carry, as in "<ctrl+a>".261+var modNames = map[string]ghostty.Mods{"shift": ghostty.ModShift, "ctrl": ghostty.ModCtrl, "alt": ghostty.ModAlt}262+263+// namedKey presses a key written as in "<shift+down>", without the264+// brackets: modifiers, then a key name or a single character.265+func (s *session) namedKey(name string) {266+ s.t.Helper()267+ parts := strings.Split(name, "+")268+ var mods ghostty.Mods269+ for _, p := range parts[:len(parts)-1] {270+ mods |= modNames[p]271+ }272+ base := parts[len(parts)-1]273+ if key, ok := namedKeys[base]; ok {274+ var cp rune275+ if base == "space" {276+ cp = ' ' // the encoder needs the key's codepoint277+ }278+ s.press(key, mods, "", cp)279+ return280+ }281+ if ck, ok := charKeys[rune(base[0])]; ok && len(base) == 1 {282+ s.press(ck.key, mods|ck.mods, "", rune(base[0]))283+ return284+ }285+ s.t.Fatalf("unknown key <%s>", name)286+}287+288+// typeText types text character by character.289+func (s *session) typeText(text string) {290+ s.t.Helper()291+ for _, r := range text {292+ key, ok := charKeys[r]293+ if !ok {294+ s.t.Fatalf("no key mapping for %q", r)295+ }296+ unshifted := key.base297+ if key.mods&ghostty.ModShift == 0 {298+ unshifted = r290299 }300+ s.press(key.key, key.mods, string(r), unshifted)291301 }292302 }293303 @@ -27,67 +27,94 @@ func renderHTML(vt *ghostty.Terminal) (string, error) {
2727 }2828 var b strings.Builder2929 for y := range uint32(rows) {30- var line strings.Builder31- var run strings.Builder32- style := ""33- flush := func() {34- if run.Len() == 0 {35- return36- }37- if style == "" {38- line.WriteString(run.String())39- } else {40- fmt.Fprintf(&line, `<span style="%s">%s</span>`, style, run.String())41- }42- run.Reset()43- }30+ var line spanWriter4431 for x := range cols {45- ref, err := vt.GridRef(ghostty.Point{Tag: ghostty.PointTagActive, X: x, Y: y})46- if err != nil {47- return "", err48- }49- cell, err := ref.Cell()32+ text, css, ok, err := cellHTML(vt, ghostty.Point{Tag: ghostty.PointTagActive, X: x, Y: y})5033 if err != nil {5134 return "", err5235 }53- if wide, _ := cell.Wide(); wide == ghostty.CellWideSpacerTail {54- continue55- }56- text := " "57- if has, _ := cell.HasText(); has {58- cps, _ := ref.Graphemes()59- var sb strings.Builder60- for _, cp := range cps {61- sb.WriteRune(rune(cp))62- }63- text = sb.String()64- }65- css := ""66- if st, err := ref.Style(); err == nil && !st.IsDefault() {67- css = styleCSS(st, strings.TrimSpace(text) == "")36+ if ok {37+ line.add(text, css)6838 }69- // Cells erased with a background color hold just that color.70- switch tag, _ := cell.ContentTag(); tag {71- case ghostty.CellContentBgColorPalette:72- p, _ := cell.ColorPalette()73- css = fmt.Sprintf("background:var(--vt-palette-%d)", p)74- case ghostty.CellContentBgColorRGB:75- c, _ := cell.ColorRGB()76- css = fmt.Sprintf("background:#%02x%02x%02x", c.R, c.G, c.B)77- }78- if css != style {79- flush()80- style = css81- }82- run.WriteString(html.EscapeString(text))8339 }84- flush()8540 b.WriteString(strings.TrimRight(line.String(), " "))8641 b.WriteByte('\n')8742 }8843 return `<pre class="screen">` + strings.TrimRight(b.String(), "\n") + "</pre>", nil8944 }9045 46+// spanWriter writes a line of cells, one span per run of cells with the47+// same CSS; unstyled runs are bare text.48+type spanWriter struct {49+ out, run strings.Builder50+ style string51+}52+53+// add appends a cell's text with its style.54+func (w *spanWriter) add(text, css string) {55+ if css != w.style {56+ w.flush()57+ w.style = css58+ }59+ w.run.WriteString(html.EscapeString(text))60+}61+62+func (w *spanWriter) flush() {63+ if w.run.Len() == 0 {64+ return65+ }66+ if w.style == "" {67+ w.out.WriteString(w.run.String())68+ } else {69+ fmt.Fprintf(&w.out, `<span style="%s">%s</span>`, w.style, w.run.String())70+ }71+ w.run.Reset()72+}73+74+// String is the line so far.75+func (w *spanWriter) String() string {76+ w.flush()77+ return w.out.String()78+}79+80+// cellHTML returns the text of the cell at p and its CSS; ok is false81+// for the second half of a wide character, which draws nothing.82+func cellHTML(vt *ghostty.Terminal, p ghostty.Point) (text, css string, ok bool, err error) {83+ ref, err := vt.GridRef(p)84+ if err != nil {85+ return "", "", false, err86+ }87+ cell, err := ref.Cell()88+ if err != nil {89+ return "", "", false, err90+ }91+ if wide, _ := cell.Wide(); wide == ghostty.CellWideSpacerTail {92+ return "", "", false, nil93+ }94+ text = " "95+ if has, _ := cell.HasText(); has {96+ cps, _ := ref.Graphemes()97+ var sb strings.Builder98+ for _, cp := range cps {99+ sb.WriteRune(rune(cp))100+ }101+ text = sb.String()102+ }103+ if st, err := ref.Style(); err == nil && !st.IsDefault() {104+ css = styleCSS(st, strings.TrimSpace(text) == "")105+ }106+ // Cells erased with a background color hold just that color.107+ switch tag, _ := cell.ContentTag(); tag {108+ case ghostty.CellContentBgColorPalette:109+ p, _ := cell.ColorPalette()110+ css = fmt.Sprintf("background:var(--vt-palette-%d)", p)111+ case ghostty.CellContentBgColorRGB:112+ c, _ := cell.ColorRGB()113+ css = fmt.Sprintf("background:#%02x%02x%02x", c.R, c.G, c.B)114+ }115+ return text, css, true, nil116+}117+91118 // styleCSS converts a cell style to CSS. For blank cells only what shows92119 // is kept (background, lines), so snapshots don't depend on whether the93120 // renderer wrote spaces or erased cells.@@ -0,0 +1,390 @@
1+package e2e2+3+import ghostty "go.mitchellh.com/libghostty"4+5+// The golden screens: every UI state recorded, how to reach it, and which6+// are recorded on a light terminal too. The fixtures they share are in7+// screensetup_test.go; TestScreens in screens_test.go records them.8+9+var screens = []screen{10+ {name: "ready-empty", setup: func(s *session) {}},11+ {name: "budget", setup: budget},12+ {name: "entry-formula", setup: func(s *session) {13+ budget(s)14+ s.keys("<down>", "=B7*12")15+ s.waitFor("ENTER")16+ }},17+ {name: "point-range", setup: func(s *session) {18+ budget(s)19+ s.keys("<right>", "=AVERAGE(", "<left>", "<up>", "<up>", "<up>", "<up>", "<shift+down>", "<shift+down>")20+ s.waitFor("=AVERAGE(B3:B5")21+ }},22+ {name: "selection-stats", setup: func(s *session) {23+ budget(s)24+ s.keys("<up>", "<up>", "<up>", "<up>", "<shift+down>", "<shift+down>")25+ s.waitFor("Sum 2158.4")26+ }},27+ {name: "column-select", setup: func(s *session) {28+ budget(s)29+ s.keys("<ctrl+space>")30+ s.waitFor("B1:B8192")31+ }},32+ {name: "edit-error", setup: func(s *session) {33+ s.keys("=SUM(A1", "<enter>")34+ s.waitFor("Expected , or ) in SUM")35+ }},36+ {name: "menu", setup: func(s *session) {37+ budget(s)38+ s.keys("<alt+f>", "<down>", "<down>", "<down>")39+ s.waitFor("Save the sheet")40+ }},41+ {name: "palette", setup: func(s *session) {42+ budget(s)43+ s.keys("<ctrl+k>")44+ s.waitFor("Search the menus")45+ }},46+ {name: "palette-search", setup: func(s *session) {47+ budget(s)48+ s.keys("<alt+/>", "col")49+ s.waitFor("│ › col")50+ }},51+ {name: "palette-narrow", opts: options{cols: 60, rows: 16}, setup: func(s *session) {52+ budget(s)53+ s.keys("<ctrl+k>", "sa")54+ s.waitFor("│ › sa")55+ }},56+ {name: "context-menu", setup: func(s *session) {57+ budget(s)58+ s.click(ghostty.MouseButtonRight, 6+10+4, 4+3)59+ s.waitFor("│ Clear")60+ }},61+ {name: "context-menu-column", setup: func(s *session) {62+ budget(s)63+ s.click(ghostty.MouseButtonRight, 6+10+4, 3)64+ s.waitFor("│ Resize column")65+ }},66+ {name: "menu-narrow", opts: options{cols: 60, rows: 16}, setup: func(s *session) {67+ budget(s)68+ s.keys("<alt+h>")69+ s.waitFor("│ About 012")70+ }},71+ {name: "functions", setup: func(s *session) {72+ budget(s)73+ s.keys("<alt+h>", "f", "if")74+ s.waitFor("│ › if")75+ }},76+ {name: "about", setup: func(s *session) {77+ s.keys("<alt+h>", "a")78+ s.waitFor("Google Sheets keys")79+ }},80+ {name: "help-narrow", opts: options{cols: 60, rows: 16}, setup: func(s *session) {81+ s.keys("<f1>")82+ s.waitFor("Keyboard shortcuts")83+ }},84+ {name: "palette-wide", opts: options{cols: 200, rows: 30}, setup: func(s *session) {85+ budget(s)86+ s.keys("<ctrl+k>", "sel")87+ s.waitFor("│ › sel")88+ }},89+ {name: "help-wide", opts: options{cols: 200, rows: 45}, setup: func(s *session) {90+ s.keys("<f1>")91+ s.waitFor("Keyboard shortcuts")92+ }},93+ {name: "quit-confirm", setup: func(s *session) {94+ budget(s)95+ s.keys("<ctrl+q>")96+ s.waitFor("unsaved changes")97+ }},98+ {name: "prompt-width", setup: func(s *session) {99+ budget(s)100+ s.keys("<ctrl+home>", "<alt+o>", "c", "<enter>", "<right>", "<right>", "<right>")101+ s.waitFor("Column width (1-240): 13")102+ }},103+ {name: "error-goto", setup: func(s *session) {104+ s.keys("<f5>", "nope", "<enter>")105+ s.waitFor("Not a cell, range or named range")106+ }},107+ {name: "help", setup: func(s *session) {108+ s.keys("<f1>")109+ s.waitFor("HELP")110+ }},111+ {name: "narrow", opts: options{cols: 60, rows: 16}, setup: budget},112+ {name: "hover-resize-handle", setup: func(s *session) {113+ budget(s)114+ s.mouse(ghostty.MouseActionMotion, ghostty.MouseButtonUnknown, 6+10-1, 3, 0)115+ s.waitFor("▐")116+ }},117+ {name: "resizing-column", setup: func(s *session) {118+ budget(s)119+ s.mouse(ghostty.MouseActionPress, ghostty.MouseButtonLeft, 6+10-1, 3, 0)120+ s.mouse(ghostty.MouseActionMotion, ghostty.MouseButtonLeft, 6+10+5, 3, 0)121+ s.waitFor("Column A width 16")122+ }},123+ {name: "copy-marker", setup: func(s *session) {124+ budget(s)125+ s.keys("<up>", "<up>", "<up>", "<up>", "<shift+down>", "<shift+down>", "<ctrl+c>", "<right>", "<up>")126+ s.waitFor("Copied B3:B5")127+ }},128+ {name: "copy-marker-selected", setup: func(s *session) {129+ budget(s)130+ s.keys("<left>", "<up>", "<up>", "<up>", "<up>", "<shift+down>", "<shift+down>", "<shift+right>", "<ctrl+x>")131+ s.waitFor("Cut A3:B5")132+ }},133+ {name: "undo-note", setup: func(s *session) {134+ budget(s)135+ s.keys("<up>", "<shift+up>", "<delete>", "<ctrl+z>")136+ s.waitFor("Undid: clear B5:B6")137+ }},138+ {name: "narrow-copy", opts: options{cols: 60, rows: 16}, setup: func(s *session) {139+ budget(s)140+ s.keys("<ctrl+c>", "<down>")141+ s.waitFor("Copied B7")142+ }},143+ {name: "formats", setup: formatted},144+ {name: "jev", opts: options{jev: true}, setup: reviews},145+ {name: "find", setup: func(s *session) {146+ budget(s)147+ s.keys("<ctrl+f>", "r")148+ s.waitFor("1 of 4")149+ }},150+ {name: "replace", setup: func(s *session) {151+ budget(s)152+ s.keys("<ctrl+home>", "<shift+down>", "<shift+down>", "<shift+down>", "<shift+down>", "<shift+down>", "<shift+down>")153+ s.keys("<ctrl+h>", "Rent", "<tab>", "Lease", "<alt+c>")154+ s.waitFor("in A1:A7")155+ }},156+ {name: "find-narrow", opts: options{cols: 60, rows: 16}, setup: func(s *session) {157+ budget(s)158+ s.keys("<ctrl+h>", "tr", "<tab>", "x")159+ s.waitFor("Replace")160+ }},161+ {name: "menu-format-number", setup: func(s *session) {162+ formatted(s)163+ s.keys("<alt+o>", "<right>")164+ s.waitFor("Currency rounded")165+ }},166+ {name: "formats-narrow", opts: options{cols: 60, rows: 16}, setup: formatted},167+ {name: "autocomplete", setup: func(s *session) {168+ budget(s)169+ s.keys("<down>", "=B7/su")170+ s.waitFor("│ SUM ")171+ }},172+ {name: "autocomplete-narrow", opts: options{cols: 60, rows: 16}, setup: func(s *session) {173+ budget(s)174+ s.keys("<down>", "=MAX(B7,co")175+ s.waitFor("│ COUNT ")176+ }},177+ {name: "signature", setup: func(s *session) {178+ budget(s)179+ s.keys("<down>", `=SUMIF(A3:A5, "R*", `)180+ s.waitFor("criterion, [sum_range])")181+ }},182+ {name: "named-ranges", setup: func(s *session) {183+ named(s)184+ s.keys("<alt+d>", "n")185+ s.waitFor("│ › Type a name")186+ }},187+ {name: "trace-precedents", setup: func(s *session) {188+ named(s)189+ s.keys("<alt+,>")190+ s.waitFor("precedents of B8")191+ }},192+ {name: "chart", setup: func(s *session) {193+ spending(s)194+ insertChart(s)195+ s.keys("<enter>", "<esc>")196+ s.waitFor("READY")197+ }},198+ {name: "chart-editor", setup: func(s *session) {199+ spending(s)200+ insertChart(s)201+ }},202+ {name: "chart-selected", setup: func(s *session) {203+ spending(s)204+ insertChart(s)205+ s.keys("<enter>")206+ s.waitFor("Column chart of A1:C5")207+ }},208+ {name: "chart-bar", setup: func(s *session) { chartOfType(s, 1, "Bar chart") }},209+ {name: "chart-line", setup: func(s *session) { chartOfType(s, 2, "Line chart") }},210+ {name: "chart-pie", setup: func(s *session) { chartOfType(s, 3, "Pie chart") }},211+ {name: "chart-narrow", opts: options{cols: 60, rows: 16}, setup: func(s *session) {212+ spending(s)213+ insertChart(s)214+ }},215+ {name: "chart-wide", opts: options{cols: 200, rows: 30}, setup: func(s *session) {216+ spending(s)217+ insertChart(s)218+ s.keys("<enter>", "<esc>")219+ s.waitFor("READY")220+ }},221+ {name: "links-errors", setup: func(s *session) {222+ s.keys("Docs", "<tab>", "https://example.com/docs", "<enter>")223+ s.keys("Help", "<tab>", `=HYPERLINK("example.org/help", "Help center")`, "<enter>")224+ s.keys("Ratio", "<tab>", "=B5/0", "<enter>")225+ s.keys("Total", "<tab>", "=B3*2", "<enter>")226+ s.keys("<up>", "<right>")227+ s.waitFor("From B3: division by zero in B5/0")228+ }},229+ {name: "decimal", setup: func(s *session) {230+ money(s)231+ s.keys("<ctrl+k>", "decimal", "<enter>")232+ s.waitFor("Decimal arithmetic on")233+ }},234+ {name: "menu-settings", setup: func(s *session) {235+ money(s)236+ s.keys("<ctrl+k>", "decimal", "<enter>")237+ s.waitFor("Decimal arithmetic on")238+ s.keys("<alt+f>", "<up>", "<up>", "<right>")239+ s.waitFor("Compute money exactly")240+ }},241+ {name: "menu-freeze", setup: func(s *session) {242+ inventory(s)243+ s.keys("<alt+v>", "<right>", "<down>")244+ s.waitFor("Keep the first row on screen")245+ }},246+ {name: "frozen", setup: func(s *session) {247+ inventory(s)248+ s.keys("<alt+v>", "<enter>", "1", "<enter>")249+ s.waitFor("Froze 1 row")250+ s.keys("<alt+v>", "<enter>", "1", "1", "<enter>")251+ s.waitFor("Froze 1 column")252+ s.keys("<ctrl+end>", "<up>", "<up>", "<shift+up>", "<shift+up>")253+ s.waitForName("F37:F39")254+ }},255+ {name: "frozen-narrow", opts: options{cols: 60, rows: 16}, setup: func(s *session) {256+ inventory(s)257+ s.keys("<right>", "<down>", "<alt+v>", "<enter>", "u", "<enter>")258+ s.waitFor("Froze 2 rows")259+ s.keys("<alt+v>", "<enter>", "u", "u", "<enter>")260+ s.waitFor("Froze 2 columns")261+ s.keys("<pgdown>", "<right>", "<right>", "<right>")262+ s.waitForName("E10")263+ }},264+ {name: "filter-picker", setup: func(s *session) {265+ inventory(s)266+ s.keys("<right>", "<alt+d>", "c")267+ s.waitFor("Created a filter")268+ s.keys("<alt+down>", "<down>", "<down>", "<space>", "<down>")269+ s.waitFor("[ ] Hardware")270+ }},271+ {name: "filter-condition", setup: func(s *session) {272+ inventory(s)273+ s.keys("<alt+d>", "c")274+ s.waitFor("Created a filter")275+ s.keys("<right>", "<right>", "<right>", "<alt+down>", "<tab>")276+ for range 8 {277+ s.keys("<down>")278+ }279+ s.keys("50")280+ s.waitFor("‹ Greater than › 50")281+ }},282+ {name: "filtered", setup: func(s *session) {283+ inventory(s)284+ s.keys("<alt+d>", "c")285+ s.waitFor("Created a filter")286+ s.keys("<right>", "<alt+down>", "<down>", "<down>", "<space>", "<enter>")287+ s.keys("<right>", "<right>", "<alt+down>", "<tab>")288+ for range 8 {289+ s.keys("<down>")290+ }291+ s.keys("50", "<enter>")292+ s.waitFor("Filtered column D")293+ s.keys("<down>", "<down>")294+ }},295+ {name: "sort-bar", setup: func(s *session) {296+ inventory(s)297+ s.keys("<right>", "<alt+d>", "<down>", "<enter>", "a", "<enter>")298+ s.waitFor("Sort A2:F41 by")299+ s.keys("<alt+a>", "<right>", "<right>", "<right>", "<space>")300+ s.waitFor("D Qty Z→A")301+ }},302+ {name: "fill-handle", setup: func(s *session) {303+ s.keys("Week", "<tab>", "Day", "<tab>", "Batch", "<enter>")304+ s.keys("1", "<tab>", "Mon", "<tab>", "Item 1", "<enter>")305+ s.keys("2", "<tab>", "Tue", "<tab>", "Item 2", "<enter>")306+ s.keys("<up>", "<up>", "<shift+down>", "<shift+right>", "<shift+right>")307+ s.waitForName("A2:C3")308+ s.mouse(ghostty.MouseActionPress, ghostty.MouseButtonLeft, 6+29, gridRow1+2, 0)309+ s.mouse(ghostty.MouseActionMotion, ghostty.MouseButtonLeft, 6+25, gridRow1+6, 0)310+ s.waitFor("Fill A2:C7")311+ }},312+ {name: "import-picker", files: importDir, setup: openImportPicker},313+ {name: "import-picker-narrow", opts: options{cols: 60, rows: 16}, files: importDir, setup: openImportPicker},314+ {name: "import-progress", setup: slowCSV},315+ {name: "import-xlsx", files: importDir, setup: func(s *session) {316+ openImportPicker(s)317+ s.keys("q3", "<enter>")318+ s.waitFor("Imported q3.xlsx")319+ }},320+ {name: "save-imported", files: importDir, setup: func(s *session) {321+ openImportPicker(s)322+ s.keys("q3", "<enter>")323+ s.waitFor("Imported q3.xlsx")324+ s.keys("<ctrl+s>")325+ s.waitFor("q3.xlsx was imported.")326+ }},327+ {name: "menu-download", setup: func(s *session) {328+ budget(s)329+ s.keys("<alt+f>", "<down>", "<down>", "<down>", "<down>", "<down>", "<right>")330+ s.waitFor("SQLite database (.sqlite)")331+ }},332+ {name: "sheets", setup: summary},333+ {name: "sheets-wide", opts: options{cols: 200, rows: 30}, setup: summary},334+ {name: "sheets-point", setup: func(s *session) {335+ summary(s)336+ s.keys("<down>", "<down>", "<left>", "Rent share", "<tab>", "=", "<ctrl+pgup>", "<up>", "<up>")337+ s.waitForBar("Summary!B3", "=Sheet1!B3")338+ }},339+ {name: "sheets-many", opts: options{cols: 60, rows: 16}, setup: func(s *session) {340+ budget(s)341+ for range 11 {342+ s.keys("<shift+f11>")343+ }344+ s.waitFor("Added Sheet12")345+ for range 5 {346+ s.keys("<ctrl+pgup>")347+ }348+ s.waitFor("‹")349+ }},350+ {name: "sheets-rename", setup: func(s *session) {351+ summary(s)352+ x := tabX(s.t, s, "Summary")353+ s.click(ghostty.MouseButtonLeft, x, int(s.rows)-1)354+ s.click(ghostty.MouseButtonLeft, x, int(s.rows)-1)355+ s.waitFor("Rename sheet: Summary")356+ s.keys("Q3 totals")357+ }},358+ {name: "sheets-menu", setup: func(s *session) {359+ summary(s)360+ s.click(ghostty.MouseButtonRight, tabX(s.t, s, "Summary"), int(s.rows)-1)361+ s.waitFor("Move right")362+ }},363+ {name: "sheets-picker", setup: func(s *session) {364+ summary(s)365+ s.keys("<alt+shift+k>")366+ s.waitFor("Go to sheet")367+ }},368+ {name: "find-all-sheets", setup: func(s *session) {369+ summary(s)370+ s.keys("<ctrl+f>", "total", "<alt+s>")371+ s.waitFor("in all sheets")372+ s.waitFor("2 of 2 on Summary")373+ s.keys("<enter>")374+ s.waitFor("1 of 2 on Sheet1")375+ }},376+}377+378+// Key screens are also recorded on a light terminal, where the app picks379+// its light theme from the reported background color.380+func init() {381+ for _, name := range []string{"budget", "point-range", "selection-stats", "menu", "palette-search", "context-menu-column", "functions", "quit-confirm", "help", "resizing-column", "copy-marker", "copy-marker-selected", "formats", "menu-format-number", "find", "replace", "jev", "import-picker", "import-progress", "import-xlsx", "frozen", "filter-picker", "filtered", "sort-bar", "fill-handle", "chart", "chart-editor", "chart-line", "chart-pie", "links-errors", "autocomplete", "signature", "named-ranges", "trace-precedents", "sheets", "sheets-point", "sheets-menu", "sheets-many"} {382+ for _, sc := range screens {383+ if sc.name == name {384+ sc.name += "-light"385+ sc.opts.light = true386+ screens = append(screens, sc)387+ }388+ }389+ }390+}@@ -9,8 +9,6 @@ import (
99 "strings"1010 "testing"1111 "time"12-13- ghostty "go.mitchellh.com/libghostty"1412 )1513 1614 var update = flag.Bool("update", false, "rewrite golden screens in testdata/screens")@@ -25,493 +23,6 @@ type screen struct {
2523 setup func(s *session)2624 }2725 28-// budget types a small sheet with text, numbers, formulas and an error the29-// way a Sheets user would (Tab across, Enter back), then selects the total.30-func budget(s *session) {31- s.keys("Household budget 2026", "<enter>", "<down>")32- s.keys("Rent", "<tab>", "1450", "<enter>")33- s.keys("Groceries", "<tab>", "612.4", "<enter>")34- s.keys("Transit", "<tab>", "96", "<enter>")35- s.keys("Savings rate", "<tab>", "=B3/0", "<enter>")36- s.keys("Total", "<tab>", "=SUM(B3:B5)", "<enter>")37- s.keys("<up>", "<right>")38- s.waitForBar("B7", "=SUM(B3:B5)")39-}40-41-// formatted types a bill schedule with dates, currency and percentages,42-// then styles it with Sheets' shortcuts: bold title and totals, headers43-// aligned over their numbers, a struck-out cancelled bill and an italic44-// note. It ends by bolding the totals, so line 3 shows the feedback.45-func formatted(s *session) {46- s.keys("Bills for October", "<enter>")47- s.keys("Item", "<tab>", "Due", "<tab>", "Amount", "<tab>", "Share", "<enter>")48- s.keys("Rent", "<tab>", "10/1/2026", "<tab>", "$1,450.00", "<tab>", "=C3/C$7", "<enter>")49- s.keys("Food", "<tab>", "9/28/2026", "<tab>", "612.4", "<tab>", "=C4/C$7", "<enter>")50- s.keys("Transit", "<tab>", "9/30/2026", "<tab>", "96", "<tab>", "=C5/C$7", "<enter>")51- s.keys("Gym", "<tab>", "10/5/2026", "<tab>", "40", "<tab>", "=C6/C$7", "<enter>")52- s.keys("Total", "<tab>", "<tab>", "=SUM(C3:C6)", "<tab>", "=SUM(D3:D6)", "<enter>")53- s.keys("<down>", "<left>", "<left>", "Gym cancelled from November", "<enter>")54- // Currency for the amounts, percent for the shares.55- s.keys("<ctrl+home>", "<down>", "<down>", "<down>", "<right>", "<right>", "<shift+down>", "<shift+down>", "<ctrl+shift+4>")56- s.keys("<right>", "<up>", "<shift+down>", "<shift+down>", "<shift+down>", "<shift+down>", "<ctrl+shift+5>")57- // Bold title, bold headers with the number headers on the right.58- s.keys("<ctrl+home>", "<ctrl+b>", "<down>", "<shift+right>", "<shift+right>", "<shift+right>", "<ctrl+b>")59- s.keys("<right>", "<shift+right>", "<shift+right>", "<ctrl+shift+r>")60- // The cancelled bill is struck out; the note is italic.61- s.keys("<ctrl+home>", "<down>", "<down>", "<down>", "<down>", "<down>", "<shift+right>", "<shift+right>", "<shift+right>", "<alt+shift+5>")62- s.keys("<down>", "<down>", "<down>", "<ctrl+i>")63- s.keys("<up>", "<up>", "<shift+right>", "<shift+right>", "<shift+right>", "<ctrl+b>")64- s.waitFor("Bold on for A7:D7")65-}66-67-// reviews types customer reviews with JEV columns, answered by a fake68-// service so the screen is deterministic.69-func reviews(s *session) {70- s.keys("Review", "<tab>", "Complaint?", "<tab>", "Likely", "<tab>", "Sentiment", "<tab>", "Urgency", "<enter>")71- for i, r := range []string{"Box arrived crushed", "Fast and friendly", "Wrong size, no reply"} {72- row := fmt.Sprint(i + 2)73- s.keys(r, "<tab>",74- `=JEV.TEST(A`+row+`, "Is this a complaint?")`, "<tab>",75- `=JEV.PROB(A`+row+`, "Is this a complaint?")`, "<tab>",76- `=JEV.CLASSIFY(A`+row+`, "Sentiment", "negative, positive")`, "<tab>",77- `=JEV.SCORE(A`+row+`, "Urgency", "low, mid, high")`, "<enter>")78- }79- s.keys("<up>", "<right>", "<right>", "<right>")80- s.waitFor("JEV: positive, 80% confident")81-}82-83-// inventory pastes a stock list of 40 items with a header row, the way84-// data usually arrives, and returns to A1.85-func inventory(s *session) {86- items := []string{"Bolts", "Nuts", "Washers", "Hinges", "Brackets", "Screws", "Anchors", "Rivets"}87- cats := []string{"Fasteners", "Fasteners", "Fasteners", "Hardware", "Hardware", "Fasteners", "Hardware", "Fasteners"}88- rows := []string{"Item\tCategory\tBin\tQty\tPrice\tReorder"}89- for i := range 40 {90- k := i % len(items)91- rows = append(rows, fmt.Sprintf("%s %d\t%s\tB%02d\t%d\t%.2f\t%s",92- items[k], i/len(items)+1, cats[k], i%12+1, (i*37)%90+5, float64((i*53)%400)/20+0.5, []string{"no", "yes"}[i%3/2]))93- }94- s.paste(strings.Join(rows, "\n"))95- s.waitFor("Pasted 246 cells")96- s.keys("<ctrl+home>")97- s.waitForName("A1")98-}99-100-var screens = []screen{101- {name: "ready-empty", setup: func(s *session) {}},102- {name: "budget", setup: budget},103- {name: "entry-formula", setup: func(s *session) {104- budget(s)105- s.keys("<down>", "=B7*12")106- s.waitFor("ENTER")107- }},108- {name: "point-range", setup: func(s *session) {109- budget(s)110- s.keys("<right>", "=AVERAGE(", "<left>", "<up>", "<up>", "<up>", "<up>", "<shift+down>", "<shift+down>")111- s.waitFor("=AVERAGE(B3:B5")112- }},113- {name: "selection-stats", setup: func(s *session) {114- budget(s)115- s.keys("<up>", "<up>", "<up>", "<up>", "<shift+down>", "<shift+down>")116- s.waitFor("Sum 2158.4")117- }},118- {name: "column-select", setup: func(s *session) {119- budget(s)120- s.keys("<ctrl+space>")121- s.waitFor("B1:B8192")122- }},123- {name: "edit-error", setup: func(s *session) {124- s.keys("=SUM(A1", "<enter>")125- s.waitFor("Expected , or ) in SUM")126- }},127- {name: "menu", setup: func(s *session) {128- budget(s)129- s.keys("<alt+f>", "<down>", "<down>", "<down>")130- s.waitFor("Save the sheet")131- }},132- {name: "palette", setup: func(s *session) {133- budget(s)134- s.keys("<ctrl+k>")135- s.waitFor("Search the menus")136- }},137- {name: "palette-search", setup: func(s *session) {138- budget(s)139- s.keys("<alt+/>", "col")140- s.waitFor("│ › col")141- }},142- {name: "palette-narrow", opts: options{cols: 60, rows: 16}, setup: func(s *session) {143- budget(s)144- s.keys("<ctrl+k>", "sa")145- s.waitFor("│ › sa")146- }},147- {name: "context-menu", setup: func(s *session) {148- budget(s)149- s.click(ghostty.MouseButtonRight, 6+10+4, 4+3)150- s.waitFor("│ Clear")151- }},152- {name: "context-menu-column", setup: func(s *session) {153- budget(s)154- s.click(ghostty.MouseButtonRight, 6+10+4, 3)155- s.waitFor("│ Resize column")156- }},157- {name: "menu-narrow", opts: options{cols: 60, rows: 16}, setup: func(s *session) {158- budget(s)159- s.keys("<alt+h>")160- s.waitFor("│ About 012")161- }},162- {name: "functions", setup: func(s *session) {163- budget(s)164- s.keys("<alt+h>", "f", "if")165- s.waitFor("│ › if")166- }},167- {name: "about", setup: func(s *session) {168- s.keys("<alt+h>", "a")169- s.waitFor("Google Sheets keys")170- }},171- {name: "help-narrow", opts: options{cols: 60, rows: 16}, setup: func(s *session) {172- s.keys("<f1>")173- s.waitFor("Keyboard shortcuts")174- }},175- {name: "palette-wide", opts: options{cols: 200, rows: 30}, setup: func(s *session) {176- budget(s)177- s.keys("<ctrl+k>", "sel")178- s.waitFor("│ › sel")179- }},180- {name: "help-wide", opts: options{cols: 200, rows: 45}, setup: func(s *session) {181- s.keys("<f1>")182- s.waitFor("Keyboard shortcuts")183- }},184- {name: "quit-confirm", setup: func(s *session) {185- budget(s)186- s.keys("<ctrl+q>")187- s.waitFor("unsaved changes")188- }},189- {name: "prompt-width", setup: func(s *session) {190- budget(s)191- s.keys("<ctrl+home>", "<alt+o>", "c", "<enter>", "<right>", "<right>", "<right>")192- s.waitFor("Column width (1-240): 13")193- }},194- {name: "error-goto", setup: func(s *session) {195- s.keys("<f5>", "nope", "<enter>")196- s.waitFor("Not a cell, range or named range")197- }},198- {name: "help", setup: func(s *session) {199- s.keys("<f1>")200- s.waitFor("HELP")201- }},202- {name: "narrow", opts: options{cols: 60, rows: 16}, setup: budget},203- {name: "hover-resize-handle", setup: func(s *session) {204- budget(s)205- s.mouse(ghostty.MouseActionMotion, ghostty.MouseButtonUnknown, 6+10-1, 3, 0)206- s.waitFor("▐")207- }},208- {name: "resizing-column", setup: func(s *session) {209- budget(s)210- s.mouse(ghostty.MouseActionPress, ghostty.MouseButtonLeft, 6+10-1, 3, 0)211- s.mouse(ghostty.MouseActionMotion, ghostty.MouseButtonLeft, 6+10+5, 3, 0)212- s.waitFor("Column A width 16")213- }},214- {name: "copy-marker", setup: func(s *session) {215- budget(s)216- s.keys("<up>", "<up>", "<up>", "<up>", "<shift+down>", "<shift+down>", "<ctrl+c>", "<right>", "<up>")217- s.waitFor("Copied B3:B5")218- }},219- {name: "copy-marker-selected", setup: func(s *session) {220- budget(s)221- s.keys("<left>", "<up>", "<up>", "<up>", "<up>", "<shift+down>", "<shift+down>", "<shift+right>", "<ctrl+x>")222- s.waitFor("Cut A3:B5")223- }},224- {name: "undo-note", setup: func(s *session) {225- budget(s)226- s.keys("<up>", "<shift+up>", "<delete>", "<ctrl+z>")227- s.waitFor("Undid: clear B5:B6")228- }},229- {name: "narrow-copy", opts: options{cols: 60, rows: 16}, setup: func(s *session) {230- budget(s)231- s.keys("<ctrl+c>", "<down>")232- s.waitFor("Copied B7")233- }},234- {name: "formats", setup: formatted},235- {name: "jev", opts: options{jev: true}, setup: reviews},236- {name: "find", setup: func(s *session) {237- budget(s)238- s.keys("<ctrl+f>", "r")239- s.waitFor("1 of 4")240- }},241- {name: "replace", setup: func(s *session) {242- budget(s)243- s.keys("<ctrl+home>", "<shift+down>", "<shift+down>", "<shift+down>", "<shift+down>", "<shift+down>", "<shift+down>")244- s.keys("<ctrl+h>", "Rent", "<tab>", "Lease", "<alt+c>")245- s.waitFor("in A1:A7")246- }},247- {name: "find-narrow", opts: options{cols: 60, rows: 16}, setup: func(s *session) {248- budget(s)249- s.keys("<ctrl+h>", "tr", "<tab>", "x")250- s.waitFor("Replace")251- }},252- {name: "menu-format-number", setup: func(s *session) {253- formatted(s)254- s.keys("<alt+o>", "<right>")255- s.waitFor("Currency rounded")256- }},257- {name: "formats-narrow", opts: options{cols: 60, rows: 16}, setup: formatted},258- {name: "autocomplete", setup: func(s *session) {259- budget(s)260- s.keys("<down>", "=B7/su")261- s.waitFor("│ SUM ")262- }},263- {name: "autocomplete-narrow", opts: options{cols: 60, rows: 16}, setup: func(s *session) {264- budget(s)265- s.keys("<down>", "=MAX(B7,co")266- s.waitFor("│ COUNT ")267- }},268- {name: "signature", setup: func(s *session) {269- budget(s)270- s.keys("<down>", `=SUMIF(A3:A5, "R*", `)271- s.waitFor("criterion, [sum_range])")272- }},273- {name: "named-ranges", setup: func(s *session) {274- named(s)275- s.keys("<alt+d>", "n")276- s.waitFor("│ › Type a name")277- }},278- {name: "trace-precedents", setup: func(s *session) {279- named(s)280- s.keys("<alt+,>")281- s.waitFor("precedents of B8")282- }},283- {name: "chart", setup: func(s *session) {284- spending(s)285- insertChart(s)286- s.keys("<enter>", "<esc>")287- s.waitFor("READY")288- }},289- {name: "chart-editor", setup: func(s *session) {290- spending(s)291- insertChart(s)292- }},293- {name: "chart-selected", setup: func(s *session) {294- spending(s)295- insertChart(s)296- s.keys("<enter>")297- s.waitFor("Column chart of A1:C5")298- }},299- {name: "chart-bar", setup: func(s *session) { chartOfType(s, 1, "Bar chart") }},300- {name: "chart-line", setup: func(s *session) { chartOfType(s, 2, "Line chart") }},301- {name: "chart-pie", setup: func(s *session) { chartOfType(s, 3, "Pie chart") }},302- {name: "chart-narrow", opts: options{cols: 60, rows: 16}, setup: func(s *session) {303- spending(s)304- insertChart(s)305- }},306- {name: "chart-wide", opts: options{cols: 200, rows: 30}, setup: func(s *session) {307- spending(s)308- insertChart(s)309- s.keys("<enter>", "<esc>")310- s.waitFor("READY")311- }},312- {name: "links-errors", setup: func(s *session) {313- s.keys("Docs", "<tab>", "https://example.com/docs", "<enter>")314- s.keys("Help", "<tab>", `=HYPERLINK("example.org/help", "Help center")`, "<enter>")315- s.keys("Ratio", "<tab>", "=B5/0", "<enter>")316- s.keys("Total", "<tab>", "=B3*2", "<enter>")317- s.keys("<up>", "<right>")318- s.waitFor("From B3: division by zero in B5/0")319- }},320- {name: "decimal", setup: func(s *session) {321- money(s)322- s.keys("<ctrl+k>", "decimal", "<enter>")323- s.waitFor("Decimal arithmetic on")324- }},325- {name: "menu-settings", setup: func(s *session) {326- money(s)327- s.keys("<ctrl+k>", "decimal", "<enter>")328- s.waitFor("Decimal arithmetic on")329- s.keys("<alt+f>", "<up>", "<up>", "<right>")330- s.waitFor("Compute money exactly")331- }},332- {name: "menu-freeze", setup: func(s *session) {333- inventory(s)334- s.keys("<alt+v>", "<right>", "<down>")335- s.waitFor("Keep the first row on screen")336- }},337- {name: "frozen", setup: func(s *session) {338- inventory(s)339- s.keys("<alt+v>", "<enter>", "1", "<enter>")340- s.waitFor("Froze 1 row")341- s.keys("<alt+v>", "<enter>", "1", "1", "<enter>")342- s.waitFor("Froze 1 column")343- s.keys("<ctrl+end>", "<up>", "<up>", "<shift+up>", "<shift+up>")344- s.waitForName("F37:F39")345- }},346- {name: "frozen-narrow", opts: options{cols: 60, rows: 16}, setup: func(s *session) {347- inventory(s)348- s.keys("<right>", "<down>", "<alt+v>", "<enter>", "u", "<enter>")349- s.waitFor("Froze 2 rows")350- s.keys("<alt+v>", "<enter>", "u", "u", "<enter>")351- s.waitFor("Froze 2 columns")352- s.keys("<pgdown>", "<right>", "<right>", "<right>")353- s.waitForName("E10")354- }},355- {name: "filter-picker", setup: func(s *session) {356- inventory(s)357- s.keys("<right>", "<alt+d>", "c")358- s.waitFor("Created a filter")359- s.keys("<alt+down>", "<down>", "<down>", "<space>", "<down>")360- s.waitFor("[ ] Hardware")361- }},362- {name: "filter-condition", setup: func(s *session) {363- inventory(s)364- s.keys("<alt+d>", "c")365- s.waitFor("Created a filter")366- s.keys("<right>", "<right>", "<right>", "<alt+down>", "<tab>")367- for range 8 {368- s.keys("<down>")369- }370- s.keys("50")371- s.waitFor("‹ Greater than › 50")372- }},373- {name: "filtered", setup: func(s *session) {374- inventory(s)375- s.keys("<alt+d>", "c")376- s.waitFor("Created a filter")377- s.keys("<right>", "<alt+down>", "<down>", "<down>", "<space>", "<enter>")378- s.keys("<right>", "<right>", "<alt+down>", "<tab>")379- for range 8 {380- s.keys("<down>")381- }382- s.keys("50", "<enter>")383- s.waitFor("Filtered column D")384- s.keys("<down>", "<down>")385- }},386- {name: "sort-bar", setup: func(s *session) {387- inventory(s)388- s.keys("<right>", "<alt+d>", "<down>", "<enter>", "a", "<enter>")389- s.waitFor("Sort A2:F41 by")390- s.keys("<alt+a>", "<right>", "<right>", "<right>", "<space>")391- s.waitFor("D Qty Z→A")392- }},393- {name: "fill-handle", setup: func(s *session) {394- s.keys("Week", "<tab>", "Day", "<tab>", "Batch", "<enter>")395- s.keys("1", "<tab>", "Mon", "<tab>", "Item 1", "<enter>")396- s.keys("2", "<tab>", "Tue", "<tab>", "Item 2", "<enter>")397- s.keys("<up>", "<up>", "<shift+down>", "<shift+right>", "<shift+right>")398- s.waitForName("A2:C3")399- s.mouse(ghostty.MouseActionPress, ghostty.MouseButtonLeft, 6+29, gridRow1+2, 0)400- s.mouse(ghostty.MouseActionMotion, ghostty.MouseButtonLeft, 6+25, gridRow1+6, 0)401- s.waitFor("Fill A2:C7")402- }},403- {name: "import-picker", files: importDir, setup: openImportPicker},404- {name: "import-picker-narrow", opts: options{cols: 60, rows: 16}, files: importDir, setup: openImportPicker},405- {name: "import-progress", setup: slowCSV},406- {name: "import-xlsx", files: importDir, setup: func(s *session) {407- openImportPicker(s)408- s.keys("q3", "<enter>")409- s.waitFor("Imported q3.xlsx")410- }},411- {name: "save-imported", files: importDir, setup: func(s *session) {412- openImportPicker(s)413- s.keys("q3", "<enter>")414- s.waitFor("Imported q3.xlsx")415- s.keys("<ctrl+s>")416- s.waitFor("q3.xlsx was imported.")417- }},418- {name: "menu-download", setup: func(s *session) {419- budget(s)420- s.keys("<alt+f>", "<down>", "<down>", "<down>", "<down>", "<down>", "<right>")421- s.waitFor("SQLite database (.sqlite)")422- }},423- {name: "sheets", setup: summary},424- {name: "sheets-wide", opts: options{cols: 200, rows: 30}, setup: summary},425- {name: "sheets-point", setup: func(s *session) {426- summary(s)427- s.keys("<down>", "<down>", "<left>", "Rent share", "<tab>", "=", "<ctrl+pgup>", "<up>", "<up>")428- s.waitForBar("Summary!B3", "=Sheet1!B3")429- }},430- {name: "sheets-many", opts: options{cols: 60, rows: 16}, setup: func(s *session) {431- budget(s)432- for range 11 {433- s.keys("<shift+f11>")434- }435- s.waitFor("Added Sheet12")436- for range 5 {437- s.keys("<ctrl+pgup>")438- }439- s.waitFor("‹")440- }},441- {name: "sheets-rename", setup: func(s *session) {442- summary(s)443- x := tabX(s.t, s, "Summary")444- s.click(ghostty.MouseButtonLeft, x, int(s.rows)-1)445- s.click(ghostty.MouseButtonLeft, x, int(s.rows)-1)446- s.waitFor("Rename sheet: Summary")447- s.keys("Q3 totals")448- }},449- {name: "sheets-menu", setup: func(s *session) {450- summary(s)451- s.click(ghostty.MouseButtonRight, tabX(s.t, s, "Summary"), int(s.rows)-1)452- s.waitFor("Move right")453- }},454- {name: "sheets-picker", setup: func(s *session) {455- summary(s)456- s.keys("<alt+shift+k>")457- s.waitFor("Go to sheet")458- }},459- {name: "find-all-sheets", setup: func(s *session) {460- summary(s)461- s.keys("<ctrl+f>", "total", "<alt+s>")462- s.waitFor("in all sheets")463- s.waitFor("2 of 2 on Summary")464- s.keys("<enter>")465- s.waitFor("1 of 2 on Sheet1")466- }},467-}468-469-// named is the budget with its expenses named and a formula using the470-// name, ending on the total.471-func named(s *session) {472- budget(s)473- s.keys("<up>", "<up>", "<up>", "<up>", "<shift+down>", "<shift+down>", "<alt+d>", "d", "Expenses", "<enter>")474- s.waitForName("Expenses")475- s.keys("<esc>", "<ctrl+home>", "<down>", "<right>", "<right>", "4", "<enter>")476- s.keys("<ctrl+home>", "<down>", "<down>", "<down>", "<down>", "<down>", "<down>", "<down>", "<right>")477- s.keys("=AVERAGE(Expenses)*C2", "<enter>")478- s.keys("<up>")479- s.waitForBar("B8", "=AVERAGE(Expenses)*C2")480-}481-482-// chartOfType inserts a chart, picks the type n steps right of Column in483-// the editor and keeps it, leaving the chart deselected.484-func chartOfType(s *session, n int, title string) {485- spending(s)486- insertChart(s)487- for range n {488- s.keys("<right>")489- }490- s.keys("<enter>")491- s.waitFor(title + " of A1:C5")492- s.keys("<esc>")493- s.waitFor("READY")494-}495-496-func openImportPicker(s *session) {497- s.keys("<alt+f>", "<down>", "<down>", "<enter>")498- s.waitFor("6 of 6")499-}500-501-// Key screens are also recorded on a light terminal, where the app picks502-// its light theme from the reported background color.503-func init() {504- for _, name := range []string{"budget", "point-range", "selection-stats", "menu", "palette-search", "context-menu-column", "functions", "quit-confirm", "help", "resizing-column", "copy-marker", "copy-marker-selected", "formats", "menu-format-number", "find", "replace", "jev", "import-picker", "import-progress", "import-xlsx", "frozen", "filter-picker", "filtered", "sort-bar", "fill-handle", "chart", "chart-editor", "chart-line", "chart-pie", "links-errors", "autocomplete", "signature", "named-ranges", "trace-precedents", "sheets", "sheets-point", "sheets-menu", "sheets-many"} {505- for _, sc := range screens {506- if sc.name == name {507- sc.name += "-light"508- sc.opts.light = true509- screens = append(screens, sc)510- }511- }512- }513-}514-51526 func TestScreens(t *testing.T) {51627 dir := filepath.Join("testdata", "screens")51728 if *update {@@ -0,0 +1,113 @@
1+package e2e2+3+import (4+ "fmt"5+ "strings"6+)7+8+// Fixtures shared by the golden screens in screenlist_test.go: sheets9+// typed or pasted the way a user would, and steps several screens take.10+11+// budget types a small sheet with text, numbers, formulas and an error the12+// way a Sheets user would (Tab across, Enter back), then selects the total.13+func budget(s *session) {14+ s.keys("Household budget 2026", "<enter>", "<down>")15+ s.keys("Rent", "<tab>", "1450", "<enter>")16+ s.keys("Groceries", "<tab>", "612.4", "<enter>")17+ s.keys("Transit", "<tab>", "96", "<enter>")18+ s.keys("Savings rate", "<tab>", "=B3/0", "<enter>")19+ s.keys("Total", "<tab>", "=SUM(B3:B5)", "<enter>")20+ s.keys("<up>", "<right>")21+ s.waitForBar("B7", "=SUM(B3:B5)")22+}23+24+// formatted types a bill schedule with dates, currency and percentages,25+// then styles it with Sheets' shortcuts: bold title and totals, headers26+// aligned over their numbers, a struck-out cancelled bill and an italic27+// note. It ends by bolding the totals, so line 3 shows the feedback.28+func formatted(s *session) {29+ s.keys("Bills for October", "<enter>")30+ s.keys("Item", "<tab>", "Due", "<tab>", "Amount", "<tab>", "Share", "<enter>")31+ s.keys("Rent", "<tab>", "10/1/2026", "<tab>", "$1,450.00", "<tab>", "=C3/C$7", "<enter>")32+ s.keys("Food", "<tab>", "9/28/2026", "<tab>", "612.4", "<tab>", "=C4/C$7", "<enter>")33+ s.keys("Transit", "<tab>", "9/30/2026", "<tab>", "96", "<tab>", "=C5/C$7", "<enter>")34+ s.keys("Gym", "<tab>", "10/5/2026", "<tab>", "40", "<tab>", "=C6/C$7", "<enter>")35+ s.keys("Total", "<tab>", "<tab>", "=SUM(C3:C6)", "<tab>", "=SUM(D3:D6)", "<enter>")36+ s.keys("<down>", "<left>", "<left>", "Gym cancelled from November", "<enter>")37+ // Currency for the amounts, percent for the shares.38+ s.keys("<ctrl+home>", "<down>", "<down>", "<down>", "<right>", "<right>", "<shift+down>", "<shift+down>", "<ctrl+shift+4>")39+ s.keys("<right>", "<up>", "<shift+down>", "<shift+down>", "<shift+down>", "<shift+down>", "<ctrl+shift+5>")40+ // Bold title, bold headers with the number headers on the right.41+ s.keys("<ctrl+home>", "<ctrl+b>", "<down>", "<shift+right>", "<shift+right>", "<shift+right>", "<ctrl+b>")42+ s.keys("<right>", "<shift+right>", "<shift+right>", "<ctrl+shift+r>")43+ // The cancelled bill is struck out; the note is italic.44+ s.keys("<ctrl+home>", "<down>", "<down>", "<down>", "<down>", "<down>", "<shift+right>", "<shift+right>", "<shift+right>", "<alt+shift+5>")45+ s.keys("<down>", "<down>", "<down>", "<ctrl+i>")46+ s.keys("<up>", "<up>", "<shift+right>", "<shift+right>", "<shift+right>", "<ctrl+b>")47+ s.waitFor("Bold on for A7:D7")48+}49+50+// reviews types customer reviews with JEV columns, answered by a fake51+// service so the screen is deterministic.52+func reviews(s *session) {53+ s.keys("Review", "<tab>", "Complaint?", "<tab>", "Likely", "<tab>", "Sentiment", "<tab>", "Urgency", "<enter>")54+ for i, r := range []string{"Box arrived crushed", "Fast and friendly", "Wrong size, no reply"} {55+ row := fmt.Sprint(i + 2)56+ s.keys(r, "<tab>",57+ `=JEV.TEST(A`+row+`, "Is this a complaint?")`, "<tab>",58+ `=JEV.PROB(A`+row+`, "Is this a complaint?")`, "<tab>",59+ `=JEV.CLASSIFY(A`+row+`, "Sentiment", "negative, positive")`, "<tab>",60+ `=JEV.SCORE(A`+row+`, "Urgency", "low, mid, high")`, "<enter>")61+ }62+ s.keys("<up>", "<right>", "<right>", "<right>")63+ s.waitFor("JEV: positive, 80% confident")64+}65+66+// inventory pastes a stock list of 40 items with a header row, the way67+// data usually arrives, and returns to A1.68+func inventory(s *session) {69+ items := []string{"Bolts", "Nuts", "Washers", "Hinges", "Brackets", "Screws", "Anchors", "Rivets"}70+ cats := []string{"Fasteners", "Fasteners", "Fasteners", "Hardware", "Hardware", "Fasteners", "Hardware", "Fasteners"}71+ rows := []string{"Item\tCategory\tBin\tQty\tPrice\tReorder"}72+ for i := range 40 {73+ k := i % len(items)74+ rows = append(rows, fmt.Sprintf("%s %d\t%s\tB%02d\t%d\t%.2f\t%s",75+ items[k], i/len(items)+1, cats[k], i%12+1, (i*37)%90+5, float64((i*53)%400)/20+0.5, []string{"no", "yes"}[i%3/2]))76+ }77+ s.paste(strings.Join(rows, "\n"))78+ s.waitFor("Pasted 246 cells")79+ s.keys("<ctrl+home>")80+ s.waitForName("A1")81+}82+83+// named is the budget with its expenses named and a formula using the84+// name, ending on the total.85+func named(s *session) {86+ budget(s)87+ s.keys("<up>", "<up>", "<up>", "<up>", "<shift+down>", "<shift+down>", "<alt+d>", "d", "Expenses", "<enter>")88+ s.waitForName("Expenses")89+ s.keys("<esc>", "<ctrl+home>", "<down>", "<right>", "<right>", "4", "<enter>")90+ s.keys("<ctrl+home>", "<down>", "<down>", "<down>", "<down>", "<down>", "<down>", "<down>", "<right>")91+ s.keys("=AVERAGE(Expenses)*C2", "<enter>")92+ s.keys("<up>")93+ s.waitForBar("B8", "=AVERAGE(Expenses)*C2")94+}95+96+// chartOfType inserts a chart, picks the type n steps right of Column in97+// the editor and keeps it, leaving the chart deselected.98+func chartOfType(s *session, n int, title string) {99+ spending(s)100+ insertChart(s)101+ for range n {102+ s.keys("<right>")103+ }104+ s.keys("<enter>")105+ s.waitFor(title + " of A1:C5")106+ s.keys("<esc>")107+ s.waitFor("READY")108+}109+110+func openImportPicker(s *session) {111+ s.keys("<alt+f>", "<down>", "<down>", "<enter>")112+ s.waitFor("6 of 6")113+}