Add landing page for kjol, documentation

This commit is contained in:
2026-07-13 16:51:21 -04:00
parent 5230bd6702
commit fec8ef4a3e
54 changed files with 3529 additions and 547 deletions

View File

@@ -0,0 +1,163 @@
package webui
import (
"strings"
"testing"
"kjol/vdom"
)
// ---- input masks ----
func TestMaskTaxID(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"", ""},
{"1", "1"},
{"12", "12"},
{"123", "12-3"},
{"123456789", "12-3456789"},
{"12-3456789", "12-3456789"}, // idempotent: re-masking its own output
{"1234567890123", "12-3456789"}, // nine digits, no more
{"ab12cd3456789xy", "12-3456789"},
} {
if got := MaskTaxID(tc.in); got != tc.want {
t.Errorf("MaskTaxID(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestMaskRate(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"", ""},
{"5", "5"},
{"5.25", "5.25"},
{"5.2555", "5.255"}, // three decimals, no more
{"5.2.5", "5.25"}, // one decimal point, no more
{"007", "7"}, // no leading zeros...
{"0", "0"}, // ...but a lone zero is a number
{"0.5", "0.5"}, // and so is a leading zero before a point
{"$1,2a3.45", "123.45"},
{"5.25", "5.25"}, // idempotent
} {
if got := MaskRate(tc.in); got != tc.want {
t.Errorf("MaskRate(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// A mask is applied to its own output on every keystroke, so anything that is not
// idempotent corrupts the field as you type.
func TestMasksAreIdempotent(t *testing.T) {
for _, in := range []string{"123456789", "12-345", "1", ""} {
once := MaskTaxID(in)
if twice := MaskTaxID(once); twice != once {
t.Errorf("MaskTaxID is not idempotent: %q -> %q -> %q", in, once, twice)
}
}
for _, in := range []string{"5.255", "0.5", "007", "12"} {
once := MaskRate(in)
if twice := MaskRate(once); twice != once {
t.Errorf("MaskRate is not idempotent: %q -> %q -> %q", in, once, twice)
}
}
}
// ---- async combobox ----
func asyncOpts(labels ...string) []FormSelectOption {
out := make([]FormSelectOption, len(labels))
for i, l := range labels {
out[i] = FormSelectOption{Value: strings.ToLower(l), Label: l}
}
return out
}
// The classic bug in a search-as-you-type box: a SLOW answer to an early query lands
// after a FAST answer to the query the user actually finished typing, and overwrites it.
// The user is then reading results for "ab" while the box says "abcdef".
//
// Every response carries the query it was for; one that no longer matches is dropped.
func TestAsyncComboboxDropsStaleResponses(t *testing.T) {
var pending []func([]FormSelectOption) // answers we have not given yet
var queries []string
c := NewAsyncCombobox(AsyncComboboxOptions{
MinChars: 2,
Search: func(q string, done func([]FormSelectOption)) {
queries = append(queries, q)
pending = append(pending, done)
},
})
// Type "ab", then "abcdef". (run() is called directly: the debounce timer is a
// browser timer, and this test has no browser.)
c.query.Set("ab")
c.run("ab")
c.query.Set("abcdef")
c.run("abcdef")
if len(pending) != 2 {
t.Fatalf("expected 2 searches, got %d (%v)", len(pending), queries)
}
// The answer to "abcdef" comes back first...
pending[1](asyncOpts("Abcdef Ltd"))
if got := c.results.Get(); len(got) != 1 || got[0].Label != "Abcdef Ltd" {
t.Fatalf("the current query's results were not shown: %v", got)
}
// ...and the slow answer to "ab" arrives afterwards. It must be DISCARDED.
pending[0](asyncOpts("Ab Corp", "Abacus"))
got := c.results.Get()
if len(got) != 1 || got[0].Label != "Abcdef Ltd" {
t.Errorf("a stale response overwrote the current results: %v", got)
}
}
// Below MinChars there is no search at all — a one-character query against a big table
// is a scan, and every user who ever types starts by typing one character.
func TestAsyncComboboxRespectsMinChars(t *testing.T) {
searched := 0
c := NewAsyncCombobox(AsyncComboboxOptions{
MinChars: 3,
Search: func(string, func([]FormSelectOption)) { searched++ },
})
c.onInput("a")
c.onInput("ab")
if searched != 0 {
t.Errorf("searched %d times below MinChars", searched)
}
if c.IsOpen() {
t.Error("the panel opened with nothing to show")
}
}
// Choosing an option closes the box and reports the whole option, not just its value —
// the caller usually needs the label back to display it.
func TestAsyncComboboxSelect(t *testing.T) {
var picked FormSelectOption
c := NewAsyncCombobox(AsyncComboboxOptions{
Search: func(_ string, done func([]FormSelectOption)) { done(asyncOpts("Ada Lovelace")) },
})
c.query.Set("ada")
c.run("ada")
c.f.Show()
node := c.Render(FormAsyncComboboxProps{OnSelect: func(o FormSelectOption) { picked = o }})
if !findAndClick(node, "Ada Lovelace") {
t.Fatalf("no result row to click:\n%s", vdom.RenderHTML(node))
}
if picked.Value != "ada lovelace" {
t.Errorf("OnSelect got %+v", picked)
}
if c.IsOpen() {
t.Error("choosing an option should close the panel")
}
if c.query.Get() != "Ada Lovelace" {
t.Errorf("the field should show the chosen label, got %q", c.query.Get())
}
}