1package live23import (4 "context"5 "os"6 "path/filepath"7 "strings"8 "testing"9 "time"1011 "src.rest/cat/012/internal/fileio"12 "src.rest/cat/012/internal/sheet"13)1415// rowsText writes an update's rows as a test reads them.16func rowsText(rows []sheet.LiveRow) string {17 var out []string18 for _, r := range rows {19 var cells []string20 for _, c := range r {21 cells = append(cells, c.V.String())22 }23 out = append(out, strings.Join(cells, ","))24 }25 return strings.Join(out, "|")26}2728// follow starts following a file in a temporary directory, returning it29// and a function writing it.30func follow(t *testing.T, name string) (*File, string) {31 t.Helper()32 path := filepath.Join(t.TempDir(), name)33 k, ok := KindOf(name, "")34 if !ok {35 t.Fatalf("no kind for %s", name)36 }37 f := NewFile(name, path, k, fileio.Options{})38 t.Cleanup(f.Close)39 return f, path40}4142func write(t *testing.T, path, text string) {43 t.Helper()44 if err := os.WriteFile(path, []byte(text), 0o644); err != nil {45 t.Fatal(err)46 }47}4849func appendTo(t *testing.T, path, text string) {50 t.Helper()51 f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)52 if err != nil {53 t.Fatal(err)54 }55 defer f.Close()56 if _, err := f.WriteString(text); err != nil {57 t.Fatal(err)58 }59}6061func poll(t *testing.T, f *File) (Update, bool) {62 t.Helper()63 return f.Poll(context.Background())64}6566func TestFileAppendAndPartialLine(t *testing.T) {67 f, path := follow(t, "log.csv")68 u, ok := poll(t, f)69 if !ok || u.Err != "the file isn't there" {70 t.Fatalf("missing file: %+v", u)71 }72 if _, ok := poll(t, f); ok {73 t.Fatal("the same error twice")74 }75 write(t, path, "when,n\n1,10\n2,2")76 u, ok = poll(t, f)77 if !ok || !u.Reset || u.Err != "" || rowsText([]sheet.LiveRow{u.Header}) != "when,n" || rowsText(u.Rows) != "1,10" {78 t.Fatalf("first read: %+v", u)79 }80 if _, ok := poll(t, f); ok {81 t.Fatal("nothing changed, yet an update")82 }83 appendTo(t, path, "0\n3,30\n")84 u, ok = poll(t, f)85 if !ok || u.Reset || rowsText(u.Rows) != "2,20|3,30" {86 t.Fatalf("append: %+v", u)87 }88}8990func TestFileTruncateRotateRewrite(t *testing.T) {91 f, path := follow(t, "log.tsv")92 write(t, path, "a\n1\n2\n")93 poll(t, f)94 // Truncated: read again from the start.95 write(t, path, "a\n9\n")96 if u, ok := poll(t, f); !ok || !u.Reset || rowsText(u.Rows) != "9" {97 t.Fatalf("truncated: %+v", u)98 }99 // Rewritten in place, longer: the bytes read before differ.100 write(t, path, "b\n7\n8\n")101 if u, ok := poll(t, f); !ok || !u.Reset || rowsText(u.Rows) != "7|8" || u.Header[0].V.Str != "b" {102 t.Fatalf("rewritten: %+v", u)103 }104 // Rotated: renamed away, and a new file in its place.105 os.Rename(path, path+".1")106 write(t, path, "c\n5\n6\n7\n")107 if u, ok := poll(t, f); !ok || !u.Reset || rowsText(u.Rows) != "5|6|7" {108 t.Fatalf("rotated: %+v", u)109 }110 appendTo(t, path+".1", "gone\n") // the old file no longer counts111 if u, ok := poll(t, f); ok {112 t.Fatalf("the rotated file was read: %+v", u)113 }114}115116func TestFileMaxRead(t *testing.T) {117 f, path := follow(t, "big.csv")118 f.MaxRead = 8119 write(t, path, "a\n1\n2\n3\n4\n5\n6\n")120 var all []sheet.LiveRow121 for i := 0; ; i++ {122 u, _ := poll(t, f)123 all = append(all, u.Rows...)124 if !u.More {125 break126 }127 if i > 10 {128 t.Fatal("never done")129 }130 }131 if rowsText(all) != "1|2|3|4|5|6" {132 t.Fatalf("%q", rowsText(all))133 }134}135136func TestFileNDJSON(t *testing.T) {137 f, path := follow(t, "events.ndjson")138 if f.kind != fileio.JSON {139 t.Skip("ndjson isn't JSON's extension here")140 }141 write(t, path, `{"a": 1}`+"\n"+`{"a": 2, "b"`)142 u, _ := poll(t, f)143 if rowsText(u.Rows) != "1" {144 t.Fatalf("%+v", u)145 }146 appendTo(t, path, `: "x"}`+"\n")147 u, _ = poll(t, f)148 if rowsText(u.Rows) != "2,x" || rowsText([]sheet.LiveRow{u.Header}) != "a,b" {149 t.Fatalf("%+v", u)150 }151}152153func TestFileRewrittenWholeDebounced(t *testing.T) {154 f, path := follow(t, "book.xlsx")155 now := time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC)156 f.Now = func() time.Time { return now }157 save := func(v string) {158 s := sheet.New()159 s.Set(sheet.Addr{}, "n")160 s.Set(sheet.Addr{Row: 1}, v)161 if _, err := fileio.Export(context.Background(), path, fileio.XLSX, fileio.SnapBook(s), fileio.ExportOptions{}); err != nil {162 t.Fatal(err)163 }164 }165 save("1")166 u, ok := poll(t, f)167 if !ok || !u.Reset || rowsText(u.Rows) != "1" {168 t.Fatalf("first read: %+v", u)169 }170 // Rewritten twice in quick succession: read once, after it settles.171 save("22")172 if _, ok := poll(t, f); ok {173 t.Fatal("read while changing")174 }175 now = now.Add(100 * time.Millisecond)176 save("333")177 os.Chtimes(path, now, now)178 if _, ok := poll(t, f); ok {179 t.Fatal("read while still changing")180 }181 now = now.Add(100 * time.Millisecond)182 if _, ok := poll(t, f); ok {183 t.Fatal("read before the debounce")184 }185 now = now.Add(time.Second)186 if u, ok := poll(t, f); !ok || rowsText(u.Rows) != "333" {187 t.Fatalf("after settling: %+v %v", u, ok)188 }189 if _, ok := poll(t, f); ok {190 t.Fatal("read again, unchanged")191 }192}193194func TestKindOf(t *testing.T) {195 if k, ok := KindOf("x.txt", "tsv"); !ok || k != fileio.TSV {196 t.Fatal("format by name")197 }198 if k, ok := KindOf("x.nuon", ""); !ok || k != fileio.NUON {199 t.Fatal("format by extension")200 }201 if _, ok := KindOf("x.txt", ""); ok {202 t.Fatal("an unknown format")203 }204}205206func TestUpdateOp(t *testing.T) {207 at := time.Now()208 op := Update{Reset: true, Err: "e", Note: "n", At: at}.Op("app")209 if op.Region != "app" || !op.Reset || op.Err != "e" || op.Note != "n" || !op.At.Equal(at) {210 t.Fatalf("%+v", op)211 }212}