oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1package sheets
2
3import (
4 "bytes"
5 "mime/multipart"
6 "net/http"
7 "net/http/httptest"
8 "os"
9 "path/filepath"
10 "strings"
11 "testing"
12 "time"
13)
14
15// The corpus these tests upload is the committed XLSX round-trip
16// corpus, read as bytes. Real files rather than hand-built ones, and
17// read rather than generated, because excelize is confined to the xlsx
18// package (guardstest' TestExcelizeConfinedToXLSXPackage walks test
19// files too) — so this package cannot build a workbook even if it
20// wanted to, and reaching for the corpus is both the only way and the
21// better one.
22func corpusFile(t *testing.T, name string) []byte {
23 t.Helper()
24 path := filepath.Join("..", "..", "testdata", "corpus", "xlsx", name)
25 body, err := os.ReadFile(path)
26 if err != nil {
27 t.Fatalf("read corpus %s: %v (run GENCORPUS=1 go test ./internal/sheets/xlsx/ -run TestGenerateCorpus)", name, err)
28 }
29 return body
30}
31
32// uploadRequest builds the multipart POST the import form sends.
33func uploadRequest(t *testing.T, filename string, body []byte) *http.Request {
34 t.Helper()
35 var buf bytes.Buffer
36 w := multipart.NewWriter(&buf)
37 part, err := w.CreateFormFile(importField, filename)
38 if err != nil {
39 t.Fatalf("CreateFormFile: %v", err)
40 }
41 if _, err := part.Write(body); err != nil {
42 t.Fatalf("write part: %v", err)
43 }
44 if err := w.Close(); err != nil {
45 t.Fatalf("close writer: %v", err)
46 }
47 req := httptest.NewRequest(http.MethodPost, "/sheets/import", &buf)
48 req.Header.Set("Content-Type", w.FormDataContentType())
49 return req
50}
51
52// awaitJob polls the status page the way a person's browser does, and
53// returns where it sent them plus what the page last said.
54//
55// Polling rather than reaching into the registry: the thing worth
56// testing is that the status page reports a job somebody can actually
57// reach, and a test that inspects jobs.Get directly would pass with the
58// route unmounted or its parameter misnamed.
59func awaitJob(t *testing.T, app *App, cookies []*http.Cookie, jobPath string) (location, body string) {
60 t.Helper()
61 deadline := time.Now().Add(20 * time.Second)
62 for time.Now().Before(deadline) {
63 rec := do(t, app, as(httptest.NewRequest(http.MethodGet, jobPath, nil), cookies))
64 switch rec.Code {
65 case http.StatusSeeOther:
66 return rec.Header().Get("Location"), rec.Body.String()
67 case http.StatusOK:
68 page := rec.Body.String()
69 // The partial drops data-poll the moment the job is no
70 // longer running, which is exactly the signal the shim
71 // stops on — so it is the right one to stop on here.
72 if !strings.Contains(page, "data-poll=") {
73 return "", page
74 }
75 default:
76 t.Fatalf("job status page answered %d, want 200 or 303", rec.Code)
77 }
78 time.Sleep(20 * time.Millisecond)
79 }
80 t.Fatal("job did not finish inside the deadline")
81 return "", ""
82}
83
84// importCorpus runs the whole path a person walks: post the file,
85// follow the 303 to the job, wait for it, and hand back where it
86// landed.
87func importCorpus(t *testing.T, app *App, cookies []*http.Cookie, filename, corpus string) (location, body string) {
88 t.Helper()
89 rec := do(t, app, as(uploadRequest(t, filename, corpusFile(t, corpus)), cookies))
90 if rec.Code != http.StatusSeeOther {
91 t.Fatalf("import answered %d, want 303 (body %q)", rec.Code, rec.Body.String())
92 }
93 jobPath := rec.Header().Get("Location")
94 if !strings.HasPrefix(jobPath, "/jobs/") {
95 t.Fatalf("import redirected to %q, want a /jobs/ path", jobPath)
96 }
97 return awaitJob(t, app, cookies, jobPath)
98}
99
100// TestImportedSheetIsThereAndEditable is the feature in one test: drop
101// a real workbook on the list, and end up on a sheet holding its
102// contents that the table view will let you edit.
103//
104// It uses formulas.xlsx on purpose. Its B column is nothing but
105// formulas over the A column, so a B3 reading 45 proves this app's own
106// engine re-evaluated them (2+3=5, SUM=9, 5*9=45) rather than importing
107// whatever cached values the file happened to carry — which is the one
108// thing about XLSX import that could look right while being wrong.
109func TestImportedSheetIsThereAndEditable(t *testing.T) {
110 app, cookies := signIn(t)
111
112 location, page := importCorpus(t, app, cookies, "budget.xlsx", "formulas.xlsx")
113 if location == "" {
114 t.Fatalf("import did not finish on a sheet; last page said %q", page)
115 }
116 if !strings.HasPrefix(location, "/sheets/") {
117 t.Fatalf("job sent us to %q, want a /sheets/ path", location)
118 }
119
120 // The sheet is in the list, named after the file.
121 viewer := signedInMember(t, app)
122 list, err := app.listSheets(viewer)
123 if err != nil {
124 t.Fatalf("listSheets: %v", err)
125 }
126 if len(list) != 1 {
127 t.Fatalf("want exactly one sheet after one import, got %d", len(list))
128 }
129 if list[0].Title != "budget" {
130 t.Errorf("sheet title = %q, want %q (the filename without its extension)", list[0].Title, "budget")
131 }
132
133 // The cells are really there, and the formulas were recomputed.
134 h, err := app.openTabFor(list[0])
135 if err != nil {
136 t.Fatalf("openTabFor: %v", err)
137 }
138 defer h.Release()
139 // cellText is the table view's own reader, so this asserts what a
140 // person would actually see rather than a parallel formatting path
141 // that could agree with the engine while the screen does not.
142 idx := newPosIndex(h)
143 for _, want := range []struct {
144 row, col int
145 value string
146 }{
147 {0, 0, "2"}, {1, 0, "3"}, {2, 0, "4"},
148 {0, 1, "5"}, {1, 1, "9"}, {2, 1, "45"},
149 } {
150 got := cellText(h, idx, want.row, want.col)
151 if got != want.value {
152 t.Errorf("cell at row %d col %d = %q, want %q", want.row, want.col, got, want.value)
153 }
154 }
155
156 // And the scriptless view opens it, which is what "available to
157 // edit" means for the path that does not need JavaScript.
158 rec := do(t, app, as(httptest.NewRequest(http.MethodGet, location+"/table", nil), cookies))
159 if rec.Code != http.StatusOK {
160 t.Fatalf("table view answered %d, want 200", rec.Code)
161 }
162 if !strings.Contains(rec.Body.String(), "45") {
163 t.Error("table view does not show the recomputed 45")
164 }
165}
166
167// TestImportKeepsAnImportedFill pins that formatting survives the HTTP
168// path too, not just the xlsx package's own round trip.
169func TestImportKeepsAnImportedFill(t *testing.T) {
170 app, cookies := signIn(t)
171
172 if location, page := importCorpus(t, app, cookies, "colours.xlsx", "fills.xlsx"); location == "" {
173 t.Fatalf("import did not finish on a sheet; last page said %q", page)
174 }
175 viewer := signedInMember(t, app)
176 list, err := app.listSheets(viewer)
177 if err != nil {
178 t.Fatalf("listSheets: %v", err)
179 }
180 h, err := app.openTabFor(list[0])
181 if err != nil {
182 t.Fatalf("openTabFor: %v", err)
183 }
184 defer h.Release()
185
186 rowID, colID := h.GridRows()[0].ID, h.GridCols()[0].ID
187 f := h.Format(rowID, colID)
188 if f == nil || f.FillHex == "" {
189 t.Fatal("A1 lost its imported fill on the way through the upload path")
190 }
191}
192