package app import ( "bytes" "os" "strings" "testing" "kjol/vdom" "kjol/webui" ) func TestSSRPages(t *testing.T) { for _, path := range []string{"/", "/about", "/wasm/chart", "/wasm/data", "/wasm/table", "/wasm/overlays", "/wasm/kit"} { deps := Deps{Path: func() string { return path }} html := vdom.RenderHTML(Shell(deps, Routes(deps))) t.Logf("%-8s %6d bytes portals=%d", path, len(html), strings.Count(html, "data-portal")) if len(html) < 200 { t.Errorf("%s rendered only %d bytes", path, len(html)) } } } func TestSSRTablePage(t *testing.T) { deps := Deps{Path: func() string { return "/wasm/table" }} html := vdom.RenderHTML(Shell(deps, Routes(deps))) // The table persists a personal layout in localStorage, which the SERVER CANNOT // READ. So the server renders a SKELETON, not the default table: if it rendered // the default one, a user who had reordered their columns would watch them // rearrange themselves once the wasm booted. // // This is a real cost — the page ships no table content — and it is the price of // never showing the wrong table. See webui.RestoreLayout. if !strings.Contains(html, `aria-busy="true"`) { t.Error("SSR /table should render the loading skeleton, not a table") } if !strings.Contains(html, "animate-pulse") { t.Error("the skeleton bars are missing") } if strings.Contains(html, "Ada Lovelace") { t.Error("SSR rendered table CONTENT — a user with a saved layout would watch it rearrange") } } // renderedTable drives the very table the page renders, past its skeleton. Natively // there is nothing to restore, so RestoreLayout just marks the layout settled. func renderedTable(t *testing.T) string { t.Helper() highlight := vdom.NewSignal("") table := newEmployeeTable(highlight) table.SetRows(employees()) table.RestoreLayout() return vdom.RenderHTML(table.Render()) } // Once the layout has settled, the table renders in full. func TestTableRendersOnceSettled(t *testing.T) { html := renderedTable(t) for _, want := range []string{"Ada Lovelace", "Salary"} { if !strings.Contains(html, want) { t.Errorf("settled table missing %q", want) } } // PerPage is 5, so page one holds 5 of the 12 rows. if got := strings.Count(html, "@example.com"); got != 5 { t.Errorf("rendered %d rows, want 5 (one page)", got) } // The Rank column is HiddenByDefault. if strings.Contains(html, ">Rank<") { t.Error("a HiddenByDefault column was rendered") } if !strings.Contains(html, "Page 1 of 3") { t.Error("pagination did not compute 3 pages for 12 rows at 5/page") } } // Calculated columns, end to end through the page, in all three shapes. // // Page 1 (declared order): // // salary 1200.50 1500.00 980.00 1340.00 1610.25 // bonus 150.00 300.00 0.00 220.00 400.00 func TestSSRCalculatedColumns(t *testing.T) { html := renderedTable(t) // BASIC: sum over the operand columns [Salary, Bonus], combined ACROSS each row. // If this ever aggregated DOWN the column instead, every row would read the same // number — which is exactly the bug these values are here to catch. for _, want := range []string{"$1,350.50", "$1,800.00", "$980.00", "$1,560.00", "$2,010.25"} { if !strings.Contains(html, want) { t.Errorf("Total comp missing %s (a per-row Salary + Bonus)", want) } } // ADVANCED: ([Salary] + [Bonus]) * 12. for _, want := range []string{"$16,206.00", "$21,600.00", "$11,760.00"} { if !strings.Contains(html, want) { t.Errorf("Annual column missing %s", want) } } // ADVANCED, position-dependent: SUM({Salary:1:ROW()}) accumulates down the rows. for _, want := range []string{"$2,700.50", "$3,680.50", "$5,020.50", "$6,630.75"} { if !strings.Contains(html, want) { t.Errorf("running total missing %s", want) } } // SUMMARY: aggregated DOWN the column, over ALL 12 filtered rows — not the 5 on // this page. 1200.50+1500+980+1340+1610.25+1120+1275.75+1050+1400+860+1180+990. if !strings.Contains(html, "$14,506.50") { t.Error("footer did not total the whole filtered set ($14,506.50)") } if !strings.Contains(html, "Average salary") { t.Error("summary row label missing") } } // The export path, driven through the very table the /table page renders. // // Export must write what the FILTER selected — every matching row across every page // — not the five rows on screen; the columns the user can SEE, in their order; and // the calculated columns, with each row's own value. func TestTableExport(t *testing.T) { highlight := vdom.NewSignal("") table := newEmployeeTable(highlight) table.RestoreLayout() // nothing to restore natively; reveals the table over its skeleton table.SetRows(employees()) // Filter to one team, then render (which resolves FilteredRows). table.SetSearchValue("Team", "Research", true) table.Render() csv := string(webui.ExportCSV(table.ExportColumns(), table.FilteredRows(), nil)) // PerPage is 5 and Research has 4 members, but the point is that export ignores // paging entirely: every filtered row, no one else's. for _, want := range []string{"Alan Turing", "Katherine Johnson", "Barbara Liskov", "Evelyn Boyd Granville"} { if !strings.Contains(csv, want) { t.Errorf("CSV missing filtered row %q", want) } } if strings.Contains(csv, "Ada Lovelace") { t.Error("CSV contains a row the filter excluded") } // Rank is HiddenByDefault, so it must not be exported. if strings.Contains(csv, "Item 10") { t.Error("CSV exported a hidden column") } // The calculated columns come along, and the running total ACCUMULATES — // $1,500.00 then $2,840.00 (Turing + Johnson), not the same number twice. if !strings.Contains(csv, "Running total") || !strings.Contains(csv, "$2,840.00") { t.Errorf("running total did not accumulate in the export:\n%s", csv) } // And the PDF: a real file, with the same filtered content. pdf := table.ExportPDFBytes(webui.AutoTablePDFHeader{ Title: "Employees", ShowDate: true, Orientation: webui.PDF_ORIENTATION_LANDSCAPE, }) if !bytes.HasPrefix(pdf, []byte("%PDF-")) || !bytes.Contains(pdf, []byte("%%EOF")) { t.Fatalf("PDF is not a PDF (%d bytes)", len(pdf)) } if out := os.Getenv("PDF_OUT"); out != "" { if err := os.WriteFile(out, pdf, 0o644); err != nil { t.Fatal(err) } t.Logf("wrote %s (%d bytes)", out, len(pdf)) } }