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 / transfer_test.go
transfer_test.go 10.7K
1package ui23import (4 "context"5 "database/sql"6 "fmt"7 "os"8 "path/filepath"9 "strings"10 "testing"1112 tea "charm.land/bubbletea/v2"1314 "src.rest/cat/012/internal/fileio"15 "src.rest/cat/012/internal/sheet"16 "src.rest/cat/012/internal/ui/choicebar"17 "src.rest/cat/012/internal/ui/transfer"18)1920func writeFile(t *testing.T, name, text string) {21 t.Helper()22 os.MkdirAll(filepath.Dir(name), 0o755)23 if err := os.WriteFile(name, []byte(text), 0o644); err != nil {24 t.Fatal(err)25 }26}2728func pickerTitles(t *testing.T, m *Model) []string {29 var out []string30 for _, pm := range openPicker(t, m).Shown() {31 out = append(out, pm.Item.Title+" | "+pm.Item.Detail)32 }33 return out34}3536func TestImportPicker(t *testing.T) {37 t.Chdir(t.TempDir())38 writeFile(t, "sales.csv", "Region,Total\nNorth,\"$1,200\"\n")39 writeFile(t, "notes.txt", "not importable")40 writeFile(t, "Old.WK1", "x")41 writeFile(t, "sub/deep.tsv", "a\tb\n")42 m := newModel()43 m.runCommand("file.import")44 got := strings.Join(pickerTitles(t, m), "\n")45 want := "Old.WK1 | Lotus 1-2-3 worksheet 1 B\nsales.csv | Comma-separated values 28 B"46 if got != want {47 t.Errorf("picker lists\n%s\nwant\n%s", got, want)48 }49 if !strings.Contains(screen(m), "Import") || !strings.Contains(line(m, m.height-1), "Import sales.csv") &&50 !strings.Contains(line(m, m.height-1), "Import Old.WK1") {51 t.Errorf("screen:\n%s", screen(m))52 }53 press(t, m, "sal", "<enter>")54 if m.overlay != nil || m.mode != modeReady {55 t.Fatalf("after import: mode %v overlay %T err %q", m.mode, m.overlay, m.errMsg)56 }57 if v := m.sheet.Value(addr("B2")); v.Num != 1200 {58 t.Errorf("B2 = %+v", v)59 }60 if l := line(m, contextLine); l != "Imported sales.csv (2 rows)" {61 t.Errorf("context line %q", l)62 }63 if l := line(m, m.height-1); !strings.HasPrefix(l, " sales ") || !strings.Contains(l, "sales.csv") || m.changed {64 t.Errorf("status %q changed %v", line(m, m.height-1), m.changed)65 }6667 // A typed path imports a file that isn't listed.68 m.runCommand("file.import")69 press(t, m, "sub/deep.tsv", "<enter>", "Replace spreadsheet", "<enter>")70 if input(m, "B1") != "b" || m.xfer.Source != "sub/deep.tsv" {71 t.Errorf("typed path: B1 %q source %q err %q", input(m, "B1"), m.xfer.Source, m.errMsg)72 }73 // Unknown types and broken files say what's wrong.74 m.runCommand("file.import")75 press(t, m, "notes.txt", "<enter>")76 if m.mode != modeError || !strings.Contains(m.errMsg, "Can't import notes.txt: 012 imports .csv, .tsv") {77 t.Errorf("txt: %v %q", m.mode, m.errMsg)78 }79 press(t, m, "<esc>")80 m.runCommand("file.import")81 press(t, m, "old", "<enter>", "Replace spreadsheet", "<enter>")82 if m.mode != modeError || m.errMsg != "Couldn't import Old.WK1: not a Lotus 1-2-3 worksheet: the file is empty" &&83 !strings.Contains(m.errMsg, "Couldn't import Old.WK1: not a Lotus 1-2-3 worksheet") {84 t.Errorf("wk1: %v %q", m.mode, m.errMsg)85 }86}8788func TestImportAsksBeforeReplacingChanges(t *testing.T) {89 t.Chdir(t.TempDir())90 writeFile(t, "a.csv", "1\n")91 m := newModel()92 press(t, m, "keep me", "<enter>")93 m.runCommand("file.import")94 press(t, m, "<enter>", "Replace spreadsheet", "<enter>")95 if _, ok := m.overlay.(*choicebar.Bar); !ok || !strings.Contains(line(m, contextLine), "Importing replaces this spreadsheet, which has unsaved changes.") {96 t.Fatalf("no warning: %q", line(m, contextLine))97 }98 press(t, m, "<esc>")99 if input(m, "A1") != "keep me" {100 t.Errorf("Esc imported anyway")101 }102 m.runCommand("file.import")103 press(t, m, "<enter>", "Replace spreadsheet", "<enter>", "<enter>")104 if input(m, "A1") != "1" {105 t.Errorf("Enter didn't import: %q", input(m, "A1"))106 }107}108109func TestOpenImportsOtherFormats(t *testing.T) {110 t.Chdir(t.TempDir())111 writeFile(t, "data.tsv", "x\t2\n")112 m := newModel()113 press(t, m, "<ctrl+o>", "data.tsv", "<enter>")114 if input(m, "B1") != "2" || m.filename != "" || m.xfer.Source != "data.tsv" {115 t.Errorf("open: B1 %q file %q source %q", input(m, "B1"), m.filename, m.xfer.Source)116 }117 // Save offers a .012 file or exporting back.118 press(t, m, "<ctrl+s>")119 if l := line(m, contextLine); !strings.Contains(l, "data.tsv was imported.") || !strings.Contains(l, "Save as data.012") || !strings.Contains(l, "Download as TSV") {120 t.Errorf("save choices %q", l)121 }122 press(t, m, "<enter>")123 if m.mode != modePrompt || m.line.Text() != "data.012" {124 t.Errorf("save as %v %q", m.mode, m.line.Text())125 }126 press(t, m, "<enter>")127 if m.filename != "data.012" {128 t.Errorf("saved as %q", m.filename)129 }130 press(t, m, "<ctrl+s>") // now a native file: saves directly131 if m.overlay != nil || m.mode != modeReady {132 t.Errorf("second save asked again")133 }134}135136func TestSQLiteTablePicker(t *testing.T) {137 t.Chdir(t.TempDir())138 db, err := sql.Open("sqlite", "shop.db")139 if err != nil {140 t.Fatal(err)141 }142 for _, s := range []string{143 "CREATE TABLE orders (id INTEGER, total REAL)", "INSERT INTO orders VALUES (1, 9.5), (2, 3)",144 "CREATE TABLE people (name TEXT)", "INSERT INTO people VALUES ('Ada')",145 } {146 if _, err := db.Exec(s); err != nil {147 t.Fatal(err)148 }149 }150 db.Close()151 m := newModel()152 m.runCommand("file.import")153 press(t, m, "<enter>")154 got := strings.Join(pickerTitles(t, m), "\n")155 if got != "orders | 2 rows, 2 columns\npeople | 1 row, 1 column\nRun a query | SELECT …" {156 t.Errorf("tables:\n%s", got)157 }158 if !strings.Contains(screen(m), "Import from shop.db") {159 t.Errorf("title missing:\n%s", screen(m))160 }161 press(t, m, "<enter>")162 if m.sheet.Value(addr("B2")).Num != 9.5 || line(m, contextLine) != "Imported orders from shop.db (3 rows)" {163 t.Errorf("B2 %+v, context %q", m.sheet.Value(addr("B2")), line(m, contextLine))164 }165166 m.runCommand("file.import")167 press(t, m, "<enter>", "Replace spreadsheet", "<enter>", "query", "<enter>")168 if m.mode != modePrompt || m.line.Text() != "SELECT * FROM orders" {169 t.Fatalf("query prompt %v %q", m.mode, m.line.Text())170 }171 press(t, m, "<ctrl+a>")172 m.line.Buf, m.line.Pos = []rune("SELECT sum(total) AS s FROM orders"), len("SELECT sum(total) AS s FROM orders")173 press(t, m, "<enter>")174 if m.sheet.Value(addr("A2")).Num != 12.5 {175 t.Errorf("query A2 %+v err %q", m.sheet.Value(addr("A2")), m.errMsg)176 }177}178179func TestDownload(t *testing.T) {180 t.Chdir(t.TempDir())181 m := newModel()182 m.runCommand("file.download.csv")183 if m.mode == modePrompt || line(m, contextLine) != "Nothing to download: the sheet is empty" {184 t.Errorf("empty sheet: %q", line(m, contextLine))185 }186 press(t, m, "Item", "<tab>", "Cost", "<enter>", "Rent", "<tab>", "$1,450", "<enter>", "Total", "<tab>", "=B2*2", "<enter>")187188 m.runCommand("file.download.csv")189 if l := line(m, contextLine); !strings.HasPrefix(l, "Download as CSV: SHEET1.csv") {190 t.Errorf("prompt %q", l)191 }192 press(t, m, "bills", "<enter>")193 data, err := os.ReadFile("bills.csv")194 if err != nil || string(data) != "Item,Cost\nRent,\"$1,450\"\nTotal,\"$2,900\"\n" {195 t.Errorf("bills.csv %q %v", data, err)196 }197 if l := line(m, contextLine); l != "Downloaded bills.csv (3 rows); 1 formula saved as values" {198 t.Errorf("note %q", l)199 }200201 // Downloading over a file asks first.202 m.runCommand("file.download.csv")203 press(t, m, "bills", "<enter>")204 if l := line(m, contextLine); !strings.Contains(l, "bills.csv exists.") || !strings.Contains(l, "Replace") {205 t.Fatalf("no replace question: %q", l)206 }207 press(t, m, "<esc>")208209 m.runCommand("file.download.xlsx")210 press(t, m, "<enter>")211 if _, err := os.Stat("SHEET1.xlsx"); err != nil {212 t.Errorf("xlsx: %v %q", err, m.errMsg)213 }214215 // SQLite asks for a table, and exports the selection when there is216 // one.217 press(t, m, "<ctrl+home>", "<shift+down>")218 m.runCommand("file.download.sqlite")219 if l := line(m, contextLine); !strings.HasPrefix(l, "Download A1:A2 as SQLite: SHEET1.sqlite") {220 t.Errorf("sqlite prompt %q", l)221 }222 press(t, m, "<enter>")223 if l := line(m, contextLine); !strings.HasPrefix(l, "Table in SHEET1.sqlite: SHEET1") {224 t.Errorf("table prompt %q", l)225 }226 press(t, m, "<enter>")227 if l := line(m, contextLine); l != "Downloaded SHEET1.sqlite (1 row)" {228 t.Errorf("sqlite note %q err %q", l, m.errMsg)229 }230 m.runCommand("file.download.sqlite")231 press(t, m, "<enter>", "<enter>")232 if l := line(m, contextLine); !strings.Contains(l, "Table SHEET1 exists in SHEET1.sqlite.") {233 t.Errorf("no table question: %q", l)234 }235 press(t, m, "<enter>")236 ts, _ := fileio.Tables(context.Background(), "SHEET1.sqlite")237 if len(ts) != 1 || ts[0].Rows != 1 {238 t.Errorf("tables %+v", ts)239 }240}241242func TestImportProgress(t *testing.T) {243 m := newModel()244 m.Update(tea.WindowSizeMsg{Width: 80, Height: 16})245 cancelled := false246 prog := fileio.NewProgress()247 m.xfer.Begin("data/big.csv", prog, func() { cancelled = true }, transfer.Book)248 prog.Report(1234, 0, 0)249 if ind := m.indicator(); ind != "WAIT" {250 t.Errorf("indicator %q", ind)251 }252 if l := line(m, contextLine); l != "Importing big.csv… Esc cancel" {253 t.Errorf("context %q", l)254 }255 if l := line(m, m.height-1); !strings.HasPrefix(l, "Importing big.csv") || !strings.HasSuffix(l, "1,234 rows read") {256 t.Errorf("status %q", l)257 }258 if pb := m.View().ProgressBar; pb == nil || pb.State != tea.ProgressBarIndeterminate {259 t.Errorf("terminal progress %+v", pb)260 }261 prog.Report(4096, 1, 2)262 l := line(m, m.height-1)263 if l != "Importing big.csv 4,096 rows read ━━━━━━━━━━━━━━━─────────────── 50%" {264 t.Errorf("status %q", l)265 }266 if pb := m.View().ProgressBar; pb == nil || pb.Value != 50 {267 t.Errorf("terminal progress %+v", pb)268 }269 // Keys wait; Esc cancels.270 press(t, m, "x", "<down>")271 if m.cur != (sheet.Addr{}) || m.mode != modeReady {272 t.Errorf("keys went through: %v %v", m.cur, m.mode)273 }274 press(t, m, "<esc>")275 if !cancelled {276 t.Error("Esc didn't cancel")277 }278 if m.xfer.Busy() || line(m, contextLine) != "Import of big.csv cancelled" {279 t.Errorf("after cancel: %q", line(m, contextLine))280 }281 // A stale result from an earlier import is ignored.282 send(m, transfer.ImportedMsg{ID: 3, Name: "old.csv", Res: &fileio.Result{Sheet: sheet.New()}})283 if m.xfer.Source != "" {284 t.Errorf("stale import applied")285 }286}287288func TestImportOnStart(t *testing.T) {289 t.Chdir(t.TempDir())290 writeFile(t, "start.csv", "7\n")291 m := New(sheet.New(), "")292 m.Import("start.csv")293 run(m, m.Init())294 if input(m, "A1") != "7" || m.displayName() != "start.csv" {295 t.Errorf("A1 %q name %q err %q", input(m, "A1"), m.displayName(), m.errMsg)296 }297}298299// An import that finishes while the terminal is in the background says so300// with a desktop notification.301func TestImportNotifiesWhenBlurred(t *testing.T) {302 m := newModel()303 send(m, tea.BlurMsg{})304 id := m.xfer.Begin("big.csv", fileio.NewProgress(), func() {}, transfer.Book)305 _, cmd := m.Update(transfer.ImportedMsg{ID: id, Name: "big.csv", Res: &fileio.Result{Sheet: sheet.New(), Rows: 3}})306 var notified bool307 for _, msg := range flatten(cmd) {308 if raw, ok := msg.(tea.RawMsg); ok && strings.Contains(fmt.Sprint(raw.Msg), "012: Imported big.csv") {309 notified = true310 }311 }312 if !notified {313 t.Error("no notification for a background import")314 }315}