1package e2e23import (4 "fmt"5 "os"6 "os/exec"7 "path/filepath"8 "strings"9 "syscall"10 "testing"1112 "github.com/xuri/excelize/v2"13)1415// writeXLSX writes a workbook with a formatted quarter on its first sheet16// and a second sheet that 012 doesn't import yet.17func writeXLSX(t *testing.T, name string) {18 t.Helper()19 x := excelize.NewFile()20 defer x.Close()21 x.SetSheetName("Sheet1", "Q3")22 x.NewSheet("Q4")23 rows := [][]any{24 {"Region", "July", "August", "September"},25 {"North", 1200.5, 1310, 990},26 {"South", 870, 905.25, 1122},27 {"West", 450, 610, 702.75},28 }29 for r, row := range rows {30 cell, _ := excelize.CoordinatesToCellName(1, r+1)31 x.SetSheetRow("Q3", cell, &row)32 }33 x.SetCellValue("Q3", "A5", "Total")34 for _, c := range []string{"B", "C", "D"} {35 x.SetCellFormula("Q3", c+"5", fmt.Sprintf("SUM(%s2:%s4)", c, c))36 }37 bold, _ := x.NewStyle(&excelize.Style{Font: &excelize.Font{Bold: true}})38 x.SetCellStyle("Q3", "A1", "D1", bold)39 x.SetCellStyle("Q3", "A5", "A5", bold)40 code := `\$#,##0.00`41 money, _ := x.NewStyle(&excelize.Style{CustomNumFmt: &code})42 x.SetCellStyle("Q3", "B2", "D5", money)43 x.SetColWidth("Q3", "A", "A", 12)44 x.SetCellValue("Q4", "A1", "later")45 if err := x.SaveAs(name); err != nil {46 t.Fatal(err)47 }48}4950func writeText(t *testing.T, name, text string) {51 t.Helper()52 if err := os.WriteFile(name, []byte(text), 0o644); err != nil {53 t.Fatal(err)54 }55}5657func TestOpenXLSXFromCommandLine(t *testing.T) {58 t.Parallel()59 dir := t.TempDir()60 writeXLSX(t, filepath.Join(dir, "q3.xlsx"))61 s := start(t, dir, "q3.xlsx")62 s.waitFor("Imported q3.xlsx (6 rows)")63 s.waitFor("$1,200.50")64 // Every sheet comes in, each with its tab.65 if !strings.HasPrefix(s.line(29), " Q3 Q4 +") {66 t.Errorf("tabs %q", s.line(29))67 }68 s.keys("<ctrl+pgdown>")69 s.waitForBar("A1", "later")70 s.keys("<ctrl+pgup>")71 s.keys("<f5>", "D5", "<enter>")72 s.waitForBar("D5", "=SUM(D2:D4)")73 s.waitFor("$2,814.75")74 if !strings.Contains(s.line(29), "q3.xlsx") {75 t.Errorf("status line %q", s.line(29))76 }77 // Save asks for a .012 name or a download back to Excel.78 s.keys("<ctrl+s>")79 s.waitFor("q3.xlsx was imported.")80 s.waitFor("Download as XLSX")81 s.keys("<enter>")82 s.waitFor("Save as: q3.012")83 s.keys("<enter>")84 s.eventually("q3.012 saved", func() bool {85 _, err := os.Stat(filepath.Join(dir, "q3.012"))86 return err == nil87 })88}8990func TestOpenCSVFromCommandLine(t *testing.T) {91 t.Parallel()92 dir := t.TempDir()93 writeText(t, filepath.Join(dir, "prices.csv"), "\xEF\xBB\xBFItem;Price;Date\nTea;$3.50;9/26/2026\nCake;4;2026-09-27\n")94 s := start(t, dir, "prices.csv")95 s.waitFor("Imported prices.csv (3 rows); separated by semicolons")96 s.waitFor("$3.50")97 s.waitFor("9/26/2026")98 s.keys("<down>", "<down>", "<right>", "<right>")99 s.waitForBar("C3", "2026-09-27")100101 // Download it back as TSV from the menus.102 s.keys("<ctrl+k>", "tsv", "<enter>")103 s.waitFor("Download as TSV: prices.tsv")104 s.keys("<enter>")105 s.waitFor("Downloaded prices.tsv (3 rows)")106 data, err := os.ReadFile(filepath.Join(dir, "prices.tsv"))107 if err != nil || string(data) != "Item\tPrice\tDate\nTea\t$3.50\t9/26/2026\nCake\t4\t2026-09-27\n" {108 t.Errorf("prices.tsv %q %v", data, err)109 }110}111112func TestOpenMissingImport(t *testing.T) {113 t.Parallel()114 dir := t.TempDir()115 out, err := runBinary(dir, "nope.xlsx")116 if err == nil || !strings.Contains(out, "nope.xlsx: no such file") {117 t.Errorf("missing file: %v %q", err, out)118 }119}120121// importDir fills dir with one file of each kind for the import picker.122func importDir(t *testing.T, dir string) {123 t.Helper()124 writeXLSX(t, filepath.Join(dir, "q3.xlsx"))125 writeText(t, filepath.Join(dir, "sales-2026.csv"), strings.Repeat("North,1200,9/26/2026\n", 40))126 writeText(t, filepath.Join(dir, "budget.tsv"), "Rent\t1450\nFood\t612\n")127 writeText(t, filepath.Join(dir, "BUDGET86.WK1"), strings.Repeat("\x00", 2400))128 writeText(t, filepath.Join(dir, "shop.sqlite"), strings.Repeat("s", 8192))129 writeText(t, filepath.Join(dir, "trips.parquet"), strings.Repeat("p", 1_250_000))130 writeText(t, filepath.Join(dir, "notes.txt"), "not listed")131 writeText(t, filepath.Join(dir, "budget.012"), "{}")132}133134func TestImportPickerLists(t *testing.T) {135 t.Parallel()136 dir := t.TempDir()137 importDir(t, dir)138 s := start(t, dir)139 s.keys("<alt+f>", "<down>", "<down>", "<enter>")140 s.waitFor("Lotus 1-2-3 worksheet")141 scr := s.screen()142 for _, want := range []string{"BUDGET86.WK1", "budget.tsv", "q3.xlsx", "sales-2026.csv", "shop.sqlite", "trips.parquet", "6 of 6"} {143 if !strings.Contains(scr, want) {144 t.Errorf("picker lacks %q:\n%s", want, scr)145 }146 }147 if strings.Contains(scr, "notes.txt") || strings.Contains(scr, "budget.012") {148 t.Errorf("picker lists other files:\n%s", scr)149 }150 s.keys("tsv", "<enter>")151 s.waitFor("Imported budget.tsv (2 rows)")152}153154// slowCSV imports from a named pipe that the test fills in two parts, so155// the import stops half way with a known row count.156func slowCSV(s *session) {157 fifo := filepath.Join(s.dir, "big.csv")158 if err := syscall.Mkfifo(fifo, 0o644); err != nil {159 s.t.Fatal(err)160 }161 done := make(chan struct{})162 s.t.Cleanup(func() { close(done) })163 go func() {164 f, err := os.OpenFile(fifo, os.O_WRONLY, 0)165 if err != nil {166 return167 }168 defer f.Close()169 for i := range 2048 {170 fmt.Fprintf(f, "%d,North,%d.50,9/26/2026,a longer note to fill the pipe\n", i, i*3)171 }172 <-done173 }()174 s.keys("<alt+f>", "<down>", "<down>", "<enter>", "./big.csv", "<enter>")175 s.waitFor("2,048 rows read")176}177178// runBinary runs 012 without a terminal, for errors reported before the179// screen starts.180func runBinary(dir string, args ...string) (string, error) {181 cmd := exec.Command(binPath, args...)182 cmd.Dir = dir183 out, err := cmd.CombinedOutput()184 return string(out), err185}186187func TestImportProgressAndCancel(t *testing.T) {188 t.Parallel()189 s := start(t, "")190 slowCSV(s)191 s.waitFor("WAIT")192 s.waitFor("Importing big.csvā¦")193 s.keys("<esc>")194 s.waitFor("Import of big.csv cancelled")195 s.waitFor("READY")196}197198// File > Import on a spreadsheet with something in it asks where the199// file goes: new sheets after the one shown (every sheet of an .xlsx),200// undone as one step, or in place of the sheet shown.201func TestImportLocation(t *testing.T) {202 t.Parallel()203 dir := t.TempDir()204 importDir(t, dir)205 s := start(t, dir)206 s.keys("<shift+f11>", "<ctrl+pgup>", "Notes", "<enter>")207 openImportPicker(s)208 s.keys("q3", "<enter>")209 s.waitFor("Import location")210 scr := s.screen()211 if !strings.Contains(scr, "Insert new sheets") || !strings.Contains(scr, "Replace spreadsheet") || strings.Contains(scr, "Replace current sheet") {212 t.Errorf("locations for an .xlsx:\n%s", scr)213 }214 s.keys("<enter>")215 s.waitFor("Imported q3.xlsx as Q3 and Q4")216 if l := s.line(int(s.rows) - 1); !strings.HasPrefix(l, " Sheet1 Q3 Q4 Sheet2 +") {217 t.Errorf("tabs %q", l)218 }219 s.waitFor("$1,200.50")220 s.keys("<ctrl+z>")221 s.waitFor("Undid: import q3.xlsx")222 if l := s.line(int(s.rows) - 1); !strings.HasPrefix(l, " Sheet1 Sheet2 +") {223 t.Errorf("tabs after undo %q", l)224 }225226 // A chart of the one-cell table in A1 doesn't fit the two columns227 // replacing it: it goes, and undo brings it back.228 s.keys("<ctrl+home>")229 insertChart(s)230 s.keys("<enter>", "<esc>")231 s.waitFor("No numbers to chart")232 openImportPicker(s)233 s.keys("tsv", "<enter>")234 s.waitFor("Import location")235 s.keys("current", "<enter>")236 s.waitFor("Imported budget.tsv into Sheet1 (2 rows); removed charts: Chart 1 (A1)")237 s.waitForBar("A1", "Rent")238 if strings.Contains(s.screen(), "No numbers to chart") {239 t.Errorf("the chart stayed:\n%s", s.screen())240 }241 s.keys("<ctrl+z>")242 s.waitFor("Undid: import budget.tsv")243 s.waitFor("No numbers to chart")244 s.keys("<ctrl+home>")245 s.waitForBar("A1", "Notes")246}