diff --git a/README.md b/README.md index 38d9a232..62bf936b 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ go get github.com/linkdata/jaws After the dependency is added, your Go module will be able to import and use JaWS as demonstrated below. +For widget authoring guidance see `ui/README.md`. + ## Quick start The following minimal program renders a single range input whose value @@ -53,8 +55,10 @@ import ( "html/template" "log/slog" "net/http" + "sync" "github.com/linkdata/jaws" + "github.com/linkdata/jaws/ui" ) const indexhtml = ` @@ -82,7 +86,7 @@ func main() { var mu sync.Mutex var f float64 - http.DefaultServeMux.Handle("/", jw.Handler("index", jaws.Bind(&mu, &f))) + http.DefaultServeMux.Handle("/", ui.Handler(jw, "index", jaws.Bind(&mu, &f))) slog.Error(http.ListenAndServe("localhost:8080", nil).Error()) } ``` @@ -148,15 +152,19 @@ endpoints to be registered in whichever router you choose to use. All of the endpoints start with "/jaws/", and `Jaws.ServeHTTP()` will handle all of them. -* `/jaws/jaws.*.js` +* `/jaws/\.jaws\.[0-9a-z]+\.css` + + Serves the built-in JaWS stylesheet. + + The response should be cached indefinitely. + +* `/jaws/\.jaws\.[0-9a-z]+\.js` - The exact URL is the value of `jaws.JavascriptPath`. It must return - the client-side Javascript, the uncompressed contents of which can be had with - `jaws.JavascriptText`, or a gzipped version with `jaws.JavascriptGZip`. + Serves the built-in JaWS client-side JavaScript. The response should be cached indefinitely. -* `/jaws/[0-9a-z]+` +* `/jaws/[0-9a-z]+` (and `/jaws/[0-9a-z]+/noscript`) The WebSocket endpoint. The trailing string must be decoded using `jaws.JawsKeyValue()` and then the matching JaWS Request retrieved @@ -172,7 +180,7 @@ of them. come online. This is done in order to not spam the WebSocket endpoint with connection requests, and browsers are better at handling XHR requests failing. - If you don't have a JaWS object, or if it's completion channel is closed (see + If you don't have a JaWS object, or if its completion channel is closed (see `Jaws.Done()`), return **503 Service Unavailable**. If you're ready to serve requests, return **204 No Content**. @@ -181,7 +189,10 @@ of them. Handling the routes with the standard library's `http.DefaultServeMux`: ```go -jw := jaws.New() +jw, err := jaws.New() +if err != nil { + panic(err) +} defer jw.Close() go jw.Serve() http.DefaultServeMux.Handle("/jaws/", jw) @@ -190,7 +201,10 @@ http.DefaultServeMux.Handle("/jaws/", jw) Handling the routes with [Echo](https://echo.labstack.com/): ```go -jw := jaws.New() +jw, err := jaws.New() +if err != nil { + panic(err) +} defer jw.Close() go jw.Serve() router := echo.New() @@ -208,7 +222,7 @@ be made into one using `jaws.MakeHTMLGetter()`. In order of precedence, this can be: * `jaws.HTMLGetter`: `JawsGetHTML(*Element) template.HTML` to be used as-is. * `jaws.Getter[string]`: `JawsGet(*Element) string` that will be escaped using `html.EscapeString`. -* `jaws.AnyGetter`: `JawsGetAny(*Element) any` that will be rendered using `fmt.Sprint()` and escaped using `html.EscapeString`. +* `jaws.Formatter`: `Format("%v") string` that will be escaped using `html.EscapeString`. * `fmt.Stringer`: `String() string` that will be escaped using `html.EscapeString`. * a static `template.HTML` or `string` to be used as-is with no HTML escaping. * everything else is rendered using `fmt.Sprint()` and escaped using `html.EscapeString`. @@ -229,9 +243,12 @@ getters and on-success handlers. ### Session handling JaWS has non-persistent session handling integrated. Sessions won't -be persisted across restarts and must have an expiry time. A new -session is created with `EnsureSession()` and sending it's `Cookie()` -to the client browser. +be persisted across restarts and must have an expiry time. + +Use one of these patterns: + +* Wrap page handlers with `Jaws.Session(handler)` to ensure a session exists. +* Call `Jaws.NewSession(w, r)` explicitly to create and attach a fresh session cookie. When subsequent Requests are created with `NewRequest()`, if the HTTP request has the cookie set and comes from the correct IP, @@ -251,11 +268,11 @@ default is `jaws`. ### A note on the Context -The Request object embeds a context.Context inside it's struct, +The Request object embeds a context.Context inside its struct, contrary to recommended Go practice. The reason is that there is no unbroken call chain from the time the Request -object is created when the initial HTTP request comes in and when it's +object is created when the initial HTTP request comes in and when it is requested during the Javascript WebSocket HTTP request. ### Security of the WebSocket callback diff --git a/jaws/LICENSE b/core/LICENSE similarity index 100% rename from jaws/LICENSE rename to core/LICENSE diff --git a/jaws/auth.go b/core/auth.go similarity index 60% rename from jaws/auth.go rename to core/auth.go index 886c03fd..1400e599 100644 --- a/jaws/auth.go +++ b/core/auth.go @@ -1,4 +1,4 @@ -package jaws +package core type Auth interface { Data() map[string]any // returns authenticated user data, or nil @@ -8,8 +8,8 @@ type Auth interface { type MakeAuthFn func(*Request) Auth -type defaultAuth struct{} +type DefaultAuth struct{} -func (defaultAuth) Data() map[string]any { return nil } -func (defaultAuth) Email() string { return "" } -func (defaultAuth) IsAdmin() bool { return true } +func (DefaultAuth) Data() map[string]any { return nil } +func (DefaultAuth) Email() string { return "" } +func (DefaultAuth) IsAdmin() bool { return true } diff --git a/jaws/auth_test.go b/core/auth_test.go similarity index 84% rename from jaws/auth_test.go rename to core/auth_test.go index 5b9a7438..081eadf0 100644 --- a/jaws/auth_test.go +++ b/core/auth_test.go @@ -1,11 +1,11 @@ -package jaws +package core import ( "testing" ) func Test_defaultAuth(t *testing.T) { - a := defaultAuth{} + a := DefaultAuth{} if a.Data() != nil { t.Fatal() } diff --git a/jaws/bind.go b/core/bind.go similarity index 96% rename from jaws/bind.go rename to core/bind.go index 5b3764bb..af4f200a 100644 --- a/jaws/bind.go +++ b/core/bind.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "sync" diff --git a/jaws/bind_test.go b/core/bind_test.go similarity index 99% rename from jaws/bind_test.go rename to core/bind_test.go index e3a0edbd..829183c8 100644 --- a/jaws/bind_test.go +++ b/core/bind_test.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "io" diff --git a/jaws/binder.go b/core/binder.go similarity index 99% rename from jaws/binder.go rename to core/binder.go index a95fa12c..588191fa 100644 --- a/jaws/binder.go +++ b/core/binder.go @@ -1,4 +1,4 @@ -package jaws +package core // BindSetHook is a function that replaces JawsSetLocked for a Binder. // diff --git a/jaws/binding.go b/core/binding.go similarity index 99% rename from jaws/binding.go rename to core/binding.go index 49b93f86..b73c6dd3 100644 --- a/jaws/binding.go +++ b/core/binding.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "fmt" diff --git a/jaws/bindinghook.go b/core/bindinghook.go similarity index 99% rename from jaws/bindinghook.go rename to core/bindinghook.go index 3aa4a3db..1956c2f3 100644 --- a/jaws/bindinghook.go +++ b/core/bindinghook.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "fmt" diff --git a/core/broadcast_test.go b/core/broadcast_test.go new file mode 100644 index 00000000..ee979191 --- /dev/null +++ b/core/broadcast_test.go @@ -0,0 +1,81 @@ +package core + +import ( + "html/template" + "testing" + + "github.com/linkdata/jaws/what" +) + +func TestCoverage_GenerateHeadAndConvenienceBroadcasts(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + + if err := jw.GenerateHeadHTML("%zz"); err == nil { + t.Fatal("expected url parse error") + } + if err := jw.GenerateHeadHTML("/favicon.ico", "/app.js"); err != nil { + t.Fatal(err) + } + + jw.Reload() + if msg := nextBroadcast(t, jw); msg.What != what.Reload { + t.Fatalf("unexpected reload msg %#v", msg) + } + jw.Redirect("/next") + if msg := nextBroadcast(t, jw); msg.What != what.Redirect || msg.Data != "/next" { + t.Fatalf("unexpected redirect msg %#v", msg) + } + jw.Alert("info", "hello") + if msg := nextBroadcast(t, jw); msg.What != what.Alert || msg.Data != "info\nhello" { + t.Fatalf("unexpected alert msg %#v", msg) + } + + jw.SetInner("t", template.HTML("x")) + if msg := nextBroadcast(t, jw); msg.What != what.Inner || msg.Data != "x" { + t.Fatalf("unexpected set inner msg %#v", msg) + } + jw.SetAttr("t", "k", "v") + if msg := nextBroadcast(t, jw); msg.What != what.SAttr || msg.Data != "k\nv" { + t.Fatalf("unexpected set attr msg %#v", msg) + } + jw.RemoveAttr("t", "k") + if msg := nextBroadcast(t, jw); msg.What != what.RAttr || msg.Data != "k" { + t.Fatalf("unexpected remove attr msg %#v", msg) + } + jw.SetClass("t", "c") + if msg := nextBroadcast(t, jw); msg.What != what.SClass || msg.Data != "c" { + t.Fatalf("unexpected set class msg %#v", msg) + } + jw.RemoveClass("t", "c") + if msg := nextBroadcast(t, jw); msg.What != what.RClass || msg.Data != "c" { + t.Fatalf("unexpected remove class msg %#v", msg) + } + jw.SetValue("t", "v") + if msg := nextBroadcast(t, jw); msg.What != what.Value || msg.Data != "v" { + t.Fatalf("unexpected set value msg %#v", msg) + } + jw.Insert("t", "0", "a") + if msg := nextBroadcast(t, jw); msg.What != what.Insert || msg.Data != "0\na" { + t.Fatalf("unexpected insert msg %#v", msg) + } + jw.Replace("t", "0", "b") + if msg := nextBroadcast(t, jw); msg.What != what.Replace || msg.Data != "0\nb" { + t.Fatalf("unexpected replace msg %#v", msg) + } + jw.Delete("t") + if msg := nextBroadcast(t, jw); msg.What != what.Delete { + t.Fatalf("unexpected delete msg %#v", msg) + } + jw.Append("t", "c") + if msg := nextBroadcast(t, jw); msg.What != what.Append || msg.Data != "c" { + t.Fatalf("unexpected append msg %#v", msg) + } + jw.JsCall("t", "fn", `{"a":1}`) + if msg := nextBroadcast(t, jw); msg.What != what.Call || msg.Data != `fn={"a":1}` { + t.Fatalf("unexpected jscall msg %#v", msg) + } +} diff --git a/jaws/clickhandler.go b/core/clickhandler.go similarity index 97% rename from jaws/clickhandler.go rename to core/clickhandler.go index 9d17f21f..f0e3c81c 100644 --- a/jaws/clickhandler.go +++ b/core/clickhandler.go @@ -1,4 +1,4 @@ -package jaws +package core import "github.com/linkdata/jaws/what" diff --git a/jaws/clickhandler_test.go b/core/clickhandler_test.go similarity index 75% rename from jaws/clickhandler_test.go rename to core/clickhandler_test.go index 268efea8..d79c04cb 100644 --- a/jaws/clickhandler_test.go +++ b/core/clickhandler_test.go @@ -1,6 +1,7 @@ -package jaws +package core import ( + "html/template" "testing" "github.com/linkdata/jaws/what" @@ -22,7 +23,7 @@ var _ ClickHandler = (*testJawsClick)(nil) func Test_clickHandlerWapper_JawsEvent(t *testing.T) { th := newTestHelper(t) - nextJid = 0 + NextJid = 0 rq := newTestRequest(t) defer rq.Close() @@ -32,12 +33,12 @@ func Test_clickHandlerWapper_JawsEvent(t *testing.T) { } want := `
inner
` - rq.Div("inner", tjc) + rq.UI(testDivWidget{inner: template.HTML("inner")}, tjc) if got := rq.BodyString(); got != want { - t.Errorf("Request.Div() = %q, want %q", got, want) + t.Errorf("Request.UI(NewDiv()) = %q, want %q", got, want) } - rq.InCh <- wsMsg{Data: "text", Jid: 1, What: what.Input} + rq.InCh <- WsMsg{Data: "text", Jid: 1, What: what.Input} select { case <-th.C: th.Timeout() @@ -46,7 +47,7 @@ func Test_clickHandlerWapper_JawsEvent(t *testing.T) { default: } - rq.InCh <- wsMsg{Data: "adam", Jid: 1, What: what.Click} + rq.InCh <- WsMsg{Data: "adam", Jid: 1, What: what.Click} select { case <-th.C: th.Timeout() diff --git a/jaws/container.go b/core/container.go similarity index 93% rename from jaws/container.go rename to core/container.go index 1a75da7d..b4218b31 100644 --- a/jaws/container.go +++ b/core/container.go @@ -1,4 +1,4 @@ -package jaws +package core type Container interface { // JawsContains must return a slice of hashable UI objects. The slice contents must not be modified after returning it. diff --git a/core/dateformat.go b/core/dateformat.go new file mode 100644 index 00000000..958eef86 --- /dev/null +++ b/core/dateformat.go @@ -0,0 +1,4 @@ +package core + +// ISO8601 is the date format used by date input widgets (YYYY-MM-DD). +const ISO8601 = "2006-01-02" diff --git a/jaws/defaultcookiename.go b/core/defaultcookiename.go similarity index 92% rename from jaws/defaultcookiename.go rename to core/defaultcookiename.go index e30c5fca..f086960f 100644 --- a/jaws/defaultcookiename.go +++ b/core/defaultcookiename.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "os" @@ -22,7 +22,7 @@ func makeCookieName(exename string) (cookie string) { var b []byte for _, ch := range exename { if ('a' <= ch && ch <= 'z') || ('A' <= ch && ch <= 'Z') || ('0' <= ch && ch <= '9') { - b = append(b, byte(ch)) + b = append(b, byte(ch)) //#nosec G115 } } if len(b) > 0 { diff --git a/jaws/defaultcookiename_test.go b/core/defaultcookiename_test.go similarity index 97% rename from jaws/defaultcookiename_test.go rename to core/defaultcookiename_test.go index 584be928..f1babadc 100644 --- a/jaws/defaultcookiename_test.go +++ b/core/defaultcookiename_test.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "path" diff --git a/jaws/element.go b/core/element.go similarity index 97% rename from jaws/element.go rename to core/element.go index 63b3518c..9df20ab9 100644 --- a/jaws/element.go +++ b/core/element.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "bytes" @@ -26,6 +26,13 @@ func (e *Element) String() string { return fmt.Sprintf("Element{%T, id=%q, Tags: %v}", e.Ui(), e.Jid(), e.Request.TagsOf(e)) } +// AddHandler adds the given handlers to the Element. +func (e *Element) AddHandlers(h ...EventHandler) { + if !e.deleted { + e.handlers = append(e.handlers, h...) + } +} + // Tag adds the given tags to the Element. func (e *Element) Tag(tags ...any) { if !e.deleted { @@ -102,7 +109,7 @@ func (e *Element) JawsUpdate() { func (e *Element) queue(wht what.What, data string) { if !e.deleted { - e.Request.queue(wsMsg{ + e.Request.queue(WsMsg{ Data: data, Jid: e.jid, What: wht, diff --git a/jaws/element_test.go b/core/element_test.go similarity index 97% rename from jaws/element_test.go rename to core/element_test.go index 9cfb5eb3..699163ea 100644 --- a/jaws/element_test.go +++ b/core/element_test.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "fmt" @@ -112,7 +112,7 @@ func TestElement_Queued(t *testing.T) { e.Order([]jid.Jid{1, 2}) replaceHTML := template.HTML(fmt.Sprintf("
", e.Jid().String())) e.Replace(replaceHTML) - th.Equal(rq.wsQueue, []wsMsg{ + th.Equal(rq.wsQueue, []WsMsg{ { Data: "hidden\n", Jid: e.jid, @@ -168,7 +168,7 @@ func TestElement_Queued(t *testing.T) { } pendingRq := rq.Jaws.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) - RequestWriter{pendingRq, httptest.NewRecorder()}.UI(tss) + testRequestWriter{rq: pendingRq, Writer: httptest.NewRecorder()}.UI(tss) rq.UI(tss) rq.Jaws.Dirty(tss) diff --git a/jaws/errillegaltagtype.go b/core/errillegaltagtype.go similarity index 96% rename from jaws/errillegaltagtype.go rename to core/errillegaltagtype.go index 476d6130..e9399c24 100644 --- a/jaws/errillegaltagtype.go +++ b/core/errillegaltagtype.go @@ -1,4 +1,4 @@ -package jaws +package core import "fmt" diff --git a/jaws/errnotcomparable.go b/core/errnotcomparable.go similarity index 97% rename from jaws/errnotcomparable.go rename to core/errnotcomparable.go index 3f9ac126..9f772ef0 100644 --- a/jaws/errnotcomparable.go +++ b/core/errnotcomparable.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "reflect" diff --git a/jaws/errnowebsocketrequest.go b/core/errnowebsocketrequest.go similarity index 97% rename from jaws/errnowebsocketrequest.go rename to core/errnowebsocketrequest.go index fbf88cb6..b5ee3f2f 100644 --- a/jaws/errnowebsocketrequest.go +++ b/core/errnowebsocketrequest.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "fmt" diff --git a/jaws/errpendingcancelled.go b/core/errpendingcancelled.go similarity index 98% rename from jaws/errpendingcancelled.go rename to core/errpendingcancelled.go index 133d3e8c..124537e1 100644 --- a/jaws/errpendingcancelled.go +++ b/core/errpendingcancelled.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "fmt" diff --git a/jaws/errvalueunchanged.go b/core/errvalueunchanged.go similarity index 94% rename from jaws/errvalueunchanged.go rename to core/errvalueunchanged.go index e8d81955..8321dd2b 100644 --- a/jaws/errvalueunchanged.go +++ b/core/errvalueunchanged.go @@ -1,4 +1,4 @@ -package jaws +package core import "errors" diff --git a/jaws/eventhandler.go b/core/eventhandler.go similarity index 94% rename from jaws/eventhandler.go rename to core/eventhandler.go index 4f46d816..a704423f 100644 --- a/jaws/eventhandler.go +++ b/core/eventhandler.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "github.com/linkdata/jaws/what" @@ -44,7 +44,7 @@ func callEventHandler(obj any, e *Element, wht what.What, val string) (err error return ErrEventUnhandled } -func callEventHandlers(ui any, e *Element, wht what.What, val string) (err error) { +func CallEventHandlers(ui any, e *Element, wht what.What, val string) (err error) { if err = callEventHandler(ui, e, wht, val); err == ErrEventUnhandled { for _, h := range e.handlers { if err = callEventHandler(h, e, wht, val); err != ErrEventUnhandled { diff --git a/core/extra_test.go b/core/extra_test.go new file mode 100644 index 00000000..858e2ed5 --- /dev/null +++ b/core/extra_test.go @@ -0,0 +1,195 @@ +package core + +import ( + "io" + "net/http/httptest" + "strings" + "testing" + + "github.com/linkdata/deadlock" + "github.com/linkdata/jaws/what" +) + +type testStringTag struct{} + +func (testStringTag) String() string { return "str" } + +type testApplyGetterAll struct { + initErr error +} + +func (a testApplyGetterAll) JawsGetTag(*Request) any { return Tag("tg") } +func (a testApplyGetterAll) JawsClick(*Element, string) error { + return ErrEventUnhandled +} +func (a testApplyGetterAll) JawsEvent(*Element, what.What, string) error { + return ErrEventUnhandled +} +func (a testApplyGetterAll) JawsInit(*Element) error { + return a.initErr +} + +func TestCoverage_getterSetterFactories(t *testing.T) { + g := MakeGetter[string]("x") + if g.JawsGet(nil) != "x" { + t.Fatal("unexpected getter value") + } + if tag := g.(TagGetter).JawsGetTag(nil); tag != nil { + t.Fatal("expected nil tag") + } + // makeGetter Getter[T] passthrough branch. + g2 := MakeGetter[string](Getter[string](getterStatic[string]{v: "y"})) + if g2.JawsGet(nil) != "y" { + t.Fatal("unexpected passthrough getter value") + } + + s := MakeSetter[string]("x") + if s.JawsGet(nil) != "x" { + t.Fatal("unexpected setter getter value") + } + if err := s.JawsSet(nil, "x"); err != ErrValueNotSettable { + t.Fatalf("unexpected err: %v", err) + } + // makeSetter Setter[T] passthrough branch. + s2 := MakeSetter[string](Setter[string](setterStatic[string]{v: "z"})) + if s2.JawsGet(nil) != "z" { + t.Fatal("unexpected passthrough setter value") + } +} + +func TestCoverage_miscBranches(t *testing.T) { + NextJid = 0 + rq := newTestRequest(t) + defer rq.Close() + + // TagString pointer branch + fmt.Stringer branch + _ = TagString(&testStringTag{}) + _ = TagString(testStringTag{}) + + // Register UI render + reg := testRegisterUI{Updater: &testUi{}} + elem := rq.NewElement(reg) + if err := elem.JawsRender(nil, nil); err != nil { + t.Fatal(err) + } + + // RequestWriter.Initial + if rq.Request.Initial() == nil { + t.Fatal("expected initial request") + } + if rq.Initial() == nil { + t.Fatal("expected initial request from writer") + } + + // DeleteElement exported wrapper + e2 := rq.NewElement(&testUi{}) + id2 := e2.Jid() + rq.DeleteElement(e2) + if rq.GetElementByJid(id2) != nil { + t.Fatal("expected deleted element") + } + +} + +func TestCoverage_namedBoolOptionAndContains(t *testing.T) { + NextJid = 0 + rq := newTestRequest(t) + defer rq.Close() + + nba := NewNamedBoolArray().Add("1", "one") + nba.Set("1", true) + contents := nba.JawsContains(nil) + if len(contents) != 1 { + t.Fatalf("want 1 content got %d", len(contents)) + } + elem := rq.NewElement(contents[0]) + var sb strings.Builder + if err := elem.JawsRender(&sb, []any{"hidden"}); err != nil { + t.Fatal(err) + } + if !strings.Contains(sb.String(), "selected") { + t.Fatal("expected selected option rendering") + } + contents[0].JawsUpdate(elem) + nba.Set("1", false) + contents[0].JawsUpdate(elem) +} + +func TestCoverage_elementBranches(t *testing.T) { + NextJid = 0 + jw, err := New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + rq := jw.NewRequest(httptest.NewRequest("GET", "/", nil)) + + tu := &testUi{renderFn: func(*Element, io.Writer, []any) error { return nil }} + elem := rq.NewElement(tu) + + // renderDebug n/a branch when request lock is held. + rq.mu.Lock() + var sb strings.Builder + elem.renderDebug(&sb) + rq.mu.Unlock() + // renderDebug tag join branch when lock is available and multiple tags exist. + elem.Tag(Tag("a"), Tag("b")) + sb.Reset() + elem.renderDebug(&sb) + if !strings.Contains(sb.String(), ", ") { + t.Fatal("expected comma-separated tags in debug output") + } + // JawsRender debug path. + rq.Jaws.Debug = true + sb.Reset() + if err := elem.JawsRender(&sb, nil); err != nil { + t.Fatal(err) + } + rq.Jaws.Debug = false + + // deleted branch in Element.JawsRender/JawsUpdate + rq.deleteElement(elem) + if err := elem.JawsRender(&sb, nil); err != nil { + t.Fatal(err) + } + elem.JawsUpdate() +} + +func TestCoverage_applyGetterBranchesAndDebugNewElement(t *testing.T) { + NextJid = 0 + rq := newTestRequest(t) + defer rq.Close() + elem := rq.NewElement(&testUi{}) + + // nil getter + if tag, err := elem.ApplyGetter(nil); tag != nil || err != nil { + t.Fatalf("unexpected %v %v", tag, err) + } + + // getter with tag/click/event/init handler + ag := testApplyGetterAll{} + if tag, err := elem.ApplyGetter(ag); tag != Tag("tg") || err != nil { + t.Fatalf("unexpected %v %v", tag, err) + } + agErr := testApplyGetterAll{initErr: ErrNotComparable} + if _, err := elem.ApplyGetter(agErr); err != ErrNotComparable { + t.Fatalf("expected init err, got %v", err) + } + + // This branch only exists in deadlock debug builds. + if deadlock.Debug { + defer func() { + if recover() == nil { + t.Fatal("expected panic for non-comparable UI in debug mode") + } + }() + rq.NewElement(testUnhashableUI{m: map[string]int{"x": 1}}) + } +} + +type testUnhashableUI struct { + m map[string]int +} + +func (testUnhashableUI) JawsRender(*Element, io.Writer, []any) error { return nil } +func (testUnhashableUI) JawsUpdate(*Element) {} diff --git a/jaws/getter.go b/core/getter.go similarity index 91% rename from jaws/getter.go rename to core/getter.go index d61c4db3..c30db190 100644 --- a/jaws/getter.go +++ b/core/getter.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "errors" @@ -31,7 +31,7 @@ func makeStaticGetter[T comparable](v T) Getter[T] { return getterStatic[T]{v} } -func makeGetter[T comparable](v any) Getter[T] { +func MakeGetter[T comparable](v any) Getter[T] { switch v := v.(type) { case Getter[T]: return v diff --git a/jaws/getter_test.go b/core/getter_test.go similarity index 77% rename from jaws/getter_test.go rename to core/getter_test.go index 61adcadc..ddcb27f7 100644 --- a/jaws/getter_test.go +++ b/core/getter_test.go @@ -1,4 +1,4 @@ -package jaws +package core import "testing" @@ -8,12 +8,12 @@ func Test_makeGetter_panic(t *testing.T) { t.Fail() } }() - setter2 := makeGetter[string](123) + setter2 := MakeGetter[string](123) t.Error(setter2) } func Test_makeGetter(t *testing.T) { - setter := makeGetter[string]("foo") + setter := MakeGetter[string]("foo") if err := setter.(Setter[string]).JawsSet(nil, "bar"); err != ErrValueNotSettable { t.Error(err) } diff --git a/core/helpers_test.go b/core/helpers_test.go new file mode 100644 index 00000000..9893adfd --- /dev/null +++ b/core/helpers_test.go @@ -0,0 +1,24 @@ +package core + +import ( + "io" + "testing" + "time" +) + +func nextBroadcast(t *testing.T, jw *Jaws) Message { + t.Helper() + select { + case msg := <-jw.bcastCh: + return msg + case <-time.After(time.Second): + t.Fatal("timeout waiting for broadcast") + return Message{} + } +} + +type errReader struct{} + +func (errReader) Read([]byte) (int, error) { + return 0, io.EOF +} diff --git a/jaws/htmlgetter.go b/core/htmlgetter.go similarity index 93% rename from jaws/htmlgetter.go rename to core/htmlgetter.go index 5ea71324..d8657fd6 100644 --- a/jaws/htmlgetter.go +++ b/core/htmlgetter.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "html/template" diff --git a/jaws/htmlgetterfunc.go b/core/htmlgetterfunc.go similarity index 97% rename from jaws/htmlgetterfunc.go rename to core/htmlgetterfunc.go index 629e355e..4e4d10e3 100644 --- a/jaws/htmlgetterfunc.go +++ b/core/htmlgetterfunc.go @@ -1,4 +1,4 @@ -package jaws +package core import "html/template" diff --git a/jaws/htmlgetterfunc_test.go b/core/htmlgetterfunc_test.go similarity index 96% rename from jaws/htmlgetterfunc_test.go rename to core/htmlgetterfunc_test.go index 9bcb58b3..25751bec 100644 --- a/jaws/htmlgetterfunc_test.go +++ b/core/htmlgetterfunc_test.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "html/template" diff --git a/core/ids_test.go b/core/ids_test.go new file mode 100644 index 00000000..fe7e7339 --- /dev/null +++ b/core/ids_test.go @@ -0,0 +1,99 @@ +package core + +import ( + "bufio" + "bytes" + "html/template" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestCoverage_IDAndLookupHelpers(t *testing.T) { + NextJid = 0 + if a, b := NextID(), NextID(); b <= a { + t.Fatalf("expected increasing ids, got %d then %d", a, b) + } + if got := string(AppendID([]byte("x"))); !strings.HasPrefix(got, "x") || len(got) <= 1 { + t.Fatalf("unexpected append id result %q", got) + } + if got := MakeID(); !strings.HasPrefix(got, "jaws.") { + t.Fatalf("unexpected id %q", got) + } + + jw, err := New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + + tmpl := template.Must(template.New("it").Parse(`ok`)) + jw.AddTemplateLookuper(tmpl) + if got := jw.LookupTemplate("it"); got == nil { + t.Fatal("expected found template") + } + if got := jw.LookupTemplate("missing"); got != nil { + t.Fatal("expected missing template") + } + jw.RemoveTemplateLookuper(nil) + jw.RemoveTemplateLookuper(tmpl) + + hr := httptest.NewRequest(http.MethodGet, "/", nil) + rq := jw.NewRequest(hr) + if rq == nil { + t.Fatal("expected request") + } + if got := jw.RequestCount(); got != 1 { + t.Fatalf("expected one request, got %d", got) + } + jw.recycle(rq) + if got := jw.RequestCount(); got != 0 { + t.Fatalf("expected zero requests, got %d", got) + } +} + +func TestCoverage_CookieParseAndIP(t *testing.T) { + h := http.Header{} + h.Add("Cookie", `a=1; jaws=`+JawsKeyString(11)+`; x=2`) + h.Add("Cookie", `jaws="`+JawsKeyString(12)+`"`) + h.Add("Cookie", `jaws=not-a-key`) + + ids := getCookieSessionsIds(h, "jaws") + if len(ids) != 2 || ids[0] != 11 || ids[1] != 12 { + t.Fatalf("unexpected cookie ids %#v", ids) + } + + if got := parseIP("127.0.0.1:1234"); !got.IsValid() { + t.Fatalf("expected parsed host:port ip, got %v", got) + } + if got := parseIP("::1"); !got.IsValid() { + t.Fatalf("expected parsed direct ip, got %v", got) + } + if got := parseIP(""); got.IsValid() { + t.Fatalf("expected invalid ip for empty remote addr, got %v", got) + } +} + +func TestCoverage_NonZeroRandomAndPanic(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + + // First random value is zero, second is one. + zeroThenOne := append(make([]byte, 8), []byte{1, 0, 0, 0, 0, 0, 0, 0}...) + jw.kg = bufio.NewReader(bytes.NewReader(zeroThenOne)) + if got := jw.nonZeroRandomLocked(); got != 1 { + t.Fatalf("unexpected non-zero random value %d", got) + } + + defer func() { + if recover() == nil { + t.Fatal("expected panic on random source read error") + } + }() + jw.kg = bufio.NewReader(errReader{}) + _ = jw.nonZeroRandomLocked() +} diff --git a/jaws/inithandler.go b/core/inithandler.go similarity index 96% rename from jaws/inithandler.go rename to core/inithandler.go index 079c4ca3..df13b720 100644 --- a/jaws/inithandler.go +++ b/core/inithandler.go @@ -1,4 +1,4 @@ -package jaws +package core // InitHandler allows initializing UI getters and setters before their use. // diff --git a/jaws/jaws.css b/core/jaws.css similarity index 100% rename from jaws/jaws.css rename to core/jaws.css diff --git a/jaws/jaws.go b/core/jaws.go similarity index 99% rename from jaws/jaws.go rename to core/jaws.go index 7fd38f0a..6e833010 100644 --- a/jaws/jaws.go +++ b/core/jaws.go @@ -1,10 +1,10 @@ -// package jaws provides a mechanism to create dynamic +// package core provides a mechanism to create dynamic // webpages using Javascript and WebSockets. // // It integrates well with Go's html/template package, // but can be used without it. It can be used with any // router that supports the standard ServeHTTP interface. -package jaws +package core import ( "bufio" @@ -207,7 +207,7 @@ func (jw *Jaws) MustLog(err error) { // NextID returns a uint64 unique within lifetime of the program. func NextID() int64 { - return atomic.AddInt64((*int64)(&nextJid), 1) + return atomic.AddInt64((*int64)(&NextJid), 1) } // AppendID appends the result of NextID() in text form to the given slice. diff --git a/jaws/jaws.js b/core/jaws.js similarity index 100% rename from jaws/jaws.js rename to core/jaws.js diff --git a/jaws/jawsevent_test.go b/core/jawsevent_test.go similarity index 89% rename from jaws/jawsevent_test.go rename to core/jawsevent_test.go index 4af7a00d..49a53f3a 100644 --- a/jaws/jawsevent_test.go +++ b/core/jawsevent_test.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "fmt" @@ -57,7 +57,7 @@ var _ UI = (*testJawsEvent)(nil) func Test_JawsEvent_ClickUnhandled(t *testing.T) { th := newTestHelper(t) - nextJid = 0 + NextJid = 0 rq := newTestRequest(t) defer rq.Close() @@ -68,7 +68,7 @@ func Test_JawsEvent_ClickUnhandled(t *testing.T) { id := rq.Register(zomgItem, je, "attr1", []string{"attr2"}, template.HTMLAttr("attr3"), []template.HTMLAttr{"attr4"}) je.clickerr = ErrEventUnhandled - rq.InCh <- wsMsg{Data: "name", Jid: id, What: what.Click} + rq.InCh <- WsMsg{Data: "name", Jid: id, What: what.Click} select { case <-th.C: th.Timeout() @@ -81,7 +81,7 @@ func Test_JawsEvent_ClickUnhandled(t *testing.T) { func Test_JawsEvent_AllUnhandled(t *testing.T) { th := newTestHelper(t) - nextJid = 0 + NextJid = 0 rq := newTestRequest(t) defer rq.Close() @@ -93,7 +93,7 @@ func Test_JawsEvent_AllUnhandled(t *testing.T) { je.clickerr = ErrEventUnhandled je.eventerr = ErrEventUnhandled - rq.InCh <- wsMsg{Data: "name", Jid: id, What: what.Click} + rq.InCh <- WsMsg{Data: "name", Jid: id, What: what.Click} select { case <-th.C: th.Timeout() @@ -125,21 +125,21 @@ func (t *testJawsEventHandler) JawsEvent(e *Element, wht what.What, val string) func Test_JawsEvent_ExtraHandler(t *testing.T) { th := newTestHelper(t) - nextJid = 0 + NextJid = 0 rq := newTestRequest(t) defer rq.Close() msgCh := make(chan string, 1) defer close(msgCh) - je := NewUiDiv(&testJawsEventHandler{msgCh: msgCh}) + je := &testJawsEventHandler{msgCh: msgCh} var sb strings.Builder - elem := rq.NewElement(je) - th.NoErr(je.JawsRender(elem, &sb, nil)) + elem := rq.NewElement(testDivWidget{inner: "tjEH"}) + th.NoErr(elem.JawsRender(&sb, []any{je})) th.Equal(sb.String(), "
tjEH
") - rq.InCh <- wsMsg{Data: "name", Jid: 1, What: what.Click} + rq.InCh <- WsMsg{Data: "name", Jid: 1, What: what.Click} select { case <-th.C: th.Timeout() diff --git a/jaws/js.go b/core/js.go similarity index 99% rename from jaws/js.go rename to core/js.go index 55836adb..fc2d251c 100644 --- a/jaws/js.go +++ b/core/js.go @@ -1,4 +1,4 @@ -package jaws +package core import ( _ "embed" diff --git a/jaws/js_test.go b/core/js_test.go similarity index 99% rename from jaws/js_test.go rename to core/js_test.go index d71771a2..f7236d14 100644 --- a/jaws/js_test.go +++ b/core/js_test.go @@ -1,4 +1,4 @@ -package jaws +package core import ( _ "embed" diff --git a/jaws/logger.go b/core/logger.go similarity index 92% rename from jaws/logger.go rename to core/logger.go index 36f5e4f5..af09c2c8 100644 --- a/jaws/logger.go +++ b/core/logger.go @@ -1,4 +1,4 @@ -package jaws +package core // Logger matches the log/slog.Logger interface. type Logger interface { diff --git a/jaws/makehtmlgetter.go b/core/makehtmlgetter.go similarity index 82% rename from jaws/makehtmlgetter.go rename to core/makehtmlgetter.go index 0a1bc70e..f7d08d35 100644 --- a/jaws/makehtmlgetter.go +++ b/core/makehtmlgetter.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "fmt" @@ -40,9 +40,9 @@ func (g htmlGetterString) JawsGetTag(rq *Request) any { // // Depending on the type of v, we return: // -// - jaws.HTMLGetter: `JawsGetHTML(e *Element) template.HTML` to be used as-is. -// - jaws.Getter[string]: `JawsGet(elem *Element) string` that will be escaped using `html.EscapeString`. -// - jaws.AnyGetter: `JawsGetAny(elem *Element) any` that will be rendered using `fmt.Sprint()` and escaped using `html.EscapeString`. +// - HTMLGetter: `JawsGetHTML(e *Element) template.HTML` to be used as-is. +// - Getter[string]: `JawsGet(elem *Element) string` that will be escaped using `html.EscapeString`. +// - Formatter: `Format("%v") string` that will be escaped using `html.EscapeString`. // - fmt.Stringer: `String() string` that will be escaped using `html.EscapeString`. // - a static `template.HTML` or `string` to be used as-is with no HTML escaping. // - everything else is rendered using `fmt.Sprint()` and escaped using `html.EscapeString`. diff --git a/jaws/makehtmlgetter_test.go b/core/makehtmlgetter_test.go similarity index 95% rename from jaws/makehtmlgetter_test.go rename to core/makehtmlgetter_test.go index 71dcdc55..5a6b9b24 100644 --- a/jaws/makehtmlgetter_test.go +++ b/core/makehtmlgetter_test.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "html" @@ -8,6 +8,12 @@ import ( "testing" ) +type testStringer struct{} + +func (testStringer) String() string { + return "" +} + /*type testAnySetter struct { Value any } diff --git a/jaws/message.go b/core/message.go similarity index 97% rename from jaws/message.go rename to core/message.go index 265ba1c1..9b7ac7e4 100644 --- a/jaws/message.go +++ b/core/message.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "fmt" diff --git a/jaws/message_test.go b/core/message_test.go similarity index 96% rename from jaws/message_test.go rename to core/message_test.go index d4a52a96..fc0da049 100644 --- a/jaws/message_test.go +++ b/core/message_test.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "testing" diff --git a/jaws/namedbool.go b/core/namedbool.go similarity index 99% rename from jaws/namedbool.go rename to core/namedbool.go index 6bfffb0b..ffdbdc6d 100644 --- a/jaws/namedbool.go +++ b/core/namedbool.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "fmt" diff --git a/jaws/namedbool_test.go b/core/namedbool_test.go similarity index 90% rename from jaws/namedbool_test.go rename to core/namedbool_test.go index 51907684..05df225a 100644 --- a/jaws/namedbool_test.go +++ b/core/namedbool_test.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "html/template" @@ -13,7 +13,7 @@ func TestNamedBool(t *testing.T) { nb := nba.data[0] rq := newTestRequest(t) - e := rq.NewElement(NewUiCheckbox(nb)) + e := rq.NewElement(&testUi{}) defer rq.Close() is.Equal(nba, nb.Array()) diff --git a/jaws/namedboolarray.go b/core/namedboolarray.go similarity index 98% rename from jaws/namedboolarray.go rename to core/namedboolarray.go index 899dfef3..1817ccb1 100644 --- a/jaws/namedboolarray.go +++ b/core/namedboolarray.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "html/template" @@ -40,7 +40,7 @@ func (nba *NamedBoolArray) WriteLocked(fn func(nbl []*NamedBool) []*NamedBool) { func (nba *NamedBoolArray) JawsContains(e *Element) (contents []UI) { nba.mu.RLock() for _, nb := range nba.data { - contents = append(contents, UiOption{nb}) + contents = append(contents, namedBoolOption{nb}) } nba.mu.RUnlock() return diff --git a/jaws/namedboolarray_test.go b/core/namedboolarray_test.go similarity index 97% rename from jaws/namedboolarray_test.go rename to core/namedboolarray_test.go index 6db85b95..5e81a83e 100644 --- a/jaws/namedboolarray_test.go +++ b/core/namedboolarray_test.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "html/template" @@ -79,7 +79,7 @@ func Test_NamedBoolArray(t *testing.T) { is.Equal(nba.IsChecked("2"), true) rq := newTestRequest(t) - e := rq.NewElement(NewUiSelect(nba)) + e := rq.NewElement(&testUi{}) defer rq.Close() is.Equal(nba.JawsGet(e), "2") diff --git a/jaws/uioption.go b/core/namedbooloption.go similarity index 56% rename from jaws/uioption.go rename to core/namedbooloption.go index aa4748f1..d85e59ed 100644 --- a/jaws/uioption.go +++ b/core/namedbooloption.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "html" @@ -6,9 +6,13 @@ import ( "io" ) -type UiOption struct{ *NamedBool } +// namedBoolOption is an internal UI wrapper used by NamedBoolArray.JawsContains. +// It intentionally stays unexported; public option widgets live in package ui. +type namedBoolOption struct { + *NamedBool +} -func (ui UiOption) JawsRender(e *Element, w io.Writer, params []any) error { +func (ui namedBoolOption) JawsRender(e *Element, w io.Writer, params []any) error { e.Tag(ui.NamedBool) attrs := e.ApplyParams(params) valattr := template.HTMLAttr(`value="` + html.EscapeString(ui.Name()) + `"`) // #nosec G203 @@ -19,7 +23,7 @@ func (ui UiOption) JawsRender(e *Element, w io.Writer, params []any) error { return WriteHTMLInner(w, e.Jid(), "option", "", ui.JawsGetHTML(e), attrs...) } -func (ui UiOption) JawsUpdate(e *Element) { +func (ui namedBoolOption) JawsUpdate(e *Element) { if ui.Checked() { e.SetAttr("selected", "") } else { diff --git a/jaws/parseparams.go b/core/parseparams.go similarity index 98% rename from jaws/parseparams.go rename to core/parseparams.go index 6cd55d0f..7627c7c6 100644 --- a/jaws/parseparams.go +++ b/core/parseparams.go @@ -1,4 +1,4 @@ -package jaws +package core import "html/template" diff --git a/jaws/renderer.go b/core/renderer.go similarity index 95% rename from jaws/renderer.go rename to core/renderer.go index 22a242b6..9d157920 100644 --- a/jaws/renderer.go +++ b/core/renderer.go @@ -1,4 +1,4 @@ -package jaws +package core import "io" diff --git a/jaws/request.go b/core/request.go similarity index 93% rename from jaws/request.go rename to core/request.go index fc94b5af..09adb172 100644 --- a/jaws/request.go +++ b/core/request.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "context" @@ -34,7 +34,7 @@ type Request struct { Jaws *Jaws // (read-only) the JaWS instance the Request belongs to JawsKey uint64 // (read-only) a random number used in the WebSocket URI to identify this Request remoteIP netip.Addr // (read-only) remote IP, or nil - rendering atomic.Bool // set to true by RequestWriter.Write() + Rendering atomic.Bool // set to true by RequestWriter.Write() running atomic.Bool // if ServeHTTP() is running claimed atomic.Bool // if UseRequest() has been called for it mu deadlock.RWMutex // protects following @@ -49,7 +49,7 @@ type Request struct { elems []*Element // our Elements tagMap map[any][]*Element // maps tags to Elements muQueue deadlock.Mutex // protects wsQueue - wsQueue []wsMsg // queued messages to send + wsQueue []WsMsg // queued messages to send } type eventFnCall struct { @@ -257,7 +257,7 @@ func (rq *Request) SetContext(fn func(oldctx context.Context) (newctx context.Co func (rq *Request) maintenance(now time.Time, requestTimeout time.Duration) bool { if !rq.running.Load() { - if rq.rendering.Swap(false) { + if rq.Rendering.Swap(false) { rq.mu.Lock() rq.lastWrite = now rq.mu.Unlock() @@ -369,11 +369,12 @@ func (rq *Request) wantMessage(msg *Message) (yes bool) { return } -var nextJid Jid +// NextJid is the next Jid that should be used. Used when testing. Do not modify it outside of tests. +var NextJid Jid func (rq *Request) newElementLocked(ui UI) (elem *Element) { elem = &Element{ - jid: Jid(atomic.AddInt64((*int64)(&nextJid), 1)), + jid: Jid(atomic.AddInt64((*int64)(&NextJid), 1)), ui: ui, Request: rq, } @@ -435,7 +436,7 @@ func (rq *Request) appendDirtyTags(tags []any) { } // Tag adds the given tags to the given Element. -func (rq *Request) tagExpanded(elem *Element, expandedtags []any) { +func (rq *Request) TagExpanded(elem *Element, expandedtags []any) { rq.mu.Lock() defer rq.mu.Unlock() for _, tag := range expandedtags { @@ -448,7 +449,7 @@ func (rq *Request) tagExpanded(elem *Element, expandedtags []any) { // Tag adds the given tags to the given Element. func (rq *Request) Tag(elem *Element, tags ...any) { if elem != nil && len(tags) > 0 && elem.Request == rq { - rq.tagExpanded(elem, MustTagExpand(elem.Request, tags)) + rq.TagExpanded(elem, MustTagExpand(elem.Request, tags)) } } @@ -472,7 +473,7 @@ func (rq *Request) GetElements(tagitem any) (elems []*Element) { } // process is the main message processing loop. Will unsubscribe broadcastMsgCh and close outboundMsgCh on exit. -func (rq *Request) process(broadcastMsgCh chan Message, incomingMsgCh <-chan wsMsg, outboundMsgCh chan<- wsMsg) { +func (rq *Request) process(broadcastMsgCh chan Message, incomingMsgCh <-chan WsMsg, outboundMsgCh chan<- WsMsg) { jawsDoneCh := rq.Jaws.Done() httpDoneCh := rq.httpDoneCh eventDoneCh := make(chan struct{}) @@ -504,7 +505,7 @@ func (rq *Request) process(broadcastMsgCh chan Message, incomingMsgCh <-chan wsM for { var tagmsg Message - var wsmsg wsMsg + var wsmsg WsMsg var ok bool rq.sendQueue(outboundMsgCh) @@ -551,7 +552,7 @@ func (rq *Request) process(broadcastMsgCh chan Message, incomingMsgCh <-chan wsM case *Request: case string: // target is a regular HTML ID - rq.queue(wsMsg{ + rq.queue(WsMsg{ Data: v + "\t" + strconv.Quote(tagmsg.Data), What: tagmsg.What, Jid: -1, @@ -562,7 +563,7 @@ func (rq *Request) process(broadcastMsgCh chan Message, incomingMsgCh <-chan wsM switch tagmsg.What { case what.Reload, what.Redirect, what.Order, what.Alert: - rq.queue(wsMsg{ + rq.queue(WsMsg{ Jid: 0, Data: tagmsg.Data, What: tagmsg.What, @@ -571,7 +572,7 @@ func (rq *Request) process(broadcastMsgCh chan Message, incomingMsgCh <-chan wsM for _, elem := range todo { switch tagmsg.What { case what.Delete: - rq.queue(wsMsg{ + rq.queue(WsMsg{ Jid: elem.Jid(), What: what.Delete, }) @@ -588,7 +589,7 @@ func (rq *Request) process(broadcastMsgCh chan Message, incomingMsgCh <-chan wsM // an error to be sent out as an alert message. // primary usecase is tests. if err := rq.Jaws.Log(rq.callAllEventHandlers(elem.Jid(), tagmsg.What, tagmsg.Data)); err != nil { - rq.queue(wsMsg{ + rq.queue(WsMsg{ Data: tagmsg.Data, Jid: elem.Jid(), What: what.Alert, @@ -597,7 +598,7 @@ func (rq *Request) process(broadcastMsgCh chan Message, incomingMsgCh <-chan wsM case what.Update: elem.JawsUpdate() default: - rq.queue(wsMsg{ + rq.queue(WsMsg{ Data: tagmsg.Data, Jid: elem.Jid(), What: tagmsg.What, @@ -618,7 +619,7 @@ func (rq *Request) handleRemove(data string) { } } -func (rq *Request) queue(msg wsMsg) { +func (rq *Request) queue(msg WsMsg) { rq.muQueue.Lock() rq.wsQueue = append(rq.wsQueue, msg) rq.muQueue.Unlock() @@ -650,7 +651,7 @@ func (rq *Request) callAllEventHandlers(id Jid, wht what.What, val string) (err rq.mu.RUnlock() for _, e := range elems { - if err = callEventHandlers(e.Ui(), e, wht, val); err != ErrEventUnhandled { + if err = CallEventHandlers(e.Ui(), e, wht, val); err != ErrEventUnhandled { return } } @@ -669,7 +670,7 @@ func (rq *Request) queueEvent(eventCallCh chan eventFnCall, call eventFnCall) { } } -func (rq *Request) getSendMsgs() (toSend []wsMsg) { +func (rq *Request) getSendMsgs() (toSend []WsMsg) { rq.mu.RLock() defer rq.mu.RUnlock() @@ -695,11 +696,11 @@ func (rq *Request) getSendMsgs() (toSend []wsMsg) { rq.wsQueue = rq.wsQueue[:0] } - slices.SortStableFunc(toSend, func(a, b wsMsg) int { return int(a.Jid - b.Jid) }) + slices.SortStableFunc(toSend, func(a, b WsMsg) int { return int(a.Jid - b.Jid) }) return } -func (rq *Request) sendQueue(outboundMsgCh chan<- wsMsg) { +func (rq *Request) sendQueue(outboundMsgCh chan<- WsMsg) { for _, msg := range rq.getSendMsgs() { select { case <-rq.Context().Done(): @@ -742,6 +743,15 @@ func (rq *Request) deleteElement(e *Element) { rq.deleteElementLocked(e) } +// DeleteElement removes elem from the Request element registry. +// +// This is primarily intended for UI implementations that manage dynamic child +// element sets and need to drop stale elements after issuing a corresponding +// DOM remove operation. +func (rq *Request) DeleteElement(elem *Element) { + rq.deleteElement(elem) +} + func (rq *Request) makeUpdateList() (todo []*Element) { rq.mu.Lock() seen := map[*Element]struct{}{} @@ -761,11 +771,11 @@ func (rq *Request) makeUpdateList() (todo []*Element) { } // eventCaller calls event functions -func (rq *Request) eventCaller(eventCallCh <-chan eventFnCall, outboundMsgCh chan<- wsMsg, eventDoneCh chan<- struct{}) { +func (rq *Request) eventCaller(eventCallCh <-chan eventFnCall, outboundMsgCh chan<- WsMsg, eventDoneCh chan<- struct{}) { defer close(eventDoneCh) for call := range eventCallCh { if err := rq.callAllEventHandlers(call.jid, call.wht, call.data); err != nil { - var m wsMsg + var m WsMsg m.FillAlert(err) select { case outboundMsgCh <- m: @@ -788,11 +798,6 @@ func (rq *Request) onConnect() (err error) { return } -// Writer returns a RequestWriter with this Request and the given Writer. -func (rq *Request) Writer(w io.Writer) RequestWriter { - return RequestWriter{rq: rq, Writer: w} -} - func (rq *Request) validateWebSocketOrigin(r *http.Request) (err error) { err = ErrWebsocketOriginMissing if origin := r.Header.Get("Origin"); origin != "" { diff --git a/core/request_lifecycle_test.go b/core/request_lifecycle_test.go new file mode 100644 index 00000000..5e1ab25d --- /dev/null +++ b/core/request_lifecycle_test.go @@ -0,0 +1,176 @@ +package core + +import ( + "context" + "errors" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/linkdata/jaws/what" +) + +func TestCoverage_PendingSubscribeMaintenanceAndParse(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + + hr := httptest.NewRequest("GET", "/", nil) + rq := jw.NewRequest(hr) + if got := jw.Pending(); got != 1 { + t.Fatalf("expected one pending request, got %d", got) + } + if claimed := jw.UseRequest(rq.JawsKey, hr); claimed != rq { + t.Fatal("expected request claim") + } + if got := jw.Pending(); got != 0 { + t.Fatalf("expected zero pending requests, got %d", got) + } + + msgCh := jw.subscribe(rq, 1) + if msgCh == nil { + t.Fatal("expected non-nil subscription channel") + } + if sub := <-jw.subCh; sub.msgCh != msgCh { + t.Fatal("unexpected subscription") + } + jw.unsubscribe(msgCh) + if got := <-jw.unsubCh; got != msgCh { + t.Fatal("unexpected unsubscribe channel") + } + + // Request timeout path. + rq.mu.Lock() + rq.lastWrite = time.Now().Add(-time.Hour) + rq.mu.Unlock() + jw.maintenance(time.Second) + if got := jw.RequestCount(); got != 0 { + t.Fatalf("expected request recycled, got %d", got) + } + + // Dead session cleanup path. + sess := jw.newSession(nil, hr) + sess.mu.Lock() + sess.deadline = time.Now().Add(-time.Second) + sess.mu.Unlock() + jw.maintenance(time.Second) + if got := jw.SessionCount(); got != 0 { + t.Fatalf("expected dead session cleanup, got %d", got) + } + + // done-channel branch in subscribe and unsubscribe. + jw.subCh <- subscription{} // fill channel so send case is not selectable + jw.unsubCh <- make(chan Message) + jw.Close() + if ch := jw.subscribe(nil, 1); ch != nil { + t.Fatalf("expected nil subscription after close, got %v", ch) + } + jw.unsubscribe(nil) +} + +func TestCoverage_RequestMaintenanceClaimAndErrors(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + + hr := httptest.NewRequest("GET", "/", nil) + rq := jw.NewRequest(hr) + if err := rq.claim(hr); err != nil { + t.Fatal(err) + } + if err := rq.claim(hr); !errors.Is(err, ErrRequestAlreadyClaimed) { + t.Fatalf("expected ErrRequestAlreadyClaimed, got %v", err) + } + + hrA := httptest.NewRequest("GET", "/", nil) + hrA.RemoteAddr = "1.2.3.4:1234" + rqA := jw.NewRequest(hrA) + hrB := httptest.NewRequest("GET", "/", nil) + hrB.RemoteAddr = "2.2.2.2:4321" + if err := rqA.claim(hrB); err == nil { + t.Fatal("expected ip mismatch error") + } + + now := time.Now() + rqM := jw.NewRequest(httptest.NewRequest("GET", "/", nil)) + rqM.lastWrite = now.Add(-time.Hour) + if !rqM.maintenance(now, time.Second) { + t.Fatal("expected maintenance timeout") + } + rqR := jw.NewRequest(httptest.NewRequest("GET", "/", nil)) + nowR := time.Now() + rqR.Rendering.Store(true) + if rqR.maintenance(nowR, time.Hour) { + t.Fatal("expected maintenance continue") + } + rqR.mu.RLock() + lastWrite := rqR.lastWrite + rqR.mu.RUnlock() + if lastWrite != nowR { + t.Fatalf("expected lastWrite updated to now, got %v want %v", lastWrite, nowR) + } + rqC := jw.NewRequest(httptest.NewRequest("GET", "/", nil)) + rqC.cancel(errors.New("cancelled")) + if !rqC.maintenance(time.Now(), time.Hour) { + t.Fatal("expected maintenance cancellation") + } + rqOK := jw.NewRequest(httptest.NewRequest("GET", "/", nil)) + rqOK.lastWrite = time.Now() + if rqOK.maintenance(time.Now(), time.Hour) { + t.Fatal("expected maintenance keepalive") + } + + errNoWS := newErrNoWebSocketRequest(rqOK) + if !errors.Is(errNoWS, ErrNoWebSocketRequest) { + t.Fatalf("expected no-websocket error type, got %v", errNoWS) + } + if got := errNoWS.Error(); !strings.Contains(got, "no WebSocket request received from") { + t.Fatalf("unexpected error text %q", got) + } + + maybePanic(nil) + defer func() { + if recover() == nil { + t.Fatal("expected panic from maybePanic") + } + }() + maybePanic(errors.New("boom")) +} + +func TestCoverage_RequestProcessHTTPDoneAndBroadcastDone(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + hr := httptest.NewRequest("GET", "/", nil).WithContext(ctx) + rq := jw.NewRequest(hr) + if err := rq.claim(hr); err != nil { + t.Fatal(err) + } + bcastCh := make(chan Message) + inCh := make(chan WsMsg) + outCh := make(chan WsMsg, 1) + done := make(chan struct{}) + go func() { + rq.process(bcastCh, inCh, outCh) + close(done) + }() + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("timeout waiting for process exit on httpDone") + } + + jw.Close() + jw.Broadcast(Message{What: what.Update}) +} diff --git a/jaws/request_test.go b/core/request_test.go similarity index 96% rename from jaws/request_test.go rename to core/request_test.go index 3d1c9169..c293243b 100644 --- a/jaws/request_test.go +++ b/core/request_test.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "bytes" @@ -19,10 +19,10 @@ import ( const testTimeout = time.Second * 3 -func fillWsCh(ch chan wsMsg) { +func fillWsCh(ch chan WsMsg) { for { select { - case ch <- wsMsg{}: + case ch <- WsMsg{}: default: return } @@ -60,7 +60,7 @@ func TestRequest_HeadHTML(t *testing.T) { func TestRequestWriter_TailHTML(t *testing.T) { th := newTestHelper(t) - nextJid = 0 + NextJid = 0 jw, _ := New() defer jw.Close() rq := jw.NewRequest(nil) @@ -107,7 +107,7 @@ func TestRequest_SendArrivesOk(t *testing.T) { elem := rq.GetElementByJid(jid) is.True(elem != nil) if elem != nil { - is.Equal(msg, wsMsg{Jid: elem.jid, Data: "bar", What: what.Inner}) + is.Equal(msg, WsMsg{Jid: elem.jid, Data: "bar", What: what.Inner}) } } } @@ -205,7 +205,7 @@ func TestRequest_Trigger(t *testing.T) { case <-th.C: th.Timeout() case msg := <-rq.OutCh: - th.Equal(msg.Format(), (&wsMsg{ + th.Equal(msg.Format(), (&WsMsg{ Data: "danger\nomg", Jid: jid.Jid(0), What: what.Alert, @@ -312,7 +312,7 @@ func TestRequest_EventFnQueueOverflowPanicsWithNoLogger(t *testing.T) { return case <-th.C: th.Timeout() - case rq.InCh <- wsMsg{Jid: jid, What: what.Input}: + case rq.InCh <- WsMsg{Jid: jid, What: what.Input}: } } } @@ -380,7 +380,7 @@ func TestRequest_IgnoresIncomingMsgsDuringShutdown(t *testing.T) { rq.Jaws.Broadcast(Message{Dest: rq}) } select { - case rq.InCh <- wsMsg{}: + case rq.InCh <- WsMsg{}: case <-rq.DoneCh: th.Fatal() case <-th.C: @@ -474,7 +474,7 @@ func TestRequest_DeleteByTag(t *testing.T) { th := newTestHelper(t) tj := newTestJaws() defer tj.Close() - nextJid = 0 + NextJid = 0 rq1 := tj.newRequest(nil) ui1 := &testUi{} e11 := rq1.NewElement(ui1) @@ -604,8 +604,8 @@ func TestRequest_Dirty(t *testing.T) { tss1 := &testUi{s: "foo1"} tss2 := &testUi{s: "foo2"} - rq.UI(NewUiText(tss1)) - rq.UI(NewUiText(tss2)) + rq.UI(newTestTextInputWidget(tss1)) + rq.UI(newTestTextInputWidget(tss2)) th.Equal(tss1.getCalled, int32(1)) th.Equal(tss2.getCalled, int32(1)) th.True(strings.Contains(string(rq.BodyString()), "foo1")) @@ -625,7 +625,7 @@ func TestRequest_Dirty(t *testing.T) { func TestRequest_UpdatePanicLogs(t *testing.T) { th := newTestHelper(t) - nextJid = 0 + NextJid = 0 rq := newTestRequest(t) defer rq.Close() var log bytes.Buffer @@ -649,17 +649,17 @@ func TestRequest_UpdatePanicLogs(t *testing.T) { func TestRequest_IncomingRemove(t *testing.T) { th := newTestHelper(t) - nextJid = 0 + NextJid = 0 rq := newTestRequest(t) defer rq.Close() tss := newTestSetter("") - rq.UI(NewUiText(tss)) + rq.UI(newTestTextInputWidget(tss)) select { case <-th.C: th.Timeout() - case rq.InCh <- wsMsg{What: what.Remove, Data: "Jid.1"}: + case rq.InCh <- WsMsg{What: what.Remove, Data: "Jid.1"}: } elem := rq.GetElementByJid(1) @@ -676,7 +676,7 @@ func TestRequest_IncomingRemove(t *testing.T) { func TestRequest_IncomingClick(t *testing.T) { th := newTestHelper(t) - nextJid = 0 + NextJid = 0 rq := newTestRequest(t) defer rq.Close() @@ -690,13 +690,13 @@ func TestRequest_IncomingClick(t *testing.T) { testSetter: newTestSetter(""), } - rq.Div("1", tjc1) - rq.Div("2", tjc2) + rq.UI(testDivWidget{inner: "1"}, tjc1) + rq.UI(testDivWidget{inner: "2"}, tjc2) select { case <-th.C: th.Timeout() - case rq.InCh <- wsMsg{What: what.Click, Data: "name\tJid.1\tJid.2"}: + case rq.InCh <- WsMsg{What: what.Click, Data: "name\tJid.1\tJid.2"}: } select { diff --git a/jaws/rwlocker.go b/core/rwlocker.go similarity index 93% rename from jaws/rwlocker.go rename to core/rwlocker.go index be68809e..470a70ab 100644 --- a/jaws/rwlocker.go +++ b/core/rwlocker.go @@ -1,4 +1,4 @@ -package jaws +package core import "sync" diff --git a/jaws/selecthandler.go b/core/selecthandler.go similarity index 82% rename from jaws/selecthandler.go rename to core/selecthandler.go index 6e72def7..174d84b0 100644 --- a/jaws/selecthandler.go +++ b/core/selecthandler.go @@ -1,4 +1,4 @@ -package jaws +package core type SelectHandler interface { Container diff --git a/core/serve_ws_test.go b/core/serve_ws_test.go new file mode 100644 index 00000000..89657624 --- /dev/null +++ b/core/serve_ws_test.go @@ -0,0 +1,98 @@ +package core + +import ( + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/linkdata/jaws/jid" + "github.com/linkdata/jaws/what" +) + +func TestCoverage_WsMsgAppendNegativeJidAndServeWithTimeoutBounds(t *testing.T) { + msg := WsMsg{Jid: jid.Jid(-1), What: what.Update, Data: "raw\tdata"} + if got := string(msg.Append(nil)); got != "Update\traw\tdata\n" { + t.Fatalf("unexpected ws append result %q", got) + } + msg = WsMsg{Jid: 1, What: what.Call, Data: `fn={"a":1}`} + if got := string(msg.Append(nil)); !strings.Contains(got, `fn={"a":1}`) || strings.Contains(got, `"fn={"`) { + t.Fatalf("unexpected ws append quoted call payload %q", got) + } + + // Min interval clamp path. + jwMin, err := New() + if err != nil { + t.Fatal(err) + } + doneMin := make(chan struct{}) + go func() { + jwMin.ServeWithTimeout(time.Nanosecond) + close(doneMin) + }() + jwMin.Close() + select { + case <-doneMin: + case <-time.After(time.Second): + t.Fatal("timeout waiting for ServeWithTimeout(min)") + } + + // Max interval clamp path. + jwMax, err := New() + if err != nil { + t.Fatal(err) + } + doneMax := make(chan struct{}) + go func() { + jwMax.ServeWithTimeout(10 * time.Second) + close(doneMax) + }() + jwMax.Close() + select { + case <-doneMax: + case <-time.After(time.Second): + t.Fatal("timeout waiting for ServeWithTimeout(max)") + } +} + +func TestCoverage_ServeWithTimeoutFullSubscriberChannel(t *testing.T) { + jw, err := New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + rq := jw.NewRequest(httptest.NewRequest("GET", "/", nil)) + msgCh := make(chan Message) // unbuffered: always full when nobody receives + done := make(chan struct{}) + go func() { + jw.ServeWithTimeout(50 * time.Millisecond) + close(done) + }() + jw.subCh <- subscription{msgCh: msgCh, rq: rq} + // Ensure ServeWithTimeout has consumed the subscription before broadcast. + for i := 0; i <= cap(jw.subCh); i++ { + jw.subCh <- subscription{} + } + jw.bcastCh <- Message{What: what.Alert, Data: "x"} + + waitUntil := time.Now().Add(time.Second) + closed := false + for !closed && time.Now().Before(waitUntil) { + select { + case _, ok := <-msgCh: + closed = !ok + default: + time.Sleep(time.Millisecond) + } + } + if !closed { + t.Fatal("expected subscriber channel to be closed when full") + } + + jw.Close() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("timeout waiting for ServeWithTimeout exit") + } +} diff --git a/jaws/servehttp.go b/core/servehttp.go similarity index 98% rename from jaws/servehttp.go rename to core/servehttp.go index ec221c78..12808eb7 100644 --- a/jaws/servehttp.go +++ b/core/servehttp.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "net/http" diff --git a/jaws/servehttp_test.go b/core/servehttp_test.go similarity index 99% rename from jaws/servehttp_test.go rename to core/servehttp_test.go index e7aac1b6..b589d3b2 100644 --- a/jaws/servehttp_test.go +++ b/core/servehttp_test.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "compress/gzip" diff --git a/jaws/session.go b/core/session.go similarity index 99% rename from jaws/session.go rename to core/session.go index 99d30ede..9e025563 100644 --- a/jaws/session.go +++ b/core/session.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "net/http" diff --git a/jaws/session_test.go b/core/session_test.go similarity index 97% rename from jaws/session_test.go rename to core/session_test.go index 18191029..24b42b6e 100644 --- a/jaws/session_test.go +++ b/core/session_test.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "context" @@ -282,7 +282,7 @@ func TestSession_Delete(t *testing.T) { } byebyeItem := &testUi{} - RequestWriter{ts.rq, httptest.NewRecorder()}.Register(byebyeItem, func(e *Element, evt what.What, val string) error { + testRequestWriter{rq: ts.rq, Writer: httptest.NewRecorder()}.Register(byebyeItem, func(e *Element, evt what.What, val string) error { sess2 := ts.jw.GetSession(e.Request.Initial()) if x := sess2; x != ts.sess { t.Error(x) @@ -319,7 +319,7 @@ func TestSession_Delete(t *testing.T) { } defer conn.Close(websocket.StatusNormalClosure, "") - msg := wsMsg{Jid: jidForTag(ts.rq, byebyeItem), What: what.Input} + msg := WsMsg{Jid: jidForTag(ts.rq, byebyeItem), What: what.Input} ctx, cancel := context.WithCancel(ts.ctx) defer cancel() diff --git a/jaws/sessioner.go b/core/sessioner.go similarity index 96% rename from jaws/sessioner.go rename to core/sessioner.go index 09b9d065..5eb4bf7c 100644 --- a/jaws/sessioner.go +++ b/core/sessioner.go @@ -1,4 +1,4 @@ -package jaws +package core import "net/http" diff --git a/jaws/sessioner_test.go b/core/sessioner_test.go similarity index 95% rename from jaws/sessioner_test.go rename to core/sessioner_test.go index 67217181..bbf872c0 100644 --- a/jaws/sessioner_test.go +++ b/core/sessioner_test.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "bytes" @@ -7,7 +7,7 @@ import ( ) func TestJaws_Session(t *testing.T) { - nextJid = 0 + NextJid = 0 rq := newTestRequest(t) defer rq.Close() diff --git a/jaws/setter.go b/core/setter.go similarity index 93% rename from jaws/setter.go rename to core/setter.go index 947f3e75..04bfe665 100644 --- a/jaws/setter.go +++ b/core/setter.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "fmt" @@ -38,7 +38,7 @@ func (s setterStatic[T]) JawsGetTag(*Request) any { return nil } -func makeSetter[T comparable](v any) Setter[T] { +func MakeSetter[T comparable](v any) Setter[T] { switch v := v.(type) { case Setter[T]: return v diff --git a/jaws/setter_test.go b/core/setter_test.go similarity index 87% rename from jaws/setter_test.go rename to core/setter_test.go index 181a09a9..6002cbd2 100644 --- a/jaws/setter_test.go +++ b/core/setter_test.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "testing" @@ -14,7 +14,7 @@ func (testGetterString) JawsGet(*Element) string { func Test_makeSetter(t *testing.T) { tsg := testGetterString{} - setter1 := makeSetter[string](tsg) + setter1 := MakeSetter[string](tsg) if err := setter1.JawsSet(nil, "foo"); err != ErrValueNotSettable { t.Error(err) } @@ -25,7 +25,7 @@ func Test_makeSetter(t *testing.T) { t.Error(tag) } - setter2 := makeSetter[string]("quux") + setter2 := MakeSetter[string]("quux") if err := setter2.JawsSet(nil, "foo"); err != ErrValueNotSettable { t.Error(err) } @@ -43,6 +43,6 @@ func Test_makeSetter_panic(t *testing.T) { t.Fail() } }() - setter2 := makeSetter[string](123) + setter2 := MakeSetter[string](123) t.Error(setter2) } diff --git a/jaws/setterfloat64.go b/core/setterfloat64.go similarity index 97% rename from jaws/setterfloat64.go rename to core/setterfloat64.go index 498e6b65..33330b96 100644 --- a/jaws/setterfloat64.go +++ b/core/setterfloat64.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "fmt" @@ -81,7 +81,7 @@ func makeSetterFloat64for[T numeric](s *Setter[float64], v any) bool { return false } -func makeSetterFloat64(v any) (s Setter[float64]) { +func MakeSetterFloat64(v any) (s Setter[float64]) { switch v := v.(type) { case Setter[float64]: return v diff --git a/jaws/setterfloat64_test.go b/core/setterfloat64_test.go similarity index 91% rename from jaws/setterfloat64_test.go rename to core/setterfloat64_test.go index eabf00f5..0d20c776 100644 --- a/jaws/setterfloat64_test.go +++ b/core/setterfloat64_test.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "reflect" @@ -53,7 +53,7 @@ func Test_makeSetterFloat64types(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if gotS := makeSetterFloat64(tt.v); !reflect.DeepEqual(gotS, tt.wantS) { + if gotS := MakeSetterFloat64(tt.v); !reflect.DeepEqual(gotS, tt.wantS) { t.Errorf("makeSetterFloat64() = %#v, want %#v", gotS, tt.wantS) } }) @@ -62,7 +62,7 @@ func Test_makeSetterFloat64types(t *testing.T) { func Test_makeSetterFloat64_int(t *testing.T) { tsint := newTestSetter(int(0)) - gotS := makeSetterFloat64(tsint) + gotS := MakeSetterFloat64(tsint) err := gotS.JawsSet(nil, 1) if err != nil { t.Fatal(err) @@ -78,7 +78,7 @@ func Test_makeSetterFloat64_int(t *testing.T) { func Test_makeSetterFloat64ReadOnly_int(t *testing.T) { tgint := testGetter[int]{1} - gotS := makeSetterFloat64(tgint) + gotS := MakeSetterFloat64(tgint) err := gotS.JawsSet(nil, 2) if err == nil { t.Fatal("expected error") @@ -94,7 +94,7 @@ func Test_makeSetterFloat64ReadOnly_int(t *testing.T) { func Test_makeSetterFloat64Static_int(t *testing.T) { v := 1 - gotS := makeSetterFloat64(v) + gotS := MakeSetterFloat64(v) err := gotS.JawsSet(nil, 2) if err == nil { t.Fatal("expected error") @@ -115,6 +115,6 @@ func Test_makeSetterFloat64_panic(t *testing.T) { } }() - _ = makeSetterFloat64("x") + _ = MakeSetterFloat64("x") t.Fatal("expected panic") } diff --git a/jaws/setup.go b/core/setup.go similarity index 99% rename from jaws/setup.go rename to core/setup.go index 21ec4d0e..86ad7259 100644 --- a/jaws/setup.go +++ b/core/setup.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "errors" diff --git a/jaws/setup_test.go b/core/setup_test.go similarity index 99% rename from jaws/setup_test.go rename to core/setup_test.go index 844bb474..6bb7108c 100644 --- a/jaws/setup_test.go +++ b/core/setup_test.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "fmt" diff --git a/jaws/stringgetterfunc.go b/core/stringgetterfunc.go similarity index 97% rename from jaws/stringgetterfunc.go rename to core/stringgetterfunc.go index 86ef5039..11ef123d 100644 --- a/jaws/stringgetterfunc.go +++ b/core/stringgetterfunc.go @@ -1,4 +1,4 @@ -package jaws +package core type stringGetterFunc struct { fn func(*Element) string diff --git a/jaws/stringgetterfunc_test.go b/core/stringgetterfunc_test.go similarity index 96% rename from jaws/stringgetterfunc_test.go rename to core/stringgetterfunc_test.go index 3df2e039..56bad309 100644 --- a/jaws/stringgetterfunc_test.go +++ b/core/stringgetterfunc_test.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "reflect" diff --git a/jaws/subscription.go b/core/subscription.go similarity index 83% rename from jaws/subscription.go rename to core/subscription.go index 6cfcef67..6811305b 100644 --- a/jaws/subscription.go +++ b/core/subscription.go @@ -1,4 +1,4 @@ -package jaws +package core type subscription struct { msgCh chan Message diff --git a/jaws/tag.go b/core/tag.go similarity index 99% rename from jaws/tag.go rename to core/tag.go index bd946fbb..b9a91eb1 100644 --- a/jaws/tag.go +++ b/core/tag.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "fmt" diff --git a/jaws/tag_test.go b/core/tag_test.go similarity index 99% rename from jaws/tag_test.go rename to core/tag_test.go index 4ae40e15..192f765d 100644 --- a/jaws/tag_test.go +++ b/core/tag_test.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "errors" diff --git a/jaws/taggetter.go b/core/taggetter.go similarity index 87% rename from jaws/taggetter.go rename to core/taggetter.go index 68a75630..accc0b07 100644 --- a/jaws/taggetter.go +++ b/core/taggetter.go @@ -1,4 +1,4 @@ -package jaws +package core type TagGetter interface { JawsGetTag(rq *Request) any // Note that the Request may be nil diff --git a/jaws/templatelookuper.go b/core/templatelookuper.go similarity index 92% rename from jaws/templatelookuper.go rename to core/templatelookuper.go index fd4ae144..e48f2b54 100644 --- a/jaws/templatelookuper.go +++ b/core/templatelookuper.go @@ -1,4 +1,4 @@ -package jaws +package core import "html/template" diff --git a/jaws/test.sh b/core/test.sh similarity index 100% rename from jaws/test.sh rename to core/test.sh diff --git a/jaws/testhelper_test.go b/core/testhelper_test.go similarity index 99% rename from jaws/testhelper_test.go rename to core/testhelper_test.go index 3b1746b0..21e695d3 100644 --- a/jaws/testhelper_test.go +++ b/core/testhelper_test.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "bytes" diff --git a/jaws/testjaws_test.go b/core/testjaws_test.go similarity index 83% rename from jaws/testjaws_test.go rename to core/testjaws_test.go index 46f4d634..e546d3ee 100644 --- a/jaws/testjaws_test.go +++ b/core/testjaws_test.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "bytes" @@ -20,12 +20,10 @@ func newTestJaws() (tj *testJaws) { if err != nil { panic(err) } - tj = &testJaws{ - Jaws: jw, - } + tj = &testJaws{Jaws: jw} tj.Jaws.Logger = slog.New(slog.NewTextHandler(&tj.log, nil)) tj.Jaws.MakeAuth = func(r *Request) Auth { - return defaultAuth{} + return DefaultAuth{} } tj.testtmpl = template.Must(template.New("testtemplate").Parse(`{{with $.Dot}}
{{.}}
{{end}}`)) tj.AddTemplateLookuper(tj.testtmpl) @@ -39,11 +37,11 @@ func (tj *testJaws) newRequest(hr *http.Request) (tr *TestRequest) { return NewTestRequest(tj.Jaws, hr) } -func newTestRequest(t *testing.T) (tr *TestRequest) { +func newTestRequest(t *testing.T) (tr *testRequest) { tj := newTestJaws() if t != nil { t.Helper() t.Cleanup(tj.Close) } - return NewTestRequest(tj.Jaws, nil) + return newWrappedTestRequest(tj.Jaws, nil) } diff --git a/core/testrequest_test.go b/core/testrequest_test.go new file mode 100644 index 00000000..6c5b30a3 --- /dev/null +++ b/core/testrequest_test.go @@ -0,0 +1,34 @@ +package core + +import "net/http" + +type testRequest struct { + *TestRequest + rw testRequestWriter +} + +func newWrappedTestRequest(jw *Jaws, hr *http.Request) *testRequest { + tr := NewTestRequest(jw, hr) + if tr == nil { + return nil + } + return &testRequest{ + TestRequest: tr, + rw: testRequestWriter{ + rq: tr.Request, + Writer: tr.ResponseRecorder, + }, + } +} + +func (tr *testRequest) UI(ui UI, params ...any) error { return tr.rw.UI(ui, params...) } +func (tr *testRequest) Initial() *http.Request { return tr.rw.Initial() } +func (tr *testRequest) HeadHTML() error { return tr.rw.HeadHTML() } +func (tr *testRequest) TailHTML() error { return tr.rw.TailHTML() } +func (tr *testRequest) Session() *Session { return tr.rw.Session() } +func (tr *testRequest) Get(key string) (val any) { return tr.rw.Get(key) } +func (tr *testRequest) Set(key string, val any) { tr.rw.Set(key, val) } +func (tr *testRequest) Register(u Updater, p ...any) Jid { return tr.rw.Register(u, p...) } +func (tr *testRequest) Template(name string, dot any, params ...any) error { + return tr.rw.Template(name, dot, params...) +} diff --git a/core/testrequestwriter_test.go b/core/testrequestwriter_test.go new file mode 100644 index 00000000..324c4ae4 --- /dev/null +++ b/core/testrequestwriter_test.go @@ -0,0 +1,202 @@ +package core + +import ( + "bytes" + "fmt" + "html/template" + "io" + "net/http" + "strings" + "text/template/parse" + + "github.com/linkdata/deadlock" + "github.com/linkdata/jaws/what" +) + +type testRequestWriter struct { + rq *Request + io.Writer +} + +type testRegisterUI struct{ Updater } + +func (testRegisterUI) JawsRender(*Element, io.Writer, []any) error { return nil } +func (ui testRegisterUI) JawsUpdate(e *Element) { ui.Updater.JawsUpdate(e) } + +func (rw testRequestWriter) UI(ui UI, params ...any) error { + return rw.rq.NewElement(ui).JawsRender(rw, params) +} + +func (rw testRequestWriter) Write(p []byte) (n int, err error) { + rw.rq.Rendering.Store(true) + return rw.Writer.Write(p) +} + +func (rw testRequestWriter) Request() *Request { + return rw.rq +} + +func (rw testRequestWriter) Initial() *http.Request { + return rw.rq.Initial() +} + +func (rw testRequestWriter) HeadHTML() error { + return rw.rq.HeadHTML(rw) +} + +func (rw testRequestWriter) TailHTML() error { + return rw.rq.TailHTML(rw) +} + +func (rw testRequestWriter) Session() *Session { + return rw.rq.Session() +} + +func (rw testRequestWriter) Get(key string) (val any) { + return rw.rq.Get(key) +} + +func (rw testRequestWriter) Set(key string, val any) { + rw.rq.Set(key, val) +} + +func (rw testRequestWriter) Register(updater Updater, params ...any) Jid { + elem := rw.rq.NewElement(testRegisterUI{Updater: updater}) + elem.Tag(updater) + elem.ApplyParams(params) + updater.JawsUpdate(elem) + return elem.Jid() +} + +func (rq *Request) Writer(w io.Writer) testRequestWriter { + return testRequestWriter{rq: rq, Writer: w} +} + +type testHandler struct { + *Jaws + Template testTemplateUI +} + +func (h testHandler) ServeHTTP(wr http.ResponseWriter, r *http.Request) { + _ = h.Log(h.NewRequest(r).NewElement(h.Template).JawsRender(wr, nil)) +} + +func (jw *Jaws) Handler(name string, dot any) http.Handler { + return testHandler{Jaws: jw, Template: testTemplateUI{Name: name, Dot: dot}} +} + +type testWith struct { + *Element + testRequestWriter + Dot any + Attrs template.HTMLAttr + Auth Auth +} + +type testTemplateUI struct { + Name string + Dot any +} + +func (t testTemplateUI) String() string { + return fmt.Sprintf("{%q, %s}", t.Name, TagString(t.Dot)) +} + +func findJidOrJsOrHTMLNode(node parse.Node) (found bool) { + switch node := node.(type) { + case *parse.TextNode: + if node != nil { + found = found || bytes.Contains(node.Text, []byte("")) + } + case *parse.ListNode: + if node != nil { + for _, n := range node.Nodes { + found = found || findJidOrJsOrHTMLNode(n) + } + } + case *parse.ActionNode: + if node != nil { + found = findJidOrJsOrHTMLNode(node.Pipe) + } + case *parse.WithNode: + if node != nil { + found = findJidOrJsOrHTMLNode(&node.BranchNode) + } + case *parse.BranchNode: + if node != nil { + found = findJidOrJsOrHTMLNode(node.Pipe) + found = found || findJidOrJsOrHTMLNode(node.List) + found = found || findJidOrJsOrHTMLNode(node.ElseList) + } + case *parse.IfNode: + if node != nil { + found = findJidOrJsOrHTMLNode(node.Pipe) + found = found || findJidOrJsOrHTMLNode(node.List) + found = found || findJidOrJsOrHTMLNode(node.ElseList) + } + case *parse.PipeNode: + if node != nil { + for _, n := range node.Cmds { + found = found || findJidOrJsOrHTMLNode(n) + } + } + case *parse.CommandNode: + if node != nil { + for _, n := range node.Args { + found = found || findJidOrJsOrHTMLNode(n) + } + } + case *parse.VariableNode: + if node != nil { + for _, s := range node.Ident { + found = found || (s == "Jid") || (s == "JsFunc") || (s == "JsVar") + } + } + } + return +} + +func (t testTemplateUI) JawsRender(e *Element, wr io.Writer, params []any) (err error) { + var expandedtags []any + if expandedtags, err = TagExpand(e.Request, t.Dot); err == nil { + e.Request.TagExpanded(e, expandedtags) + tags, handlers, attrs := ParseParams(params) + e.Tag(tags...) + e.AddHandlers(handlers...) + attrstr := template.HTMLAttr(strings.Join(attrs, " ")) // #nosec G203 + var auth Auth = DefaultAuth{} + if f := e.Request.Jaws.MakeAuth; f != nil { + auth = f(e.Request) + } + err = fmt.Errorf("missing template %q", t.Name) + if tmpl := e.Request.Jaws.LookupTemplate(t.Name); tmpl != nil { + err = tmpl.Execute(wr, testWith{ + Element: e, + testRequestWriter: testRequestWriter{rq: e.Request, Writer: wr}, + Dot: t.Dot, + Attrs: attrstr, + Auth: auth, + }) + if deadlock.Debug && e.Jaws.Logger != nil { + if !findJidOrJsOrHTMLNode(tmpl.Tree.Root) { + e.Jaws.Logger.Warn("jaws: template has no Jid reference", "template", t.Name) + } + } + } + } + return +} + +func (t testTemplateUI) JawsUpdate(e *Element) { + if dot, ok := t.Dot.(Updater); ok { + dot.JawsUpdate(e) + } +} + +func (t testTemplateUI) JawsEvent(e *Element, wht what.What, val string) error { + return CallEventHandlers(t.Dot, e, wht, val) +} + +func (rw testRequestWriter) Template(name string, dot any, params ...any) error { + return rw.UI(testTemplateUI{Name: name, Dot: dot}, params...) +} diff --git a/jaws/testsetter_test.go b/core/testsetter_test.go similarity index 99% rename from jaws/testsetter_test.go rename to core/testsetter_test.go index f598af9c..0246fad7 100644 --- a/jaws/testsetter_test.go +++ b/core/testsetter_test.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "github.com/linkdata/deadlock" diff --git a/jaws/testrequest.go b/core/testsupport.go similarity index 85% rename from jaws/testrequest.go rename to core/testsupport.go index 4dbf659d..29062df0 100644 --- a/jaws/testrequest.go +++ b/core/testsupport.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "bytes" @@ -8,14 +8,16 @@ import ( "strings" ) +// TestRequest is a request harness intended for tests. +// +// Exposed for testing only. type TestRequest struct { *Request *httptest.ResponseRecorder - RequestWriter ReadyCh chan struct{} DoneCh chan struct{} - InCh chan wsMsg - OutCh chan wsMsg + InCh chan WsMsg + OutCh chan WsMsg BcastCh chan Message ExpectPanic bool Panicked bool @@ -25,7 +27,7 @@ type TestRequest struct { // NewTestRequest creates a TestRequest for use when testing. // Passing nil for hr will create a "GET /" request with no body. // -// If NewRequest() or UseRequest() fails, it returns nil. +// Exposed for testing only. func NewTestRequest(jw *Jaws, hr *http.Request) (tr *TestRequest) { if hr == nil { hr = httptest.NewRequest(http.MethodGet, "/", nil) @@ -42,11 +44,10 @@ func NewTestRequest(jw *Jaws, hr *http.Request) (tr *TestRequest) { tr = &TestRequest{ ReadyCh: make(chan struct{}), DoneCh: make(chan struct{}), - InCh: make(chan wsMsg), - OutCh: make(chan wsMsg, cap(bcastCh)), + InCh: make(chan WsMsg), + OutCh: make(chan WsMsg, cap(bcastCh)), BcastCh: bcastCh, Request: rq, - RequestWriter: rq.Writer(rr), ResponseRecorder: rr, } diff --git a/core/testuiwidget_test.go b/core/testuiwidget_test.go new file mode 100644 index 00000000..eb8edbd0 --- /dev/null +++ b/core/testuiwidget_test.go @@ -0,0 +1,61 @@ +package core + +import ( + "html/template" + "io" + + "github.com/linkdata/jaws/what" +) + +type testDivWidget struct { + inner template.HTML +} + +func (ui testDivWidget) JawsRender(e *Element, w io.Writer, params []any) error { + e.ApplyParams(params) + return WriteHTMLInner(w, e.Jid(), "div", "", ui.inner) +} + +func (testDivWidget) JawsUpdate(*Element) {} + +type testTextInputWidget struct { + Setter[string] + tag any + last string +} + +func newTestTextInputWidget(s Setter[string]) *testTextInputWidget { + return &testTextInputWidget{Setter: s} +} + +func (ui *testTextInputWidget) JawsRender(e *Element, w io.Writer, params []any) (err error) { + if ui.tag, err = e.ApplyGetter(ui.Setter); err == nil { + attrs := e.ApplyParams(params) + v := ui.JawsGet(e) + ui.last = v + err = WriteHTMLInput(w, e.Jid(), "text", v, attrs) + } + return +} + +func (ui *testTextInputWidget) JawsUpdate(e *Element) { + if v := ui.JawsGet(e); v != ui.last { + ui.last = v + e.SetValue(v) + } +} + +func (ui *testTextInputWidget) JawsEvent(e *Element, wht what.What, val string) (err error) { + err = ErrEventUnhandled + if wht == what.Input { + if changed, setErr := e.maybeDirty(ui.tag, ui.Setter.JawsSet(e, val)); setErr != nil { + err = setErr + } else { + err = nil + if changed { + ui.last = val + } + } + } + return +} diff --git a/jaws/ui.go b/core/ui.go similarity index 93% rename from jaws/ui.go rename to core/ui.go index dec93e95..402ed81a 100644 --- a/jaws/ui.go +++ b/core/ui.go @@ -1,4 +1,4 @@ -package jaws +package core // UI defines the required methods on JaWS UI objects. // In addition, all UI objects must be comparable so they can be used as map keys. diff --git a/jaws/uitemplate_test.go b/core/uitemplate_test.go similarity index 98% rename from jaws/uitemplate_test.go rename to core/uitemplate_test.go index a8c209c2..90e376f3 100644 --- a/jaws/uitemplate_test.go +++ b/core/uitemplate_test.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "bytes" @@ -17,7 +17,7 @@ func TestRequest_TemplateMissingJid(t *testing.T) { if !deadlock.Debug { t.Skip("debug tag not set") } - nextJid = 0 + NextJid = 0 rq := newTestRequest(t) defer rq.Close() var log bytes.Buffer @@ -36,7 +36,7 @@ func TestRequest_TemplateJidInsideIf(t *testing.T) { if !deadlock.Debug { t.Skip("debug tag not set") } - nextJid = 0 + NextJid = 0 rq := newTestRequest(t) defer rq.Close() var log bytes.Buffer @@ -55,7 +55,7 @@ func TestRequest_TemplateMissingJidButHasHTMLTag(t *testing.T) { if !deadlock.Debug { t.Skip("debug tag not set") } - nextJid = 0 + NextJid = 0 rq := newTestRequest(t) defer rq.Close() var log bytes.Buffer @@ -113,7 +113,7 @@ func TestRequest_Template(t *testing.T) { // `{{with $.Dot}}
{{.}}
{{end}}` for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - nextJid = 0 + NextJid = 0 rq := newTestRequest(t) defer rq.Close() if tt.errtxt != "" { diff --git a/jaws/updater.go b/core/updater.go similarity index 94% rename from jaws/updater.go rename to core/updater.go index e811cdc3..5698ef23 100644 --- a/jaws/updater.go +++ b/core/updater.go @@ -1,4 +1,4 @@ -package jaws +package core type Updater interface { // JawsUpdate is called for an Element that has been marked dirty to update it's HTML. diff --git a/jaws/writehtml.go b/core/writehtml.go similarity index 99% rename from jaws/writehtml.go rename to core/writehtml.go index f1202a60..e56831f6 100644 --- a/jaws/writehtml.go +++ b/core/writehtml.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "html/template" diff --git a/jaws/writehtml_test.go b/core/writehtml_test.go similarity index 99% rename from jaws/writehtml_test.go rename to core/writehtml_test.go index 8810557c..c64b33c0 100644 --- a/jaws/writehtml_test.go +++ b/core/writehtml_test.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "html/template" diff --git a/jaws/ws.go b/core/ws.go similarity index 89% rename from jaws/ws.go rename to core/ws.go index 3833d383..5ffec2ba 100644 --- a/jaws/ws.go +++ b/core/ws.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "context" @@ -42,15 +42,15 @@ func (rq *Request) ServeHTTP(w http.ResponseWriter, r *http.Request) { ws, err = websocket.Accept(w, r, nil) if err == nil { if err = rq.onConnect(); err == nil { - incomingMsgCh := make(chan wsMsg) + incomingMsgCh := make(chan WsMsg) broadcastMsgCh := rq.Jaws.subscribe(rq, 4+len(rq.elems)*4) - outboundMsgCh := make(chan wsMsg, cap(broadcastMsgCh)) + outboundMsgCh := make(chan WsMsg, cap(broadcastMsgCh)) go wsReader(rq.ctx, rq.cancelFn, rq.Jaws.Done(), incomingMsgCh, ws) // closes incomingMsgCh go wsWriter(rq.ctx, rq.cancelFn, rq.Jaws.Done(), outboundMsgCh, ws) // calls ws.Close() rq.process(broadcastMsgCh, incomingMsgCh, outboundMsgCh) // unsubscribes broadcastMsgCh, closes outboundMsgCh } else { defer ws.Close(websocket.StatusNormalClosure, err.Error()) - var msg wsMsg + var msg WsMsg msg.FillAlert(rq.Jaws.Log(err)) _ = ws.Write(r.Context(), websocket.MessageText, msg.Append(nil)) } @@ -62,7 +62,7 @@ func (rq *Request) ServeHTTP(w http.ResponseWriter, r *http.Request) { // wsReader reads websocket text messages, parses them and sends them on incomingMsgCh. // // Closes incomingMsgCh on exit. -func wsReader(ctx context.Context, ccf context.CancelCauseFunc, jawsDoneCh <-chan struct{}, incomingMsgCh chan<- wsMsg, ws *websocket.Conn) { +func wsReader(ctx context.Context, ccf context.CancelCauseFunc, jawsDoneCh <-chan struct{}, incomingMsgCh chan<- WsMsg, ws *websocket.Conn) { var typ websocket.MessageType var txt []byte var err error @@ -88,7 +88,7 @@ func wsReader(ctx context.Context, ccf context.CancelCauseFunc, jawsDoneCh <-cha // wsWriter reads JaWS messages from outboundMsgCh, formats them and writes them to the websocket. // // Closes the websocket on exit. -func wsWriter(ctx context.Context, ccf context.CancelCauseFunc, jawsDoneCh <-chan struct{}, outboundMsgCh <-chan wsMsg, ws *websocket.Conn) { +func wsWriter(ctx context.Context, ccf context.CancelCauseFunc, jawsDoneCh <-chan struct{}, outboundMsgCh <-chan WsMsg, ws *websocket.Conn) { defer ws.Close(websocket.StatusNormalClosure, "") var err error for err == nil { @@ -112,7 +112,7 @@ func wsWriter(ctx context.Context, ccf context.CancelCauseFunc, jawsDoneCh <-cha } } -func wsWriteData(wc io.WriteCloser, firstMsg wsMsg, outboundMsgCh <-chan wsMsg) (err error) { +func wsWriteData(wc io.WriteCloser, firstMsg WsMsg, outboundMsgCh <-chan WsMsg) (err error) { b := firstMsg.Append(nil) // accumulate data to send as long as more messages // are available until it exceeds 32K diff --git a/jaws/ws_test.go b/core/ws_test.go similarity index 94% rename from jaws/ws_test.go rename to core/ws_test.go index 58617682..1345d3a1 100644 --- a/jaws/ws_test.go +++ b/core/ws_test.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "bufio" @@ -231,7 +231,7 @@ func TestWS_NormalExchange(t *testing.T) { gotCallCh := make(chan struct{}) fooItem := &testUi{} - RequestWriter{ts.rq, httptest.NewRecorder()}.Register(fooItem, func(e *Element, evt what.What, val string) error { + testRequestWriter{rq: ts.rq, Writer: httptest.NewRecorder()}.Register(fooItem, func(e *Element, evt what.What, val string) error { close(gotCallCh) return fooError }) @@ -245,7 +245,7 @@ func TestWS_NormalExchange(t *testing.T) { } defer conn.Close(websocket.StatusNormalClosure, "") - msg := wsMsg{Jid: jidForTag(ts.rq, fooItem), What: what.Input} + msg := WsMsg{Jid: jidForTag(ts.rq, fooItem), What: what.Input} ctx, cancel := context.WithTimeout(ts.ctx, testTimeout) defer cancel() @@ -266,7 +266,7 @@ func TestWS_NormalExchange(t *testing.T) { if mt != websocket.MessageText { t.Error(mt) } - var m2 wsMsg + var m2 WsMsg m2.FillAlert(fooError) if !bytes.Equal(b, m2.Append(nil)) { t.Error(b) @@ -278,9 +278,9 @@ func TestReader_RespectsContextDone(t *testing.T) { ts := newTestServer() defer ts.Close() - msg := wsMsg{Jid: Jid(1234), What: what.Input} + msg := WsMsg{Jid: Jid(1234), What: what.Input} doneCh := make(chan struct{}) - inCh := make(chan wsMsg) + inCh := make(chan WsMsg) client, server := Pipe() ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) @@ -315,7 +315,7 @@ func TestReader_RespectsJawsDone(t *testing.T) { defer ts.Close() doneCh := make(chan struct{}) - inCh := make(chan wsMsg) + inCh := make(chan WsMsg) client, server := Pipe() ctx, cancel := context.WithTimeout(context.Background(), testTimeout) @@ -327,7 +327,7 @@ func TestReader_RespectsJawsDone(t *testing.T) { }() ts.jw.Close() - msg := wsMsg{Jid: Jid(1234), What: what.Input} + msg := WsMsg{Jid: Jid(1234), What: what.Input} err := client.Write(ctx, websocket.MessageText, []byte(msg.Format())) if err != nil { t.Error(err) @@ -345,7 +345,7 @@ func TestWriter_SendsThePayload(t *testing.T) { ts := newTestServer() defer ts.Close() - outCh := make(chan wsMsg) + outCh := make(chan WsMsg) defer close(outCh) client, server := Pipe() @@ -361,7 +361,7 @@ func TestWriter_SendsThePayload(t *testing.T) { ts.cancel() }() - msg := wsMsg{Jid: Jid(1234)} + msg := WsMsg{Jid: Jid(1234)} select { case <-th.C: th.Timeout() @@ -396,10 +396,10 @@ func TestWriter_ConcatenatesMessages(t *testing.T) { ts := newTestServer() defer ts.Close() - outCh := make(chan wsMsg, 2) + outCh := make(chan WsMsg, 2) defer close(outCh) - msg := wsMsg{Jid: Jid(1234)} + msg := WsMsg{Jid: Jid(1234)} outCh <- msg outCh <- msg @@ -447,7 +447,7 @@ func TestWriter_RespectsContext(t *testing.T) { defer ts.Close() doneCh := make(chan struct{}) - outCh := make(chan wsMsg) + outCh := make(chan WsMsg) defer close(outCh) client, server := Pipe() client.CloseRead(context.Background()) @@ -473,7 +473,7 @@ func TestWriter_RespectsJawsDone(t *testing.T) { defer ts.Close() doneCh := make(chan struct{}) - outCh := make(chan wsMsg) + outCh := make(chan WsMsg) defer close(outCh) client, server := Pipe() client.CloseRead(ts.ctx) @@ -498,7 +498,7 @@ func TestWriter_RespectsOutboundClosed(t *testing.T) { defer ts.Close() doneCh := make(chan struct{}) - outCh := make(chan wsMsg) + outCh := make(chan WsMsg) client, server := Pipe() client.CloseRead(ts.ctx) @@ -526,7 +526,7 @@ func TestWriter_ReportsError(t *testing.T) { defer ts.Close() doneCh := make(chan struct{}) - outCh := make(chan wsMsg) + outCh := make(chan WsMsg) client, server := Pipe() client.CloseRead(ts.ctx) server.Close(websocket.StatusNormalClosure, "") @@ -536,7 +536,7 @@ func TestWriter_ReportsError(t *testing.T) { wsWriter(ts.rq.ctx, ts.rq.cancelFn, ts.jw.Done(), outCh, server) }() - msg := wsMsg{Jid: Jid(1234)} + msg := WsMsg{Jid: Jid(1234)} select { case <-th.C: th.Timeout() @@ -561,7 +561,7 @@ func TestReader_ReportsError(t *testing.T) { defer ts.Close() doneCh := make(chan struct{}) - inCh := make(chan wsMsg) + inCh := make(chan WsMsg) client, server := Pipe() client.CloseRead(ts.ctx) server.Close(websocket.StatusNormalClosure, "") @@ -571,7 +571,7 @@ func TestReader_ReportsError(t *testing.T) { wsReader(ts.rq.ctx, ts.rq.cancelFn, ts.jw.Done(), inCh, server) }() - msg := wsMsg{Jid: Jid(1234), What: what.Input} + msg := WsMsg{Jid: Jid(1234), What: what.Input} err := client.Write(ts.ctx, websocket.MessageText, []byte(msg.Format())) if err == nil { t.Fatal("expected error") diff --git a/jaws/wsmsg.go b/core/wsmsg.go similarity index 83% rename from jaws/wsmsg.go rename to core/wsmsg.go index fd97bb7a..3291b172 100644 --- a/jaws/wsmsg.go +++ b/core/wsmsg.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "bytes" @@ -10,14 +10,14 @@ import ( "github.com/linkdata/jaws/what" ) -// wsMsg is a message sent to or from a WebSocket. -type wsMsg struct { +// WsMsg is a message sent to or from a WebSocket. +type WsMsg struct { Data string // data to send Jid Jid // Jid to send, or -1 if Data contains that already What what.What // command } -func (m *wsMsg) Append(b []byte) []byte { +func (m *WsMsg) Append(b []byte) []byte { b = append(b, m.What.String()...) b = append(b, '\t') if m.Jid >= 0 { @@ -38,12 +38,12 @@ func (m *wsMsg) Append(b []byte) []byte { return b } -func (m *wsMsg) Format() string { +func (m *WsMsg) Format() string { return string(m.Append(nil)) } // wsParse parses an incoming text buffer into a message. -func wsParse(txt []byte) (wsMsg, bool) { +func wsParse(txt []byte) (WsMsg, bool) { if len(txt) > 2 && txt[len(txt)-1] == '\n' { if nl1 := bytes.IndexByte(txt, '\t'); nl1 >= 0 { if nl2 := bytes.IndexByte(txt[nl1+1:], '\t'); nl2 >= 0 { @@ -56,10 +56,10 @@ func wsParse(txt []byte) (wsMsg, bool) { if wht != what.Set && txt[nl2+1] == '"' { var err error if data, err = strconv.Unquote(data); err != nil { - return wsMsg{}, false + return WsMsg{}, false } } - return wsMsg{ + return WsMsg{ Data: strings.ToValidUTF8(data, ""), Jid: id, What: wht, @@ -69,10 +69,10 @@ func wsParse(txt []byte) (wsMsg, bool) { } } } - return wsMsg{}, false + return WsMsg{}, false } -func (m *wsMsg) FillAlert(err error) { +func (m *WsMsg) FillAlert(err error) { m.Jid = 0 m.What = what.Alert m.Data = "danger\n" + html.EscapeString(err.Error()) diff --git a/jaws/wsmsg_test.go b/core/wsmsg_test.go similarity index 90% rename from jaws/wsmsg_test.go rename to core/wsmsg_test.go index bf4cbf8b..a2c2e1e0 100644 --- a/jaws/wsmsg_test.go +++ b/core/wsmsg_test.go @@ -1,4 +1,4 @@ -package jaws +package core import ( "bytes" @@ -11,7 +11,7 @@ import ( ) func Benchmark_wsMsg_AppendAlert(b *testing.B) { - m := wsMsg{ + m := WsMsg{ Data: "name", Jid: 1, What: what.Alert, @@ -79,7 +79,7 @@ func Test_wsMsg_Append(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - m := wsMsg{ + m := WsMsg{ Data: tt.fields.Data, Jid: tt.fields.Jid, What: tt.fields.What, @@ -100,12 +100,12 @@ func Test_wsParse_CompletePasses(t *testing.T) { tests := []struct { name string txt string - want wsMsg + want WsMsg }{ - {"shortest", "Update\t\t\n", wsMsg{What: what.Update}}, - {"unquoted", "Input\tJid.1\ttrue\n", wsMsg{Jid: Jid(1), What: what.Input, Data: "true"}}, - {"normal", "Input\tJid.2\t\"c\"\n", wsMsg{Jid: Jid(2), What: what.Input, Data: "c"}}, - {"newline", "Input\tJid.3\t\"c\\nd\"\n", wsMsg{Jid: Jid(3), What: what.Input, Data: "c\nd"}}, + {"shortest", "Update\t\t\n", WsMsg{What: what.Update}}, + {"unquoted", "Input\tJid.1\ttrue\n", WsMsg{Jid: Jid(1), What: what.Input, Data: "true"}}, + {"normal", "Input\tJid.2\t\"c\"\n", WsMsg{Jid: Jid(2), What: what.Input, Data: "c"}}, + {"newline", "Input\tJid.3\t\"c\\nd\"\n", WsMsg{Jid: Jid(3), What: what.Input, Data: "c\nd"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -132,7 +132,7 @@ func Test_wsParse_IncompleteFails(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, ok := wsParse(tt.txt) - if ok || !reflect.DeepEqual(got, wsMsg{}) { + if ok || !reflect.DeepEqual(got, WsMsg{}) { t.Errorf("wsParse(%q): got %q wanted wsMsg{}", tt.txt, got) } }) @@ -169,7 +169,7 @@ func Test_wsMsg_FillAlert(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var m wsMsg + var m WsMsg m.FillAlert(tt.err) got := m.Format() if got != tt.want { diff --git a/example_test.go b/example_test.go index 4328eac8..b48ade8c 100644 --- a/example_test.go +++ b/example_test.go @@ -7,6 +7,7 @@ import ( "sync" "github.com/linkdata/jaws" + "github.com/linkdata/jaws/ui" ) const indexhtml = ` @@ -37,6 +38,6 @@ func Example() { var mu sync.Mutex var f float64 - http.DefaultServeMux.Handle("/", jw.Handler("index", jaws.Bind(&mu, &f))) + http.DefaultServeMux.Handle("/", ui.Handler(jw, "index", jaws.Bind(&mu, &f))) slog.Error(http.ListenAndServe("localhost:8080", nil).Error()) } diff --git a/go.mod b/go.mod index b193b0a4..05e11a74 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.24 require ( github.com/coder/websocket v1.8.14 github.com/linkdata/deadlock v0.5.5 - github.com/linkdata/jq v0.0.2 + github.com/linkdata/jq v0.0.3 ) -require github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe // indirect +require github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect diff --git a/go.sum b/go.sum index 86026a8b..61427ce6 100644 --- a/go.sum +++ b/go.sum @@ -2,7 +2,8 @@ github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9 github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/linkdata/deadlock v0.5.5 h1:d6O+rzEqasSfamGDA8u7bjtaq7hOX8Ha4Zn36Wxrkvo= github.com/linkdata/deadlock v0.5.5/go.mod h1:tXb28stzAD3trzEEK0UJWC+rZKuobCoPktPYzebb1u0= -github.com/linkdata/jq v0.0.2 h1:soi7rqpPYer/g2kh+qjy3eDsSqH5WmRR++ez1z0S2pY= -github.com/linkdata/jq v0.0.2/go.mod h1:b76MMuWybyXrVEKSHJkv+r5IEattnghPf1bNRJ5PNlE= -github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe h1:vHpqOnPlnkba8iSxU4j/CvDSS9J4+F4473esQsYLGoE= +github.com/linkdata/jq v0.0.3 h1:m1eFUh2gJ8ebg3GF5d+etiGbMtYtYSO3M7jn2Gy5cs4= +github.com/linkdata/jq v0.0.3/go.mod h1:b76MMuWybyXrVEKSHJkv+r5IEattnghPf1bNRJ5PNlE= github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14= +github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= diff --git a/jaws.go b/jaws.go index 5fe530e9..e137f25b 100644 --- a/jaws.go +++ b/jaws.go @@ -4,8 +4,9 @@ import ( "sync" "time" - pkg "github.com/linkdata/jaws/jaws" + "github.com/linkdata/jaws/core" "github.com/linkdata/jaws/jid" + "github.com/linkdata/jaws/ui" ) // The point of this is to not have a zillion files in the repository root @@ -16,150 +17,331 @@ import ( type ( Jid = jid.Jid - Jaws = pkg.Jaws - Request = pkg.Request - Element = pkg.Element - UI = pkg.UI - Updater = pkg.Updater - Renderer = pkg.Renderer - TemplateLookuper = pkg.TemplateLookuper - HandleFunc = pkg.HandleFunc - PathSetter = pkg.PathSetter - SetPather = pkg.SetPather - Formatter = pkg.Formatter - Auth = pkg.Auth - InitHandler = pkg.InitHandler - ClickHandler = pkg.ClickHandler - EventHandler = pkg.EventHandler - SelectHandler = pkg.SelectHandler - Container = pkg.Container - Getter[T comparable] = pkg.Getter[T] - Setter[T comparable] = pkg.Setter[T] - Binder[T comparable] = pkg.Binder[T] - HTMLGetter = pkg.HTMLGetter - JsVar[T any] = pkg.JsVar[T] - IsJsVar = pkg.IsJsVar - JsVarMaker = pkg.JsVarMaker - Logger = pkg.Logger - RWLocker = pkg.RWLocker - TagGetter = pkg.TagGetter - NamedBool = pkg.NamedBool - NamedBoolArray = pkg.NamedBoolArray - Template = pkg.Template - RequestWriter = pkg.RequestWriter - With = pkg.With - Session = pkg.Session - Tag = pkg.Tag - TestRequest = pkg.TestRequest + Jaws = core.Jaws + Request = core.Request + Element = core.Element + UI = core.UI + Updater = core.Updater + Renderer = core.Renderer + TemplateLookuper = core.TemplateLookuper + HandleFunc = core.HandleFunc + Formatter = core.Formatter + Auth = core.Auth + InitHandler = core.InitHandler + ClickHandler = core.ClickHandler + EventHandler = core.EventHandler + SelectHandler = core.SelectHandler + Container = core.Container + Getter[T comparable] = core.Getter[T] + Setter[T comparable] = core.Setter[T] + Binder[T comparable] = core.Binder[T] + HTMLGetter = core.HTMLGetter + Logger = core.Logger + RWLocker = core.RWLocker + TagGetter = core.TagGetter + NamedBool = core.NamedBool + NamedBoolArray = core.NamedBoolArray + Session = core.Session + Tag = core.Tag + TestRequest = core.TestRequest ) var ( - ErrEventUnhandled = pkg.ErrEventUnhandled - ErrIllegalTagType = pkg.ErrIllegalTagType // ErrIllegalTagType is returned when a UI tag type is disallowed - ErrMissingTemplate = pkg.ErrMissingTemplate - ErrNotComparable = pkg.ErrNotComparable - ErrNoWebSocketRequest = pkg.ErrNoWebSocketRequest - ErrPendingCancelled = pkg.ErrPendingCancelled - ErrValueUnchanged = pkg.ErrValueUnchanged - ErrValueNotSettable = pkg.ErrValueNotSettable - ErrRequestAlreadyClaimed = pkg.ErrRequestAlreadyClaimed - ErrJavascriptDisabled = pkg.ErrJavascriptDisabled - ErrTooManyTags = pkg.ErrTooManyTags + ErrEventUnhandled = core.ErrEventUnhandled + ErrIllegalTagType = core.ErrIllegalTagType // ErrIllegalTagType is returned when a UI tag type is disallowed + ErrNotComparable = core.ErrNotComparable + ErrNoWebSocketRequest = core.ErrNoWebSocketRequest + ErrPendingCancelled = core.ErrPendingCancelled + ErrValueUnchanged = core.ErrValueUnchanged + ErrValueNotSettable = core.ErrValueNotSettable + ErrRequestAlreadyClaimed = core.ErrRequestAlreadyClaimed + ErrJavascriptDisabled = core.ErrJavascriptDisabled + ErrTooManyTags = core.ErrTooManyTags ) const ( - ISO8601 = pkg.ISO8601 + ISO8601 = core.ISO8601 ) // Non-generic function assignments (no wrapper overhead) var ( - New = pkg.New - JawsKeyString = pkg.JawsKeyString - WriteHTMLTag = pkg.WriteHTMLTag - NewTemplate = pkg.NewTemplate - HTMLGetterFunc = pkg.HTMLGetterFunc - StringGetterFunc = pkg.StringGetterFunc - MakeHTMLGetter = pkg.MakeHTMLGetter - NewNamedBool = pkg.NewNamedBool - NewNamedBoolArray = pkg.NewNamedBoolArray + New = core.New + JawsKeyString = core.JawsKeyString + WriteHTMLTag = core.WriteHTMLTag + HTMLGetterFunc = core.HTMLGetterFunc + StringGetterFunc = core.StringGetterFunc + MakeHTMLGetter = core.MakeHTMLGetter + NewNamedBool = core.NewNamedBool + NewNamedBoolArray = core.NewNamedBoolArray + NewTestRequest = core.NewTestRequest ) // Generic functions must be wrapped func Bind[T comparable](l sync.Locker, p *T) Binder[T] { - return pkg.Bind(l, p) + return core.Bind(l, p) } +/* + The following should no longer be accessed using jaws.X, + but should instead be ui.X. + + Mark as deprecated. +*/ + +// Template is an alias for ui.Template. +// +// Deprecated: use ui.Template directly. +type Template = ui.Template + +// RequestWriter is an alias for ui.RequestWriter. +// +// Deprecated: use ui.RequestWriter directly. +type RequestWriter = ui.RequestWriter + +// PathSetter is an alias for ui.PathSetter. +// +// Deprecated: use ui.PathSetter directly. +type PathSetter = ui.PathSetter + +// SetPather is an alias for ui.SetPather. +// +// Deprecated: use ui.SetPather directly. +type SetPather = ui.SetPather + +// JsVar is an alias for ui.JsVar. +// +// Deprecated: use ui.JsVar directly. +type JsVar[T any] = ui.JsVar[T] + +// IsJsVar is an alias for ui.IsJsVar. +// +// Deprecated: use ui.IsJsVar directly. +type IsJsVar = ui.IsJsVar + +// JsVarMaker is an alias for ui.JsVarMaker. +// +// Deprecated: use ui.JsVarMaker directly. +type JsVarMaker = ui.JsVarMaker + +// With is an alias for ui.With. +// +// Deprecated: use ui.With directly. +type With = ui.With + +// NewTemplate creates a new ui.Template. +// +// Deprecated: use ui.NewTemplate directly. +var NewTemplate = ui.NewTemplate + +// NewJsVar creates a new ui.JsVar. +// +// Deprecated: use ui.NewJsVar directly. func NewJsVar[T any](l sync.Locker, v *T) *JsVar[T] { - return pkg.NewJsVar(l, v) + return ui.NewJsVar(l, v) } -type ( - UiA = pkg.UiA - UiButton = pkg.UiButton - UiCheckbox = pkg.UiCheckbox - UiContainer = pkg.UiContainer - UiDate = pkg.UiDate - UiDiv = pkg.UiDiv - UiImg = pkg.UiImg - UiLabel = pkg.UiLabel - UiLi = pkg.UiLi - UiNumber = pkg.UiNumber - UiPassword = pkg.UiPassword - UiRadio = pkg.UiRadio - UiRange = pkg.UiRange - UiSelect = pkg.UiSelect - UiSpan = pkg.UiSpan - UiTbody = pkg.UiTbody - UiTd = pkg.UiTd - UiText = pkg.UiText - UiTr = pkg.UiTr -) +// UiA is an alias for ui.A. +// +// Deprecated: use ui.A directly. +type UiA = ui.A -// UI constructor assignments (generic types require wrappers, others are direct) -var ( - NewUiA = pkg.NewUiA - NewUiButton = pkg.NewUiButton - NewUiContainer = pkg.NewUiContainer - NewUiDiv = pkg.NewUiDiv - NewUiLabel = pkg.NewUiLabel - NewUiLi = pkg.NewUiLi - NewUiSelect = pkg.NewUiSelect - NewUiSpan = pkg.NewUiSpan - NewUiTbody = pkg.NewUiTbody - NewUiTd = pkg.NewUiTd - NewUiTr = pkg.NewUiTr - NewTestRequest = pkg.NewTestRequest -) +// UiButton is an alias for ui.Button. +// +// Deprecated: use ui.Button directly. +type UiButton = ui.Button + +// UiCheckbox is an alias for ui.Checkbox. +// +// Deprecated: use ui.Checkbox directly. +type UiCheckbox = ui.Checkbox + +// UiContainer is an alias for ui.Container. +// +// Deprecated: use ui.Container directly. +type UiContainer = ui.Container + +// UiDate is an alias for ui.Date. +// +// Deprecated: use ui.Date directly. +type UiDate = ui.Date + +// UiDiv is an alias for ui.Div. +// +// Deprecated: use ui.Div directly. +type UiDiv = ui.Div -// UI constructors with generic parameters must be wrapped +// UiImg is an alias for ui.Img. +// +// Deprecated: use ui.Img directly. +type UiImg = ui.Img + +// UiLabel is an alias for ui.Label. +// +// Deprecated: use ui.Label directly. +type UiLabel = ui.Label + +// UiLi is an alias for ui.Li. +// +// Deprecated: use ui.Li directly. +type UiLi = ui.Li + +// UiNumber is an alias for ui.Number. +// +// Deprecated: use ui.Number directly. +type UiNumber = ui.Number + +// UiPassword is an alias for ui.Password. +// +// Deprecated: use ui.Password directly. +type UiPassword = ui.Password + +// UiRadio is an alias for ui.Radio. +// +// Deprecated: use ui.Radio directly. +type UiRadio = ui.Radio + +// UiRange is an alias for ui.Range. +// +// Deprecated: use ui.Range directly. +type UiRange = ui.Range + +// UiSelect is an alias for ui.Select. +// +// Deprecated: use ui.Select directly. +type UiSelect = ui.Select + +// UiSpan is an alias for ui.Span. +// +// Deprecated: use ui.Span directly. +type UiSpan = ui.Span + +// UiTbody is an alias for ui.Tbody. +// +// Deprecated: use ui.Tbody directly. +type UiTbody = ui.Tbody + +// UiTd is an alias for ui.Td. +// +// Deprecated: use ui.Td directly. +type UiTd = ui.Td + +// UiText is an alias for ui.Text. +// +// Deprecated: use ui.Text directly. +type UiText = ui.Text + +// UiTr is an alias for ui.Tr. +// +// Deprecated: use ui.Tr directly. +type UiTr = ui.Tr + +// NewUiA creates a new ui.A. +// +// Deprecated: use ui.NewA directly. +var NewUiA = ui.NewA + +// NewUiButton creates a new ui.Button. +// +// Deprecated: use ui.NewButton directly. +var NewUiButton = ui.NewButton + +// NewUiContainer creates a new ui.Container. +// +// Deprecated: use ui.NewContainer directly. +var NewUiContainer = ui.NewContainer + +// NewUiDiv creates a new ui.Div. +// +// Deprecated: use ui.NewDiv directly. +var NewUiDiv = ui.NewDiv + +// NewUiLabel creates a new ui.Label. +// +// Deprecated: use ui.NewLabel directly. +var NewUiLabel = ui.NewLabel + +// NewUiLi creates a new ui.Li. +// +// Deprecated: use ui.NewLi directly. +var NewUiLi = ui.NewLi + +// NewUiSelect creates a new ui.Select. +// +// Deprecated: use ui.NewSelect directly. +var NewUiSelect = ui.NewSelect + +// NewUiSpan creates a new ui.Span. +// +// Deprecated: use ui.NewSpan directly. +var NewUiSpan = ui.NewSpan + +// NewUiTbody creates a new ui.Tbody. +// +// Deprecated: use ui.NewTbody directly. +var NewUiTbody = ui.NewTbody + +// NewUiTd creates a new ui.Td. +// +// Deprecated: use ui.NewTd directly. +var NewUiTd = ui.NewTd + +// NewUiTr creates a new ui.Tr. +// +// Deprecated: use ui.NewTr directly. +var NewUiTr = ui.NewTr + +// NewUiCheckbox creates a new ui.Checkbox. +// +// Deprecated: use ui.NewCheckbox directly. func NewUiCheckbox(g Setter[bool]) *UiCheckbox { - return pkg.NewUiCheckbox(g) + return ui.NewCheckbox(g) } +// NewUiDate creates a new ui.Date. +// +// Deprecated: use ui.NewDate directly. func NewUiDate(g Setter[time.Time]) *UiDate { - return pkg.NewUiDate(g) + return ui.NewDate(g) } +// NewUiImg creates a new ui.Img. +// +// Deprecated: use ui.NewImg directly. func NewUiImg(g Getter[string]) *UiImg { - return pkg.NewUiImg(g) + return ui.NewImg(g) } +// NewUiNumber creates a new ui.Number. +// +// Deprecated: use ui.NewNumber directly. func NewUiNumber(g Setter[float64]) *UiNumber { - return pkg.NewUiNumber(g) + return ui.NewNumber(g) } +// NewUiPassword creates a new ui.Password. +// +// Deprecated: use ui.NewPassword directly. func NewUiPassword(g Setter[string]) *UiPassword { - return pkg.NewUiPassword(g) + return ui.NewPassword(g) } +// NewUiRadio creates a new ui.Radio. +// +// Deprecated: use ui.NewRadio directly. func NewUiRadio(vp Setter[bool]) *UiRadio { - return pkg.NewUiRadio(vp) + return ui.NewRadio(vp) } +// NewUiRange creates a new ui.Range. +// +// Deprecated: use ui.NewRange directly. func NewUiRange(g Setter[float64]) *UiRange { - return pkg.NewUiRange(g) + return ui.NewRange(g) } +// NewUiText creates a new ui.Text. +// +// Deprecated: use ui.NewText directly. func NewUiText(vp Setter[string]) *UiText { - return pkg.NewUiText(vp) + return ui.NewText(vp) } diff --git a/jaws/jawsjaws_test.go b/jaws/jawsjaws_test.go deleted file mode 100644 index 75d36df0..00000000 --- a/jaws/jawsjaws_test.go +++ /dev/null @@ -1,559 +0,0 @@ -package jaws - -import ( - "bufio" - "bytes" - "context" - "errors" - "fmt" - "html/template" - "io" - "log/slog" - "net/http" - "net/http/httptest" - "net/netip" - "strconv" - "strings" - "testing" - "time" - - "github.com/linkdata/jaws/what" -) - -func TestJaws_parseIP(t *testing.T) { - is := newTestHelper(t) - is.True(!parseIP("").IsValid()) - is.True(parseIP("192.168.0.1").Compare(netip.MustParseAddr("192.168.0.1")) == 0) - is.True(parseIP("192.168.0.2:1234").Compare(netip.MustParseAddr("192.168.0.2")) == 0) - is.True(parseIP("127.0.0.1").Compare(netip.MustParseAddr("127.0.0.1")) == 0) - is.True(parseIP("::1").Compare(netip.IPv6Loopback()) == 0) - is.True(equalIP(parseIP("127.0.0.1"), parseIP("::1"))) - is.Equal(equalIP(parseIP("127.0.0.1"), netip.Addr{}), false) - is.Equal(equalIP(netip.IPv4Unspecified(), netip.Addr{}), false) - is.Equal(equalIP(netip.IPv6Unspecified(), netip.Addr{}), false) - is.Equal(equalIP(netip.IPv6Loopback(), netip.Addr{}), false) - is.Equal(equalIP(netip.Addr{}, netip.Addr{}), true) -} - -func TestJaws_getCookieSessionsIds(t *testing.T) { - const sessId = 1234 - sessCookie := JawsKeyString(sessId) - is := newTestHelper(t) - is.Equal(getCookieSessionsIds(nil, "meh"), nil) - is.Equal(getCookieSessionsIds(http.Header{}, "meh"), nil) - is.Equal(getCookieSessionsIds(http.Header{"Cookie": []string{}}, "meh"), nil) - is.Equal(getCookieSessionsIds(http.Header{"Cookie": []string{"foo=123"}}, "meh"), nil) - is.Equal(getCookieSessionsIds(http.Header{"Cookie": []string{"meh=" + sessCookie}}, "meh"), []uint64{sessId}) - is.Equal(getCookieSessionsIds(http.Header{"Cookie": []string{"meh=\"" + sessCookie + "\""}}, "meh"), []uint64{sessId}) -} - -func TestJaws_MultipleCloseCalls(t *testing.T) { - jw, _ := New() - go jw.Serve() - jw.Close() - jw.Close() -} - -func TestJaws_MakeID(t *testing.T) { - is := newTestHelper(t) - jw, _ := New() - defer jw.Close() - go jw.Serve() - id1 := MakeID() - id2 := MakeID() - is.True(strings.HasPrefix(id1, "jaws.")) - is.True(len(id1) > 5) - is.True(id1 != id2) -} - -func TestJaws_maybePanic(t *testing.T) { - is := newTestHelper(t) - defer func() { - if recover() == nil { - is.Fail() - } - }() - maybePanic(errors.New("let's panic!")) -} - -func TestJaws_Logger(t *testing.T) { - jw, _ := New() - defer jw.Close() - var b bytes.Buffer - jw.Logger = slog.New(slog.NewTextHandler(&b, nil)) - go jw.Serve() - jw.Log(errors.New("bar")) - if !strings.Contains(b.String(), "msg=jaws err=bar") { - t.Error(b.String()) - } -} - -func TestJaws_MustLog(t *testing.T) { - is := newTestHelper(t) - jw, _ := New() - defer jw.Close() - - barErr := errors.New("bar") - - defer func() { - is.Equal(recover(), barErr) - }() - - var b bytes.Buffer - jw.Logger = slog.New(slog.NewTextHandler(&b, nil)) - go jw.Serve() - jw.MustLog(barErr) - is.True(strings.Contains(b.String(), "err=bar")) - jw.Logger = nil - jw.MustLog(barErr) -} - -func TestJaws_BroadcastDoesntBlockWhenClosed(t *testing.T) { - jw, _ := New() - go jw.Serve() - jw.Close() - for i := 0; i < cap(jw.bcastCh)+1; i++ { - jw.Broadcast(Message{}) - } -} - -func TestJaws_BroadcastWaitsWhenFull(t *testing.T) { - th := newTestHelper(t) - jw, _ := New() - go jw.ServeWithTimeout(testTimeout) - - subCh := jw.subscribe(jw.NewRequest(nil), 0) - defer jw.unsubscribe(subCh) - - // ensure our sub has been processed - jw.subCh <- subscription{} - jw.subCh <- subscription{} - - // send two broadcasts - select { - case <-th.C: - th.Timeout() - case jw.bcastCh <- Message{What: what.Reload}: - } - select { - case <-th.C: - th.Timeout() - case jw.bcastCh <- Message{What: what.Reload}: - } - - // read one of the broadcasts, the other is - // left to fall into the retry loop - select { - case <-th.C: - th.Timeout() - case <-subCh: - } - - // wait a bit to make sure it's in the loop - time.Sleep(time.Millisecond * 5) - - // finally, read the msg - select { - case <-th.C: - th.Timeout() - case <-subCh: - } -} - -func TestJaws_BroadcastFullClosesChannel(t *testing.T) { - th := newTestHelper(t) - jw, _ := New() - go jw.ServeWithTimeout(time.Millisecond) - - doneCh := make(chan struct{}) - failCh := make(chan struct{}) - - subCh1 := jw.subscribe(jw.NewRequest(nil), 0) - - defer jw.unsubscribe(subCh1) - subCh2 := jw.subscribe(jw.NewRequest(nil), 0) - defer jw.unsubscribe(subCh2) - jw.subCh <- subscription{} - jw.subCh <- subscription{} - - go func() { - select { - case <-t.Context().Done(): - close(failCh) - case <-th.C: - close(failCh) - case <-subCh2: - close(doneCh) - } - }() - - select { - case <-th.C: - th.Timeout() - case jw.bcastCh <- Message{What: what.Reload}: - } - - select { - case <-th.C: - th.Timeout() - case <-failCh: - th.Timeout() - case <-doneCh: - } - - // the order in which subscribers are used is random, - // so we have to wait a bit to ensure we get processed - time.Sleep(time.Millisecond * 5) - - select { - case msg, ok := <-subCh1: - th.True(!ok) - th.Equal(msg, Message{}) - default: - } -} - -func TestJaws_UseRequest(t *testing.T) { - th := newTestHelper(t) - jw, _ := New() - defer jw.Close() - - th.Equal(0, jw.RequestCount()) - - type testKey string - rq1 := jw.NewRequest(nil) - th.True(rq1.JawsKey != 0) - rq1.SetContext(func(oldctx context.Context) (newctx context.Context) { - return context.WithValue(oldctx, testKey("key"), "val") - }) - if val := rq1.Context().Value(testKey("key")); val != "val" { - t.Error("value wrong", val) - } - - rq2 := jw.NewRequest(&http.Request{RemoteAddr: "10.0.0.2:1010"}) - th.True(rq2.JawsKey != 0) - th.True(rq1.JawsKey != rq2.JawsKey) - th.Equal(jw.Pending(), 2) - - rqfail := jw.UseRequest(0, nil) // wrong JawsKey - th.Equal(rqfail, nil) - th.Equal(jw.Pending(), 2) - - rqfail = jw.UseRequest(rq1.JawsKey, &http.Request{RemoteAddr: "10.0.0.1:1010"}) // wrong IP, expect blank - th.Equal(rqfail, nil) - th.Equal(jw.Pending(), 2) - - rqfail = jw.UseRequest(rq2.JawsKey, &http.Request{RemoteAddr: "10.0.0.1:1010"}) // wrong IP, expect .2 - th.Equal(rqfail, nil) - th.Equal(jw.Pending(), 2) - - rq2ret := jw.UseRequest(rq2.JawsKey, &http.Request{RemoteAddr: "10.0.0.2:1212"}) // different port is OK - th.Equal(rq2, rq2ret) - th.Equal(jw.Pending(), 1) - - rqfail = jw.UseRequest(rq2.JawsKey, &http.Request{RemoteAddr: "10.0.0.2:1214"}) // already claimed - th.Equal(rqfail, nil) - th.Equal(jw.Pending(), 1) - - rq1ret := jw.UseRequest(rq1.JawsKey, nil) - th.Equal(rq1, rq1ret) - th.Equal(jw.Pending(), 0) - - if val := rq1.Context().Value(testKey("key")); val != "val" { - t.Error("value no longer set", val) - } -} - -func TestJaws_BlockingRandomPanics(t *testing.T) { - th := newTestHelper(t) - defer func() { - if recover() == nil { - th.Error("expected error") - } - }() - jw, _ := New() - defer jw.Close() - jw.kg = bufio.NewReader(&bytes.Buffer{}) - jw.NewRequest(nil) -} - -type rawLogger struct { - w io.Writer -} - -var _ slog.Handler = &rawLogger{} - -func (h *rawLogger) Enabled(_ context.Context, level slog.Level) bool { - return true -} - -func (h *rawLogger) Handle(ctx context.Context, r slog.Record) error { - _, err := h.w.Write([]byte(r.Message)) - r.Attrs(func(a slog.Attr) bool { - fmt.Fprintf(h.w, " %s=%q", a.Key, a.Value) - return true - }) - h.w.Write([]byte("\n")) - return err -} - -func (h *rawLogger) WithAttrs(attrs []slog.Attr) slog.Handler { - return h -} - -func (h *rawLogger) WithGroup(name string) slog.Handler { - return h -} - -func TestJaws_CleansUpUnconnected(t *testing.T) { - const numReqs = 100 - th := newTestHelper(t) - jw, _ := New() - defer jw.Close() - var b bytes.Buffer - jw.Logger = slog.New(&rawLogger{w: &b}) - hr := httptest.NewRequest(http.MethodGet, "/", nil) - th.Equal(jw.Pending(), 0) - deadline := time.Now().Add(testTimeout) - var expectLen int - for i := 0; i < numReqs; i++ { - rq := jw.NewRequest(hr) - if (i % (numReqs / 5)) == 0 { - rq.NewElement(NewUiDiv(MakeHTMLGetter("meh"))) - } - err := context.Cause(rq.ctx) - if err == nil && rq.lastWrite.Before(deadline) { - err = newErrPendingCancelledLocked(rq, newErrNoWebSocketRequest(rq)) - } - if err == nil { - t.Fatal("expected error") - } - expectLen += len("jaws " + "err=" + strconv.Quote(err.Error()) + "\n") - } - th.Equal(jw.Pending(), numReqs) - - go jw.ServeWithTimeout(time.Millisecond) - - lastPending := jw.Pending() - for jw.Pending() > 0 && time.Now().Before(deadline) { - if jw.Pending() < lastPending { - lastPending = jw.Pending() - deadline = time.Now().Add(time.Second) - } - } - - th.Equal(jw.Pending(), 0) - - jw.Close() - select { - case <-th.C: - th.Timeout() - case <-jw.Done(): - } - if x := b.Len(); x != expectLen { - t.Log(b.String()) - th.Equal(b.Len(), expectLen) - } -} - -func getLastWrite(rq *Request) (when time.Time) { - rq.mu.RLock() - when = rq.lastWrite - rq.mu.RUnlock() - return -} - -func TestJaws_RequestWriterExtendsDeadline(t *testing.T) { - th := newTestHelper(t) - jw, _ := New() - defer jw.Close() - var b bytes.Buffer - jw.Logger = slog.New(slog.NewTextHandler(&b, nil)) - defer jw.Close() - - hr := httptest.NewRequest(http.MethodGet, "/", nil) - rq := jw.NewRequest(hr) - rq.lastWrite = time.Now().Add(time.Second) - lastWrite := rq.lastWrite - - var sb strings.Builder - rw := rq.Writer(&sb) - - ui := &testUi{renderFn: func(e *Element, w io.Writer, params []any) error { - w.Write(nil) - return nil - }} - - rw.UI(ui) - - th.True(ui.renderCalled > 0) - th.True(rq.rendering.Load()) - th.Equal(lastWrite, getLastWrite(rq)) - - go jw.ServeWithTimeout(time.Millisecond) - - for lastWrite.Equal(getLastWrite(rq)) { - select { - case <-th.C: - th.Timeout() - case <-jw.Done(): - th.Error("unexpected close") - default: - time.Sleep(time.Millisecond) - } - } - if getLastWrite(rq).IsZero() { - th.Error("last write is zero") - } - if lastWrite.Equal(getLastWrite(rq)) { - th.Error("last write not modified") - } -} - -func TestJaws_UnconnectedLivesUntilDeadline(t *testing.T) { - th := newTestHelper(t) - jw, _ := New() - defer jw.Close() - - hr := httptest.NewRequest(http.MethodGet, "/", nil) - rq1 := jw.NewRequest(hr) - rq1ctx := rq1.Context() - rq2 := jw.NewRequest(hr) - rq2.lastWrite = time.Now().Add(-time.Second * 10) - rq2ctx := rq2.Context() - - th.Equal(jw.Pending(), 2) - - go jw.ServeWithTimeout(time.Second) - - for jw.Pending() > 1 { - select { - case <-th.C: - th.Timeout() - case <-jw.Done(): - th.Error("unexpected close") - default: - time.Sleep(time.Millisecond) - } - } - - th.Equal(jw.Pending(), 1) - - jw.Close() - select { - case <-th.C: - th.Timeout() - case <-jw.Done(): - } - - // neither should have been recycled - th.Equal(rq1.Jaws, jw) - th.Equal(rq2.Jaws, jw) - - th.NoErr(context.Cause(rq1ctx)) - if !errors.Is(context.Cause(rq2ctx), errNoWebSocketRequest{}) { - th.Error(context.Cause(rq2ctx)) - } - -} - -func TestJaws_BroadcastsCallable(t *testing.T) { - jw, _ := New() - defer jw.Close() - go jw.Serve() - - jw.Delete("foo") - jw.Insert("foo", "bar", "baz") - jw.Append("foo", "bar") - jw.Replace("foo", "bar", "baz") - jw.Reload() - jw.Redirect("foo") - jw.Alert("info", "bar") - someTags := []any{Tag("tag1"), Tag("tag2")} - jw.SetInner("regularHTMLId", template.HTML("")) - jw.SetValue("regularHTMLId", "value") - jw.SetAttr(someTags, "attribute", "value") - jw.RemoveAttr(someTags, "attribute") - jw.SetClass(someTags, "classname") - jw.RemoveClass(someTags, "classname") -} - -func TestJaws_subscribeOnClosedReturnsNil(t *testing.T) { - th := newTestHelper(t) - jw, _ := New() - jw.Close() - <-jw.Done() - for len(jw.subCh) < cap(jw.subCh) { - select { - case jw.subCh <- subscription{}: - default: - } - } - - th.Equal(jw.subscribe(jw.NewRequest(nil), 1), nil) -} - -func TestJaws_GenerateHeadHTML(t *testing.T) { - const extraScript = "someExtraScript.js?disregard" - const extraStyle = "http://other.server/someExtraStyle.css" - const extraImage = "someExtraImage.png" - const extraIcon = "favicon.png" - const extraFont = "someExtraFont.woff2" - th := newTestHelper(t) - jw, _ := New() - jw.Close() - - th.NoErr(jw.GenerateHeadHTML()) - th.True(strings.Contains(string(jw.headPrefix), jw.serveJS.Name)) - th.True(strings.Contains(string(jw.headPrefix), jw.serveCSS.Name)) - - th.NoErr(jw.GenerateHeadHTML(extraScript, extraStyle, extraImage, extraIcon, extraFont)) - th.True(strings.Contains(string(jw.headPrefix), jw.serveJS.Name)) - th.True(strings.Contains(string(jw.headPrefix), jw.serveCSS.Name)) - th.True(strings.Contains(string(jw.headPrefix), extraScript)) - th.True(strings.Contains(string(jw.headPrefix), extraStyle)) - th.True(strings.Contains(string(jw.headPrefix), extraImage)) - th.True(strings.Contains(string(jw.headPrefix), extraIcon)) - th.True(strings.Contains(string(jw.headPrefix), extraFont)) - - th.Equal(jw.FaviconURL(), extraIcon) - - th.True(jw.GenerateHeadHTML("\n") != nil) -} - -func TestJaws_TemplateLookuper(t *testing.T) { - th := newTestHelper(t) - tj := newTestJaws() - defer tj.Close() - rq := NewTestRequest(tj.Jaws, nil) - defer rq.Close() - th.Equal(rq.Jaws.LookupTemplate("nosuchtemplate"), nil) - th.Equal(rq.Jaws.LookupTemplate("testtemplate"), tj.testtmpl) - rq.Jaws.RemoveTemplateLookuper(tj.testtmpl) - th.Equal(rq.Jaws.LookupTemplate("testtemplate"), nil) -} - -func TestJaws_JsCall(t *testing.T) { - th := newTestHelper(t) - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - tss := &testUi{} - - var sb strings.Builder - elem := rq.NewElement(tss) - th.Equal(elem.Jid(), Jid(1)) - err := elem.JawsRender(&sb, nil) - th.NoErr(err) - - elem.Jaws.JsCall(tss, "somefn", "1.3") - - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - got := msg.Format() - th.Equal(got, "Call\tJid.1\tsomefn=1.3\n") - } -} diff --git a/jaws/jsvar_test.go b/jaws/jsvar_test.go deleted file mode 100644 index 96c1681a..00000000 --- a/jaws/jsvar_test.go +++ /dev/null @@ -1,319 +0,0 @@ -package jaws - -import ( - "encoding/json" - "fmt" - "html/template" - "reflect" - "strings" - "sync" - "sync/atomic" - "testing" - - "github.com/linkdata/deadlock" - "github.com/linkdata/jaws/what" -) - -const varname = "myjsvar" - -type valtype struct { - String string - Number float64 -} - -type testLocker struct { - sync.Locker - unlockCalled chan struct{} - unlockCount int32 -} - -func (tl *testLocker) reset() { - tl.unlockCalled = make(chan struct{}) - atomic.StoreInt32(&tl.unlockCount, 0) -} - -func (tl *testLocker) Unlock() { - tl.Locker.Unlock() - if atomic.AddInt32(&tl.unlockCount, 1) == 1 { - if tl.unlockCalled != nil { - close(tl.unlockCalled) - } - } -} - -func Test_JsVar_JawsRender(t *testing.T) { - th := newTestHelper(t) - rq := newTestRequest(t) - defer rq.Close() - - nextJid = 0 - rq.Jaws.AddTemplateLookuper(template.Must(template.New("jsvartemplate").Parse(`{{$.JsVar "` + varname + `" .Dot}}`))) - - var mu deadlock.RWMutex - var val valtype - jsv := NewJsVar(&mu, &val) - dot := jsv - elem := rq.NewElement(dot) - - jsv.JawsUpdate(nil) // no-op, just to satisfy coverage - - if err := dot.JawsSet(elem, valtype{String: "text", Number: 1.23}); err != nil { - t.Error(err) - } - - th.Equal(val.String, "text") - - x := dot.JawsGet(elem) - th.Equal(x, val) - - if err := rq.Template("jsvartemplate", dot); err != nil { - t.Error(err) - } - - want := `` - th.Equal(string(rq.BodyHTML()), want) -} - -func Test_JsVar_Update(t *testing.T) { - th := newTestHelper(t) - jw, _ := New() - defer jw.Close() - nextJid = 0 - - type valtype struct { - String string - Number float64 - } - var mu deadlock.Mutex - var val valtype - dot := NewJsVar(&mu, &val) - - rq := newTestRequest(t) - defer rq.Close() - - elem := rq.NewElement(dot) - var sb strings.Builder - if err := dot.JawsRender(elem, &sb, []any{varname}); err != nil { - t.Fatal(err) - } - // data-jawsdata not present because it's the zero value - want := `` - th.Equal(strings.TrimSpace(sb.String()), want) - if err := dot.JawsSet(elem, valtype{"x", 2}); err != nil { - t.Error(err) - } - // rq.Dirty(dot) - - select { - case <-th.C: - th.Timeout() - case gotMsg := <-rq.OutCh: - wantMsg := wsMsg{ - Data: "={\"String\":\"x\",\"Number\":2}", - Jid: 1, - What: what.Set, - } - if !reflect.DeepEqual(gotMsg, wantMsg) { - t.Errorf("\n got %v\nwant %v\n", gotMsg, wantMsg) - } - } -} - -func Test_JsVar_Event(t *testing.T) { - th := newTestHelper(t) - jw, _ := New() - defer jw.Close() - nextJid = 0 - - const varname = "myjsvar" - type valtype struct { - String string - Number float64 - } - var mu deadlock.Mutex - val := valtype{String: "!"} - tl := testLocker{Locker: &mu, unlockCalled: make(chan struct{})} - dot := NewJsVar(&tl, &val) - - tj := newTestJaws() - defer tj.Close() - const expectedHTML = `` - - rq1 := tj.newRequest(nil) - elem1 := rq1.NewElement(dot) - var sb1 strings.Builder - if err := dot.JawsRender(elem1, &sb1, []any{varname}); err != nil { - t.Fatal(err) - } - th.Equal(strings.TrimSpace(sb1.String()), fmt.Sprintf(expectedHTML, 1)) - - rq2 := tj.newRequest(nil) - elem2 := rq2.NewElement(dot) - var sb2 strings.Builder - if err := dot.JawsRender(elem2, &sb2, []any{varname}); err != nil { - t.Fatal(err) - } - th.Equal(strings.TrimSpace(sb2.String()), fmt.Sprintf(expectedHTML, 2)) - - select { - case <-th.C: - th.Timeout() - case <-tl.unlockCalled: - } - - tl.reset() - - select { - case <-th.C: - th.Timeout() - case rq1.InCh <- wsMsg{Jid: 1, What: what.Set, Data: "={\"String\":\"y\",\"Number\":3}"}: - } - - select { - case <-th.C: - th.Timeout() - case <-tl.unlockCalled: - } - - th.Equal(val, valtype{"y", 3}) - - select { - case <-th.C: - th.Timeout() - case msg := <-rq1.OutCh: - s := msg.Format() - after, found := strings.CutPrefix(s, "Set\tJid.1\t=") - th.Equal(found, true) - if found { - var x valtype - err := json.Unmarshal([]byte(after), &x) - th.NoErr(err) - th.Equal(x, valtype{"y", 3}) - } else { - t.Fatalf("%q", s) - } - } - - select { - case <-th.C: - th.Timeout() - case msg := <-rq2.OutCh: - s := msg.Format() - after, found := strings.CutPrefix(s, "Set\tJid.2\t=") - th.Equal(found, true) - if found { - var x valtype - err := json.Unmarshal([]byte(after), &x) - th.NoErr(err) - th.Equal(x, valtype{"y", 3}) - } else { - t.Fatalf("%q", s) - } - } - - select { - case <-th.C: - th.Timeout() - case rq1.InCh <- wsMsg{Jid: 1, What: what.Set, Data: "=1"}: - } - - select { - case <-th.C: - th.Timeout() - case msg := <-rq1.OutCh: - s := msg.Format() - if !strings.Contains(s, "jq: expected") { - th.Error(s) - } - } -} - -func Test_JsVar_PanicsOnWrongType(t *testing.T) { - th := newTestHelper(t) - rq := newTestRequest(t) - defer rq.Close() - defer func() { - if x := recover(); x == nil { - th.Fail() - } - }() - rq.JsVar("", 1) - th.Fail() -} - -type testJsVarMaker struct { -} - -func (t *testJsVarMaker) JawsMakeJsVar(rq *Request) (v IsJsVar, err error) { - var mu deadlock.Mutex - val := "quote(')" - return NewJsVar(&mu, &val), nil -} - -var _ JsVarMaker = &testJsVarMaker{} - -func Test_JsVar_JsVarMaker(t *testing.T) { - nextJid = 0 - th := newTestHelper(t) - rq := newTestRequest(t) - defer rq.Close() - err := rq.JsVar("foo", &testJsVarMaker{}) - th.NoErr(err) - th.Equal(string(rq.BodyHTML()), "") -} - -type testJsVarPathSetter struct { - Value string - atomic.Bool -} - -func (t *testJsVarPathSetter) JawsSetPath(elem *Element, jspath string, value any) (err error) { - s := value.(string) + "!!" - if t.Value == s { - return ErrValueUnchanged - } - t.Value = s - return nil -} - -func (t *testJsVarPathSetter) JawsPathSet(elem *Element, jspath string, value any) { - t.Bool.Store(true) -} - -var _ PathSetter = &testJsVarPathSetter{} -var _ SetPather = &testJsVarPathSetter{} - -func Test_JsVar_PathSetter_SetPather(t *testing.T) { - nextJid = 0 - th := newTestHelper(t) - rq := newTestRequest(t) - defer rq.Close() - - var mu deadlock.Mutex - var val testJsVarPathSetter - jsv := NewJsVar(&mu, &val) - elem := rq.NewElement(jsv) - err := jsv.JawsSetPath(elem, "", "foo") - th.NoErr(err) - th.Equal(val.Value, "foo!!") - th.Equal(val.Bool.Load(), true) -} - -func Test_JsVar_Unchanged(t *testing.T) { - nextJid = 0 - th := newTestHelper(t) - rq := newTestRequest(t) - defer rq.Close() - - var mu deadlock.Mutex - var val string - jsv := NewJsVar(&mu, &val) - elem := rq.NewElement(jsv) - err := jsv.JawsSetPath(elem, "", "foo") - th.NoErr(err) - th.Equal(val, "foo") - - err = jsv.JawsSetPath(elem, "", "foo") - th.Equal(err, ErrValueUnchanged) - th.Equal(nil, elideErrValueUnchanged(err)) -} diff --git a/jaws/requestwriter.go b/jaws/requestwriter.go deleted file mode 100644 index aba67c7e..00000000 --- a/jaws/requestwriter.go +++ /dev/null @@ -1,56 +0,0 @@ -package jaws - -import ( - "io" - "net/http" -) - -type RequestWriter struct { - rq *Request - io.Writer -} - -func (rw RequestWriter) UI(ui UI, params ...any) error { - return rw.rq.NewElement(ui).JawsRender(rw, params) -} - -func (rw RequestWriter) Write(p []byte) (n int, err error) { - rw.rq.rendering.Store(true) - return rw.Writer.Write(p) -} - -// Request returns the current jaws.Request. -func (rw RequestWriter) Request() *Request { - return rw.rq -} - -// Initial returns the initial http.Request. -func (rw RequestWriter) Initial() *http.Request { - return rw.Request().Initial() -} - -// HeadHTML outputs the HTML code needed in the HEAD section. -func (rw RequestWriter) HeadHTML() error { - return rw.Request().HeadHTML(rw) -} - -// TailHTML writes optional HTML code at the end of the page's BODY section that -// will immediately apply updates made during initial rendering. -func (rw RequestWriter) TailHTML() error { - return rw.Request().TailHTML(rw) -} - -// Session returns the Requests's Session, or nil. -func (rw RequestWriter) Session() *Session { - return rw.Request().Session() -} - -// Get calls Request().Get() -func (rw RequestWriter) Get(key string) (val any) { - return rw.Request().Get(key) -} - -// Set calls Request().Set() -func (rw RequestWriter) Set(key string, val any) { - rw.Request().Set(key, val) -} diff --git a/jaws/testpage_test.go b/jaws/testpage_test.go deleted file mode 100644 index 82eef84c..00000000 --- a/jaws/testpage_test.go +++ /dev/null @@ -1,109 +0,0 @@ -package jaws - -import ( - "html/template" - "io" - "time" -) - -const testPageTmplText = "(" + - "{{$.Initial.URL.Path}}" + - "{{$.A `a`}}" + - "{{$.Button `button`}}" + - "{{$.Checkbox .TheBool `checkbox`}}" + - "{{$.Container `container` .TheContainer}}" + - "{{$.Date .TheTime `dateattr`}}" + - "{{$.Div `div`}}" + - "{{$.Img `img`}}" + - "{{$.Label `label`}}" + - "{{$.Li `li`}}" + - "{{$.Number .TheNumber}}" + - "{{$.Password .TheString}}" + - "{{$.Radio .TheBool}}" + - "{{$.Range .TheNumber}}" + - "{{$.Select .TheSelector}}" + - "{{$.Span `span`}}" + - "{{$.Tbody .TheContainer}}" + - "{{$.Td `td`}}" + - "{{$.Template `nested` .TheDot `someattr`}}" + - "{{$.Text .TheString}}" + - "{{$.Textarea .TheString}}" + - "{{$.Tr `tr`}}" + - ")" -const testPageNestedTmplText = "" + - "{{$.Initial.URL.Path}}" + - "{{with .Dot}}{{.}}{{$.Span `span2`}}{{end}}" + - "" - -const testPageWant = "(" + - "/" + - "a" + - "" + - "" + - "" + - "" + - "
div
" + - "" + - "" + - "
  • li
  • " + - "" + - "" + - "" + - "" + - "" + - "span" + - "" + - "td" + - "/dotspan2" + - "" + - "" + - "tr" + - ")" - -type testPage struct { - RequestWriter - Normal *template.Template - TheBool Setter[bool] - TheContainer Container - TheTime Setter[time.Time] - TheNumber Setter[float64] - TheString Setter[string] - TheSelector SelectHandler - TheDot any -} - -func newTestPage(tr *TestRequest) *testPage { - testDate, _ := time.Parse(ISO8601, "1901-02-03") - tr.Jaws.AddTemplateLookuper(template.Must(template.New("nested").Parse(testPageNestedTmplText))) - tmpl := template.Must(template.New("normal").Parse(testPageTmplText)) - - tp := &testPage{ - RequestWriter: RequestWriter{rq: tr.rq}, - Normal: tmpl, - TheBool: newTestSetter(true), - TheContainer: &testContainer{}, - TheTime: newTestSetter(testDate), - TheNumber: newTestSetter(float64(1.2)), - TheString: newTestSetter("bar"), - TheSelector: &testNamedBoolArray{ - setCalled: make(chan struct{}), - NamedBoolArray: NewNamedBoolArray(), - }, - TheDot: Tag("dot"), - } - - return tp -} - -func (tp *testPage) render(w io.Writer) (err error) { - nextJid = 4 - tp.RequestWriter.Writer = w - return tp.Normal.Execute(w, tp) -} - -func (tp *testPage) updateElems() { - rq := tp.RequestWriter.rq - for _, elem := range rq.elems { - elem.JawsUpdate() - } -} diff --git a/jaws/ui_test.go b/jaws/ui_test.go deleted file mode 100644 index cc79a5a7..00000000 --- a/jaws/ui_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package jaws - -import ( - "bytes" - "errors" - "io" - "strings" - "testing" - - "github.com/linkdata/deadlock" -) - -func TestRequest_NewElement_DebugPanicsIfNotComparable(t *testing.T) { - notHashableUI := struct { - *UiSpan - x map[int]int - }{ - UiSpan: NewUiSpan(testHTMLGetter("foo")), - x: map[int]int{}, - } - - if newErrNotComparable(notHashableUI) == nil { - t.FailNow() - } - - if deadlock.Debug { - defer func() { - if x := recover(); x != nil { - if err, ok := x.(error); ok { - if !errors.Is(err, ErrNotComparable) { - t.Errorf("%T", err) - } - return - } - } - t.Fatal("expected ErrNotComparable") - }() - - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - rq.NewElement(notHashableUI) - t.Fail() - } -} - -type testStringer struct{} - -func (testStringer) String() string { return "foo" } - -func TestRequest_JawsRender_DebugOutput(t *testing.T) { - is := newTestHelper(t) - rq := newTestRequest(t) - defer rq.Close() - - rq.Jaws.Debug = true - rq.UI(&testUi{renderFn: func(e *Element, w io.Writer, params []any) error { - e.Tag(Tag("footag")) - e.Tag(e.Request) - e.Tag(testStringer{}) - return nil - }}) - h := rq.BodyString() - t.Log(h) - if !strings.Contains(h, "tags=[n/a]") { - is.True(strings.Contains(h, "footag")) - is.True(strings.Contains(h, "testUi")) - is.True(strings.Contains(h, testStringer{}.String())) - } -} - -func TestRequest_InsideTemplate(t *testing.T) { - var buf bytes.Buffer - tr := newTestRequest(t) - defer tr.Close() - tp := newTestPage(tr) - err := tp.render(&buf) - if err != nil { - t.Fatal(err) - } - if x := buf.String(); x != testPageWant { - t.Errorf("mismatch:\nwant %q\n got %q", testPageWant, x) - } -} - -func BenchmarkPageRender(b *testing.B) { - tr := newTestRequest(nil) - defer tr.Close() - - tp := newTestPage(tr) - b.ResetTimer() - for i := 0; i < b.N; i++ { - var buf bytes.Buffer - tp.render(&buf) - } -} - -func BenchmarkPageUpdate(b *testing.B) { - tr := newTestRequest(nil) - defer tr.Close() - tp := newTestPage(tr) - var buf bytes.Buffer - tp.render(&buf) - b.ResetTimer() - for i := 0; i < b.N; i++ { - var x []byte - tp.updateElems() - for _, wsmsg := range tr.rq.wsQueue { - x = wsmsg.Append(x) - } - tr.rq.wsQueue = tr.rq.wsQueue[:0] - } -} diff --git a/jaws/uia.go b/jaws/uia.go deleted file mode 100644 index 4b5f7d27..00000000 --- a/jaws/uia.go +++ /dev/null @@ -1,25 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiA struct { - UiHTMLInner -} - -func (ui *UiA) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderInner(e, w, "a", "", params) -} - -func NewUiA(innerHTML HTMLGetter) *UiA { - return &UiA{ - UiHTMLInner{ - HTMLGetter: innerHTML, - }, - } -} - -func (rq RequestWriter) A(innerHTML any, params ...any) error { - return rq.UI(NewUiA(MakeHTMLGetter(innerHTML)), params...) -} diff --git a/jaws/uia_test.go b/jaws/uia_test.go deleted file mode 100644 index d1fbd2c5..00000000 --- a/jaws/uia_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package jaws - -import ( - "html/template" - "reflect" - "testing" -) - -type testHTMLGetter string - -func (g testHTMLGetter) JawsGetHTML(e *Element) template.HTML { - return template.HTML(g) -} - -var _ HTMLGetter = testHTMLGetter("foo") - -func TestRequest_A(t *testing.T) { - type args struct { - innerHTML any - params []any - } - tests := []struct { - name string - args args - want template.HTML - }{ - { - name: "string", - args: args{ - innerHTML: "string", - params: []any{}, - }, - want: `string`, - }, - { - name: "template.HTML", - args: args{ - innerHTML: template.HTML("
    "), - params: []any{`href="#"`}, - }, - want: `
    `, - }, - { - name: "HTMLGetter", - args: args{ - innerHTML: testHTMLGetter("
    "), - params: []any{}, - }, - want: `
    `, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - rq.A(tt.args.innerHTML, tt.args.params...) - if got := rq.BodyHTML(); !reflect.DeepEqual(got, tt.want) { - t.Errorf("Request.A() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/jaws/uibutton.go b/jaws/uibutton.go deleted file mode 100644 index ce2b2c0d..00000000 --- a/jaws/uibutton.go +++ /dev/null @@ -1,25 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiButton struct { - UiHTMLInner -} - -func (ui *UiButton) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderInner(e, w, "button", "button", params) -} - -func NewUiButton(innerHTML HTMLGetter) *UiButton { - return &UiButton{ - UiHTMLInner{ - HTMLGetter: innerHTML, - }, - } -} - -func (rq RequestWriter) Button(innerHTML any, params ...any) error { - return rq.UI(NewUiButton(MakeHTMLGetter(innerHTML)), params...) -} diff --git a/jaws/uibutton_test.go b/jaws/uibutton_test.go deleted file mode 100644 index fb2e7804..00000000 --- a/jaws/uibutton_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package jaws - -import ( - "testing" -) - -func TestRequest_Button(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - want := `` - rq.Button("inner") - if got := rq.BodyString(); got != want { - t.Errorf("Request.Button() = %q, want %q", got, want) - } -} diff --git a/jaws/uicheckbox.go b/jaws/uicheckbox.go deleted file mode 100644 index df19cf8b..00000000 --- a/jaws/uicheckbox.go +++ /dev/null @@ -1,25 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiCheckbox struct { - UiInputBool -} - -func (ui *UiCheckbox) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderBoolInput(e, w, "checkbox", params...) -} - -func NewUiCheckbox(g Setter[bool]) *UiCheckbox { - return &UiCheckbox{ - UiInputBool{ - Setter: g, - }, - } -} - -func (rq RequestWriter) Checkbox(value any, params ...any) error { - return rq.UI(NewUiCheckbox(makeSetter[bool](value)), params...) -} diff --git a/jaws/uicheckbox_test.go b/jaws/uicheckbox_test.go deleted file mode 100644 index b537889b..00000000 --- a/jaws/uicheckbox_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package jaws - -import ( - "errors" - "testing" - - "github.com/linkdata/jaws/what" -) - -func TestRequest_Checkbox(t *testing.T) { - th := newTestHelper(t) - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - ts := newTestSetter(true) - want := `` - rq.Checkbox(ts) - if got := rq.BodyString(); got != want { - t.Errorf("Request.Checkbox() = %q, want %q", got, want) - } - - val := false - rq.InCh <- wsMsg{Data: "false", Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case <-ts.setCalled: - } - if ts.Get() != val { - t.Error(ts.Get(), "!=", val) - } - select { - case s := <-rq.OutCh: - t.Errorf("%q", s) - default: - } - - val = true - ts.Set(val) - rq.Dirty(ts) - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Value\tJid.1\t\"true\"\n" { - t.Errorf("%q", s) - } - } - if ts.Get() != val { - t.Error("not set") - } - if ts.SetCount() != 1 { - t.Error("SetCount", ts.SetCount()) - } - - rq.InCh <- wsMsg{Data: "omg", Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Alert\t\t\"danger\\nstrconv.ParseBool: parsing "omg": invalid syntax\"\n" { - t.Errorf("wrong Alert: %q", s) - } - } - - ts.err = errors.New("meh") - rq.InCh <- wsMsg{Data: "true", Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Alert\t\t\"danger\\nmeh\"\n" { - t.Errorf("wrong Alert: %q", s) - } - } -} diff --git a/jaws/uicontainer.go b/jaws/uicontainer.go deleted file mode 100644 index ecd8689c..00000000 --- a/jaws/uicontainer.go +++ /dev/null @@ -1,27 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiContainer struct { - OuterHTMLTag string - uiWrapContainer -} - -func NewUiContainer(outerHTMLTag string, c Container) *UiContainer { - return &UiContainer{ - OuterHTMLTag: outerHTMLTag, - uiWrapContainer: uiWrapContainer{ - Container: c, - }, - } -} - -func (ui *UiContainer) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderContainer(e, w, ui.OuterHTMLTag, params) -} - -func (rq RequestWriter) Container(outerHTMLTag string, c Container, params ...any) error { - return rq.UI(NewUiContainer(outerHTMLTag, c), params...) -} diff --git a/jaws/uicontainer_test.go b/jaws/uicontainer_test.go deleted file mode 100644 index 45730529..00000000 --- a/jaws/uicontainer_test.go +++ /dev/null @@ -1,188 +0,0 @@ -package jaws - -import ( - "html/template" - "net/http" - "net/http/httptest" - "reflect" - "slices" - "strings" - "testing" - - "github.com/linkdata/jaws/what" -) - -type testContainer struct{ contents []UI } - -func (tc *testContainer) JawsContains(e *Element) (contents []UI) { - return tc.contents -} - -var _ Container = &testContainer{} - -func TestRequest_Container(t *testing.T) { - type args struct { - c Container - params []any - } - tests := []struct { - name string - args args - want template.HTML - }{ - { - name: "empty", - args: args{ - c: &testContainer{}, - params: []any{}, - }, - want: `
    `, - }, - { - name: "one", - args: args{ - c: &testContainer{[]UI{NewUiSpan(testHTMLGetter("foo"))}}, - params: []any{"hidden"}, - }, - want: ``, - }, - { - name: "two", - args: args{ - c: &testContainer{[]UI{NewUiSpan(testHTMLGetter("foo")), NewUiSpan(testHTMLGetter("bar"))}}, - params: []any{"hidden"}, - }, - want: ``, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - err := rq.Container("div", tt.args.c, tt.args.params...) - if err != nil { - t.Error(err) - } - if got := rq.BodyHTML(); !reflect.DeepEqual(got, tt.want) { - t.Errorf("Request.Container()\nwant %v\n got %v", tt.want, got) - } - }) - } -} - -func TestRequest_Container_Alteration(t *testing.T) { - span1 := NewUiSpan(MakeHTMLGetter("span1")) - span2 := NewUiSpan(MakeHTMLGetter("span2")) - span3 := NewUiSpan(MakeHTMLGetter("span3")) - tests := []struct { - name string - c *testContainer - l []UI - want []wsMsg - }{ - { - name: "no change", - c: &testContainer{contents: []UI{span1, span2, span3}}, - l: []UI{span1, span2, span3}, - want: []wsMsg{}, - }, - { - name: "add one to empty", - c: &testContainer{}, - l: []UI{span1}, - want: []wsMsg{ - { - Data: `span1`, - Jid: 1, - What: what.Append, - }, - { - Data: `Jid.2`, - Jid: 1, - What: what.Order, - }, - }, - }, - { - name: "append two", - c: &testContainer{contents: []UI{span1}}, - l: []UI{span1, span2, span3}, - want: []wsMsg{ - { - Data: `span2`, - Jid: 1, - What: what.Append, - }, - { - Data: `span3`, - Jid: 1, - What: what.Append, - }, - { - Data: `Jid.2 Jid.3 Jid.4`, - Jid: 1, - What: what.Order, - }, - }, - }, - { - name: "remove first", - c: &testContainer{contents: []UI{span1, span2, span3}}, - l: []UI{span2, span3}, - want: []wsMsg{ - { - Data: `Jid.2`, - Jid: 1, - What: what.Remove, - }, - { - Data: `Jid.3 Jid.4`, - Jid: 1, - What: what.Order, - }, - }, - }, - { - name: "reorder and replace", - c: &testContainer{contents: []UI{span1, span2}}, - l: []UI{span3, span1}, - want: []wsMsg{ - { - Data: `Jid.3`, - Jid: 1, - What: what.Remove, - }, - { - Data: `span3`, - Jid: 1, - What: what.Append, - }, - { - Data: `Jid.4 Jid.2`, - Jid: 1, - What: what.Order, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - jw, _ := New() - defer jw.Close() - nextJid = 0 - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) - ui := NewUiContainer("div", tt.c) - elem := rq.NewElement(ui) - var sb strings.Builder - if err := ui.JawsRender(elem, &sb, nil); err != nil { - t.Fatal(err) - } - tt.c.contents = tt.l - elem.JawsUpdate() - if !slices.Equal(rq.wsQueue, tt.want) { - t.Errorf("got %v, want %v", rq.wsQueue, tt.want) - } - }) - } -} diff --git a/jaws/uidate.go b/jaws/uidate.go deleted file mode 100644 index 7b725230..00000000 --- a/jaws/uidate.go +++ /dev/null @@ -1,28 +0,0 @@ -package jaws - -import ( - "io" - "time" -) - -const ISO8601 = "2006-01-02" - -type UiDate struct { - UiInputDate -} - -func (ui *UiDate) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderDateInput(e, w, "date", params...) -} - -func NewUiDate(g Setter[time.Time]) *UiDate { - return &UiDate{ - UiInputDate{ - Setter: g, - }, - } -} - -func (rq RequestWriter) Date(value any, params ...any) error { - return rq.UI(NewUiDate(makeSetter[time.Time](value)), params...) -} diff --git a/jaws/uidate_test.go b/jaws/uidate_test.go deleted file mode 100644 index de30cab8..00000000 --- a/jaws/uidate_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package jaws - -import ( - "errors" - "fmt" - "testing" - "time" - - "github.com/linkdata/jaws/what" -) - -func TestRequest_Date(t *testing.T) { - th := newTestHelper(t) - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - ts := newTestSetter(time.Now()) - want := fmt.Sprintf(``, ts.Get().Format(ISO8601)) - rq.Date(ts) - if got := rq.BodyString(); got != want { - t.Errorf("Request.Date() = %q, want %q", got, want) - } - - val, _ := time.Parse(ISO8601, "1970-02-03") - rq.InCh <- wsMsg{Data: val.Format(ISO8601), Jid: 1, What: what.Input} - tmr := time.NewTimer(testTimeout) - defer tmr.Stop() - select { - case <-th.C: - th.Timeout() - case <-ts.setCalled: - } - if ts.Get() != val { - t.Error(ts.Get(), "!=", val) - } - select { - case s := <-rq.OutCh: - t.Errorf("%q", s) - default: - } - - val = time.Now() - ts.Set(val) - rq.Dirty(ts) - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != fmt.Sprintf("Value\tJid.1\t\"%s\"\n", val.Format(ISO8601)) { - t.Error("wrong Value") - } - } - if ts.Get() != val { - t.Error("not set") - } - if ts.SetCount() != 1 { - t.Error("SetCount", ts.SetCount()) - } - - rq.InCh <- wsMsg{Data: "omg", Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Alert\t\t\"danger\\nparsing time "omg" as "2006-01-02": cannot parse "omg" as "2006"\"\n" { - t.Errorf("wrong Alert: %q", s) - } - } - - ts.err = errors.New("meh") - rq.InCh <- wsMsg{Data: val.Format(ISO8601), Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Alert\t\t\"danger\\nmeh\"\n" { - t.Errorf("wrong Alert: %q", s) - } - } -} diff --git a/jaws/uidiv.go b/jaws/uidiv.go deleted file mode 100644 index b40dddaa..00000000 --- a/jaws/uidiv.go +++ /dev/null @@ -1,25 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiDiv struct { - UiHTMLInner -} - -func (ui *UiDiv) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderInner(e, w, "div", "", params) -} - -func NewUiDiv(innerHTML HTMLGetter) *UiDiv { - return &UiDiv{ - UiHTMLInner{ - HTMLGetter: innerHTML, - }, - } -} - -func (rq RequestWriter) Div(innerHTML any, params ...any) error { - return rq.UI(NewUiDiv(MakeHTMLGetter(innerHTML)), params...) -} diff --git a/jaws/uidiv_test.go b/jaws/uidiv_test.go deleted file mode 100644 index 95ddc020..00000000 --- a/jaws/uidiv_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package jaws - -import ( - "testing" -) - -func TestRequest_Div(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - want := `
    inner
    ` - rq.Div("inner") - if got := rq.BodyString(); got != want { - t.Errorf("Request.Div() = %q, want %q", got, want) - } -} diff --git a/jaws/uihtmlinner.go b/jaws/uihtmlinner.go deleted file mode 100644 index 93d61061..00000000 --- a/jaws/uihtmlinner.go +++ /dev/null @@ -1,20 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiHTMLInner struct { - HTMLGetter -} - -func (ui *UiHTMLInner) renderInner(e *Element, w io.Writer, htmltag, htmltype string, params []any) (err error) { - if _, err = e.ApplyGetter(ui.HTMLGetter); err == nil { - err = WriteHTMLInner(w, e.Jid(), htmltag, htmltype, ui.JawsGetHTML(e), e.ApplyParams(params)...) - } - return -} - -func (ui *UiHTMLInner) JawsUpdate(e *Element) { - e.SetInner(ui.JawsGetHTML(e)) -} diff --git a/jaws/uihtmlinner_test.go b/jaws/uihtmlinner_test.go deleted file mode 100644 index edfab8e3..00000000 --- a/jaws/uihtmlinner_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package jaws - -import ( - "html/template" - "net/http" - "net/http/httptest" - "slices" - "strings" - "testing" - - "github.com/linkdata/jaws/what" -) - -func TestUiHTMLInner_JawsUpdate(t *testing.T) { - jw, _ := New() - defer jw.Close() - nextJid = 0 - ts := newTestSetter(template.HTML("first")) - rq := jw.NewRequest(httptest.NewRequest(http.MethodGet, "/", nil)) - ui := NewUiDiv(ts) - elem := rq.NewElement(ui) - var sb strings.Builder - if err := ui.JawsRender(elem, &sb, nil); err != nil { - t.Fatal(err) - } - wantHTML := "
    first
    " - if sb.String() != wantHTML { - t.Errorf("got %q, want %q", sb.String(), wantHTML) - } - ts.Set(template.HTML("second")) - ui.JawsUpdate(elem) - want := []wsMsg{{ - Data: "second", - Jid: 1, - What: what.Inner, - }} - if !slices.Equal(rq.wsQueue, want) { - t.Errorf("got %v, want %v", elem.wsQueue, want) - } -} diff --git a/jaws/uiimg.go b/jaws/uiimg.go deleted file mode 100644 index e5403e39..00000000 --- a/jaws/uiimg.go +++ /dev/null @@ -1,34 +0,0 @@ -package jaws - -import ( - "html/template" - "io" - "strconv" -) - -type UiImg struct { - Getter[string] -} - -func (ui *UiImg) JawsRender(e *Element, w io.Writer, params []any) (err error) { - if _, err = e.ApplyGetter(ui.Getter); err == nil { - srcattr := template.HTMLAttr("src=" + strconv.Quote(ui.JawsGet(e))) // #nosec G203 - attrs := append(e.ApplyParams(params), srcattr) - err = WriteHTMLInner(w, e.Jid(), "img", "", "", attrs...) - } - return -} - -func (ui *UiImg) JawsUpdate(e *Element) { - e.SetAttr("src", ui.JawsGet(e)) -} - -func NewUiImg(g Getter[string]) *UiImg { - return &UiImg{ - Getter: g, - } -} - -func (rq RequestWriter) Img(imageSrc any, params ...any) error { - return rq.UI(NewUiImg(makeGetter[string](imageSrc)), params...) -} diff --git a/jaws/uiimg_test.go b/jaws/uiimg_test.go deleted file mode 100644 index 341c7cfa..00000000 --- a/jaws/uiimg_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package jaws - -import ( - "strconv" - "testing" -) - -func TestRequest_Img(t *testing.T) { - th := newTestHelper(t) - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - ts := newTestSetter("image.png") - - wantHTML := "" - rq.Img(ts, "hidden") - if gotHTML := rq.BodyString(); gotHTML != wantHTML { - t.Errorf("Request.Img() = %q, want %q", gotHTML, wantHTML) - } - - ts.Set("image2.jpg") - rq.Dirty(ts) - - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "SAttr\tJid.1\t\"src\\nimage2.jpg\"\n" { - t.Error(strconv.Quote(s)) - } - } -} diff --git a/jaws/uiinput.go b/jaws/uiinput.go deleted file mode 100644 index 2204959c..00000000 --- a/jaws/uiinput.go +++ /dev/null @@ -1,21 +0,0 @@ -package jaws - -import "sync/atomic" - -type UiInput struct { - Tag any - Last atomic.Value -} - -func (ui *UiInput) applyGetter(e *Element, getter any) (err error) { - ui.Tag, err = e.ApplyGetter(getter) - return -} - -func (ui *UiInput) maybeDirty(val any, e *Element, err error) error { - var changed bool - if changed, err = e.maybeDirty(ui.Tag, err); changed { - ui.Last.Store(val) - } - return err -} diff --git a/jaws/uiinputbool.go b/jaws/uiinputbool.go deleted file mode 100644 index 498e1a05..00000000 --- a/jaws/uiinputbool.go +++ /dev/null @@ -1,51 +0,0 @@ -package jaws - -import ( - "io" - "strconv" - - "github.com/linkdata/jaws/what" -) - -type UiInputBool struct { - UiInput - Setter[bool] -} - -func (ui *UiInputBool) renderBoolInput(e *Element, w io.Writer, htmltype string, params ...any) (err error) { - if err = ui.applyGetter(e, ui.Setter); err == nil { - attrs := e.ApplyParams(params) - v := ui.JawsGet(e) - ui.Last.Store(v) - if v { - attrs = append(attrs, "checked") - } - err = WriteHTMLInput(w, e.Jid(), htmltype, "", attrs) - } - return -} - -func (ui *UiInputBool) JawsUpdate(e *Element) { - v := ui.JawsGet(e) - if ui.Last.Swap(v) != v { - txt := "false" - if v { - txt = "true" - } - e.SetValue(txt) - } -} - -func (ui *UiInputBool) JawsEvent(e *Element, wht what.What, val string) (err error) { - err = ErrEventUnhandled - if wht == what.Input { - var v bool - if val != "" { - if v, err = strconv.ParseBool(val); err != nil { - return - } - } - err = ui.maybeDirty(v, e, ui.Setter.JawsSet(e, v)) - } - return -} diff --git a/jaws/uiinputdate.go b/jaws/uiinputdate.go deleted file mode 100644 index 9261eb41..00000000 --- a/jaws/uiinputdate.go +++ /dev/null @@ -1,46 +0,0 @@ -package jaws - -import ( - "io" - "time" - - "github.com/linkdata/jaws/what" -) - -type UiInputDate struct { - UiInput - Setter[time.Time] -} - -func (ui *UiInputDate) str() string { - return ui.Last.Load().(time.Time).Format(ISO8601) -} - -func (ui *UiInputDate) renderDateInput(e *Element, w io.Writer, htmltype string, params ...any) (err error) { - if err = ui.applyGetter(e, ui.Setter); err == nil { - attrs := e.ApplyParams(params) - ui.Last.Store(ui.JawsGet(e)) - err = WriteHTMLInput(w, e.Jid(), htmltype, ui.str(), attrs) - } - return -} - -func (ui *UiInputDate) JawsUpdate(e *Element) { - if t := ui.JawsGet(e); ui.Last.Swap(t) != t { - e.SetValue(ui.str()) - } -} - -func (ui *UiInputDate) JawsEvent(e *Element, wht what.What, val string) (err error) { - err = ErrEventUnhandled - if wht == what.Input { - var v time.Time - if val != "" { - if v, err = time.Parse(ISO8601, val); err != nil { - return - } - } - err = ui.maybeDirty(v, e, ui.Setter.JawsSet(e, v)) - } - return -} diff --git a/jaws/uiinputfloat.go b/jaws/uiinputfloat.go deleted file mode 100644 index 2dca8def..00000000 --- a/jaws/uiinputfloat.go +++ /dev/null @@ -1,46 +0,0 @@ -package jaws - -import ( - "io" - "strconv" - - "github.com/linkdata/jaws/what" -) - -type UiInputFloat struct { - UiInput - Setter[float64] -} - -func (ui *UiInputFloat) str() string { - return strconv.FormatFloat(ui.Last.Load().(float64), 'f', -1, 64) -} - -func (ui *UiInputFloat) renderFloatInput(e *Element, w io.Writer, htmltype string, params ...any) (err error) { - if err = ui.applyGetter(e, ui.Setter); err == nil { - attrs := e.ApplyParams(params) - ui.Last.Store(ui.JawsGet(e)) - err = WriteHTMLInput(w, e.Jid(), htmltype, ui.str(), attrs) - } - return -} - -func (ui *UiInputFloat) JawsUpdate(e *Element) { - if f := ui.JawsGet(e); ui.Last.Swap(f) != f { - e.SetValue(ui.str()) - } -} - -func (ui *UiInputFloat) JawsEvent(e *Element, wht what.What, val string) (err error) { - err = ErrEventUnhandled - if wht == what.Input { - var v float64 - if val != "" { - if v, err = strconv.ParseFloat(val, 64); err != nil { - return - } - } - err = ui.maybeDirty(v, e, ui.Setter.JawsSet(e, v)) - } - return -} diff --git a/jaws/uiinputtext.go b/jaws/uiinputtext.go deleted file mode 100644 index 0395320f..00000000 --- a/jaws/uiinputtext.go +++ /dev/null @@ -1,36 +0,0 @@ -package jaws - -import ( - "io" - - "github.com/linkdata/jaws/what" -) - -type UiInputText struct { - UiInput - Setter[string] -} - -func (ui *UiInputText) renderStringInput(e *Element, w io.Writer, htmltype string, params ...any) (err error) { - if err = ui.applyGetter(e, ui.Setter); err == nil { - attrs := e.ApplyParams(params) - v := ui.JawsGet(e) - ui.Last.Store(v) - err = WriteHTMLInput(w, e.Jid(), htmltype, v, attrs) - } - return -} - -func (ui *UiInputText) JawsUpdate(e *Element) { - if v := ui.JawsGet(e); ui.Last.Swap(v) != v { - e.SetValue(v) - } -} - -func (ui *UiInputText) JawsEvent(e *Element, wht what.What, val string) (err error) { - err = ErrEventUnhandled - if wht == what.Input { - err = ui.maybeDirty(val, e, ui.Setter.JawsSet(e, val)) - } - return -} diff --git a/jaws/uilabel.go b/jaws/uilabel.go deleted file mode 100644 index b503ab51..00000000 --- a/jaws/uilabel.go +++ /dev/null @@ -1,25 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiLabel struct { - UiHTMLInner -} - -func (ui *UiLabel) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderInner(e, w, "label", "", params) -} - -func NewUiLabel(innerHTML HTMLGetter) *UiLabel { - return &UiLabel{ - UiHTMLInner{ - HTMLGetter: innerHTML, - }, - } -} - -func (rq RequestWriter) Label(innerHTML any, params ...any) error { - return rq.UI(NewUiLabel(MakeHTMLGetter(innerHTML)), params...) -} diff --git a/jaws/uilabel_test.go b/jaws/uilabel_test.go deleted file mode 100644 index db60b725..00000000 --- a/jaws/uilabel_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package jaws - -import ( - "testing" -) - -func TestRequest_Label(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - want := `` - rq.Label("inner") - if got := rq.BodyString(); got != want { - t.Errorf("Request.Label() = %q, want %q", got, want) - } -} diff --git a/jaws/uili.go b/jaws/uili.go deleted file mode 100644 index 3cea94b5..00000000 --- a/jaws/uili.go +++ /dev/null @@ -1,25 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiLi struct { - UiHTMLInner -} - -func (ui *UiLi) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderInner(e, w, "li", "", params) -} - -func NewUiLi(innerHTML HTMLGetter) *UiLi { - return &UiLi{ - UiHTMLInner{ - HTMLGetter: innerHTML, - }, - } -} - -func (rq RequestWriter) Li(innerHTML any, params ...any) error { - return rq.UI(NewUiLi(MakeHTMLGetter(innerHTML)), params...) -} diff --git a/jaws/uili_test.go b/jaws/uili_test.go deleted file mode 100644 index 979f1438..00000000 --- a/jaws/uili_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package jaws - -import ( - "testing" -) - -func TestRequest_Li(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - want := `
  • inner
  • ` - rq.Li("inner") - if got := rq.BodyString(); got != want { - t.Errorf("Request.Li() = %q, want %q", got, want) - } -} diff --git a/jaws/uinumber.go b/jaws/uinumber.go deleted file mode 100644 index 03d9db79..00000000 --- a/jaws/uinumber.go +++ /dev/null @@ -1,25 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiNumber struct { - UiInputFloat -} - -func (ui *UiNumber) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderFloatInput(e, w, "number", params...) -} - -func NewUiNumber(g Setter[float64]) *UiNumber { - return &UiNumber{ - UiInputFloat{ - Setter: g, - }, - } -} - -func (rq RequestWriter) Number(value any, params ...any) error { - return rq.UI(NewUiNumber(makeSetterFloat64(value)), params...) -} diff --git a/jaws/uinumber_test.go b/jaws/uinumber_test.go deleted file mode 100644 index a93a6752..00000000 --- a/jaws/uinumber_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package jaws - -import ( - "errors" - "fmt" - "testing" - - "github.com/linkdata/jaws/what" -) - -func TestRequest_Number(t *testing.T) { - th := newTestHelper(t) - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - ts := newTestSetter(float64(1.2)) - want := fmt.Sprintf(``, ts.Get()) - rq.Number(ts) - if got := rq.BodyString(); got != want { - t.Errorf("Request.Number() = %q, want %q", got, want) - } - - val := float64(2.3) - rq.InCh <- wsMsg{Data: fmt.Sprint(val), Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case <-ts.setCalled: - } - if ts.Get() != val { - t.Error(ts.Get(), "!=", val) - } - select { - case s := <-rq.OutCh: - t.Errorf("%q", s) - default: - } - - val = 3.4 - ts.Set(val) - rq.Dirty(ts) - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != fmt.Sprintf("Value\tJid.1\t\"%v\"\n", val) { - t.Error("wrong Value") - } - } - if ts.Get() != val { - t.Error("not set") - } - if ts.SetCount() != 1 { - t.Error("SetCount", ts.SetCount()) - } - - rq.InCh <- wsMsg{Data: "omg", Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Alert\t\t\"danger\\nstrconv.ParseFloat: parsing "omg": invalid syntax\"\n" { - t.Errorf("wrong Alert: %q", s) - } - } - - ts.err = errors.New("meh") - rq.InCh <- wsMsg{Data: fmt.Sprint(val), Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Alert\t\t\"danger\\nmeh\"\n" { - t.Errorf("wrong Alert: %q", s) - } - } -} diff --git a/jaws/uioption_test.go b/jaws/uioption_test.go deleted file mode 100644 index 363e5e36..00000000 --- a/jaws/uioption_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package jaws - -import ( - "strings" - "testing" -) - -func TestUiOption(t *testing.T) { - th := newTestHelper(t) - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - nba := NewNamedBoolArray() - nb := NewNamedBool(nba, "escape\"me", "", true) - - ui := UiOption{nb} - elem := rq.NewElement(ui) - var sb strings.Builder - if err := ui.JawsRender(elem, &sb, []any{"hidden"}); err != nil { - t.Fatal(err) - } - wantHTML := "" - if gotHTML := sb.String(); gotHTML != wantHTML { - t.Errorf("got %q, want %q", gotHTML, wantHTML) - } - - nb.Set(false) - rq.Dirty(nb) - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "RAttr\tJid.1\t\"selected\"\n" { - t.Errorf("%q", s) - } - } - - nb.Set(true) - rq.Dirty(nb) - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "SAttr\tJid.1\t\"selected\\n\"\n" { - t.Errorf("%q", s) - } - } -} diff --git a/jaws/uipassword.go b/jaws/uipassword.go deleted file mode 100644 index b3d1a287..00000000 --- a/jaws/uipassword.go +++ /dev/null @@ -1,25 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiPassword struct { - UiInputText -} - -func (ui *UiPassword) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderStringInput(e, w, "password", params...) -} - -func NewUiPassword(g Setter[string]) *UiPassword { - return &UiPassword{ - UiInputText{ - Setter: g, - }, - } -} - -func (rq RequestWriter) Password(value any, params ...any) error { - return rq.UI(NewUiPassword(makeSetter[string](value)), params...) -} diff --git a/jaws/uipassword_test.go b/jaws/uipassword_test.go deleted file mode 100644 index 8e42e7d1..00000000 --- a/jaws/uipassword_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package jaws - -import ( - "testing" -) - -func TestRequest_Password(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - ts := newTestSetter("") - want := `` - rq.Password(ts) - if got := rq.BodyString(); got != want { - t.Errorf("Request.Password() = %q, want %q", got, want) - } -} diff --git a/jaws/uiradio.go b/jaws/uiradio.go deleted file mode 100644 index a321a1e7..00000000 --- a/jaws/uiradio.go +++ /dev/null @@ -1,25 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiRadio struct { - UiInputBool -} - -func (ui *UiRadio) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderBoolInput(e, w, "radio", params...) -} - -func NewUiRadio(vp Setter[bool]) *UiRadio { - return &UiRadio{ - UiInputBool{ - Setter: vp, - }, - } -} - -func (rq RequestWriter) Radio(value any, params ...any) error { - return rq.UI(NewUiRadio(makeSetter[bool](value)), params...) -} diff --git a/jaws/uiradio_test.go b/jaws/uiradio_test.go deleted file mode 100644 index c0c40391..00000000 --- a/jaws/uiradio_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package jaws - -import ( - "testing" -) - -func TestRequest_Radio(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - ts := newTestSetter(true) - want := `` - rq.Radio(ts) - if got := rq.BodyString(); got != want { - t.Errorf("Request.Radio() = %q, want %q", got, want) - } -} diff --git a/jaws/uiradiogroup.go b/jaws/uiradiogroup.go deleted file mode 100644 index 1ab03974..00000000 --- a/jaws/uiradiogroup.go +++ /dev/null @@ -1,42 +0,0 @@ -package jaws - -import ( - "html/template" - "strings" -) - -type RadioElement struct { - radio *Element - label *Element - nameAttr string -} - -func (rw RequestWriter) RadioGroup(nba *NamedBoolArray) (rel []RadioElement) { - nameAttr := `name="` + MakeID() + `"` - nba.ReadLocked(func(nbl []*NamedBool) { - for _, nb := range nbl { - rel = append(rel, RadioElement{ - radio: rw.Request().NewElement(NewUiRadio(nb)), - label: rw.Request().NewElement(NewUiLabel(nb)), - nameAttr: nameAttr, - }, - ) - } - }) - return -} - -// Radio renders a HTML input element of type 'radio'. -func (re RadioElement) Radio(params ...any) template.HTML { - var sb strings.Builder - maybePanic(re.radio.JawsRender(&sb, append(params, re.nameAttr))) - return template.HTML(sb.String()) // #nosec G203 -} - -// Label renders a HTML label element. -func (re RadioElement) Label(params ...any) template.HTML { - var sb strings.Builder - forAttr := string(re.radio.Jid().AppendQuote([]byte("for="))) - maybePanic(re.label.JawsRender(&sb, append(params, forAttr))) - return template.HTML(sb.String()) // #nosec G203 -} diff --git a/jaws/uiradiogroup_test.go b/jaws/uiradiogroup_test.go deleted file mode 100644 index 50e9c17d..00000000 --- a/jaws/uiradiogroup_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package jaws - -import ( - "testing" -) - -func TestRequest_RadioGroup(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - nba := NewNamedBoolArray() - nba.Add("1", "one") - rel := rq.RadioGroup(nba) - - wantHTML := "" - gotHTML := string(rel[0].Radio("radioattr")) - if gotHTML != wantHTML { - t.Errorf("got %q, want %q", gotHTML, wantHTML) - } - - wantHTML = "" - gotHTML = string(rel[0].Label("labelattr")) - if gotHTML != wantHTML { - t.Errorf("got %q, want %q", gotHTML, wantHTML) - } -} diff --git a/jaws/uirange.go b/jaws/uirange.go deleted file mode 100644 index b087753a..00000000 --- a/jaws/uirange.go +++ /dev/null @@ -1,25 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiRange struct { - UiInputFloat -} - -func (ui *UiRange) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderFloatInput(e, w, "range", params...) -} - -func NewUiRange(g Setter[float64]) *UiRange { - return &UiRange{ - UiInputFloat{ - Setter: g, - }, - } -} - -func (rq RequestWriter) Range(value any, params ...any) error { - return rq.UI(NewUiRange(makeSetterFloat64(value)), params...) -} diff --git a/jaws/uirange_test.go b/jaws/uirange_test.go deleted file mode 100644 index 8e93beeb..00000000 --- a/jaws/uirange_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package jaws - -import ( - "errors" - "testing" - - "github.com/linkdata/jaws/what" -) - -func TestRequest_Range(t *testing.T) { - th := newTestHelper(t) - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - ts := newTestSetter(float64(1)) - want := `` - rq.Range(ts) - if got := rq.BodyString(); got != want { - t.Errorf("Request.Range() = %q, want %q", got, want) - } - rq.InCh <- wsMsg{Data: "2.1", Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case <-ts.setCalled: - } - if ts.Get() != 2.1 { - t.Error(ts.Get()) - } - select { - case s := <-rq.OutCh: - t.Errorf("%q", s) - default: - } - ts.Set(2.3) - rq.Dirty(ts) - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Value\tJid.1\t\"2.3\"\n" { - t.Error(s) - } - } - if ts.Get() != 2.3 { - t.Error(ts.Get()) - } - if ts.SetCount() != 1 { - t.Error("SetCount", ts.SetCount()) - } - - ts.err = errors.New("meh") - rq.InCh <- wsMsg{Data: "3.4", Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Alert\t\t\"danger\\nmeh\"\n" { - t.Errorf("wrong Alert: %q", s) - } - } - - if ts.Get() != 2.3 { - t.Error(ts.Get()) - } -} diff --git a/jaws/uiregister_test.go b/jaws/uiregister_test.go deleted file mode 100644 index 96242d9d..00000000 --- a/jaws/uiregister_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package jaws - -import ( - "sync/atomic" - "testing" -) - -func TestRequestWriter_Register(t *testing.T) { - th := newTestHelper(t) - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - item := &testUi{} - jid := rq.Register(item) - th.Equal(jid, Jid(1)) - th.Equal(atomic.LoadInt32(&item.updateCalled), int32(1)) - e := rq.GetElementByJid(jid) - th.NoErr(e.JawsRender(nil, nil)) -} diff --git a/jaws/uiselect.go b/jaws/uiselect.go deleted file mode 100644 index 1ca32514..00000000 --- a/jaws/uiselect.go +++ /dev/null @@ -1,40 +0,0 @@ -package jaws - -import ( - "io" - - "github.com/linkdata/jaws/what" -) - -type UiSelect struct { - uiWrapContainer -} - -func NewUiSelect(sh SelectHandler) *UiSelect { - return &UiSelect{ - uiWrapContainer: uiWrapContainer{ - Container: sh, - }, - } -} - -func (ui *UiSelect) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderContainer(e, w, "select", params) -} - -func (ui *UiSelect) JawsUpdate(e *Element) { - e.SetValue(ui.uiWrapContainer.Container.(Getter[string]).JawsGet(e)) - ui.uiWrapContainer.JawsUpdate(e) -} - -func (ui *UiSelect) JawsEvent(e *Element, wht what.What, val string) (err error) { - err = ErrEventUnhandled - if wht == what.Input { - _, err = e.maybeDirty(ui.Tag, ui.uiWrapContainer.Container.(Setter[string]).JawsSet(e, val)) - } - return -} - -func (rq RequestWriter) Select(sh SelectHandler, params ...any) error { - return rq.UI(NewUiSelect(sh), params...) -} diff --git a/jaws/uiselect_test.go b/jaws/uiselect_test.go deleted file mode 100644 index 521fcdea..00000000 --- a/jaws/uiselect_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package jaws - -import ( - "errors" - "testing" - - "github.com/linkdata/deadlock" - "github.com/linkdata/jaws/what" -) - -type testNamedBoolArray struct { - mu deadlock.Mutex - setCount int - setCalled chan struct{} - err error - *NamedBoolArray -} - -func (ts *testNamedBoolArray) JawsSet(e *Element, val string) (err error) { - ts.mu.Lock() - defer ts.mu.Unlock() - if err = ts.err; err == nil { - err = ts.NamedBoolArray.JawsSet(e, val) - ts.setCount++ - if ts.setCount == 1 { - close(ts.setCalled) - } - } - return -} - -func TestRequest_Select(t *testing.T) { - th := newTestHelper(t) - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - a := &testNamedBoolArray{ - setCalled: make(chan struct{}), - NamedBoolArray: NewNamedBoolArray(), - } - a.Add("1", "one") - a.Add("2", "two") - a.Set("1", true) - - wantHTML := "" - rq.Select(a, "disabled") - if gotHTML := rq.BodyString(); gotHTML != wantHTML { - t.Errorf("Request.Select() = %q, want %q", gotHTML, wantHTML) - } - - if !a.IsChecked("1") { - t.Error("1 is not checked") - } - if a.IsChecked("2") { - t.Error("2 is checked") - } - - rq.InCh <- wsMsg{Data: "2", Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case <-a.setCalled: - } - - if a.IsChecked("1") { - t.Error("1 is checked") - } - if !a.IsChecked("2") { - t.Error("2 is not checked") - } - - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Value\tJid.1\t\"2\"\n" { - t.Errorf("wrong Value %q", s) - } - } - - a.Set("2", false) - rq.Dirty(a) - - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Value\tJid.1\t\"\"\n" { - t.Errorf("wrong Value %q", s) - } - } - - if a.IsChecked("1") { - t.Error("1 is checked") - } - if a.IsChecked("2") { - t.Error("2 is checked") - } - - a.mu.Lock() - a.err = errors.New("meh") - a.mu.Unlock() - rq.InCh <- wsMsg{Data: "1", Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Alert\t\t\"danger\\nmeh\"\n" { - t.Errorf("wrong Alert: %q", s) - select { - case msg := <-rq.OutCh: - s := msg.Format() - t.Errorf("queued msg: %q", s) - default: - } - } - } - - if a.IsChecked("1") { - t.Error("1 is checked") - } - if a.IsChecked("2") { - t.Error("2 is checked") - } -} diff --git a/jaws/uispan.go b/jaws/uispan.go deleted file mode 100644 index 394cd4b6..00000000 --- a/jaws/uispan.go +++ /dev/null @@ -1,25 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiSpan struct { - UiHTMLInner -} - -func (ui *UiSpan) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderInner(e, w, "span", "", params) -} - -func NewUiSpan(innerHTML HTMLGetter) *UiSpan { - return &UiSpan{ - UiHTMLInner{ - HTMLGetter: innerHTML, - }, - } -} - -func (rq RequestWriter) Span(innerHTML any, params ...any) error { - return rq.UI(NewUiSpan(MakeHTMLGetter(innerHTML)), params...) -} diff --git a/jaws/uispan_test.go b/jaws/uispan_test.go deleted file mode 100644 index c16551ac..00000000 --- a/jaws/uispan_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package jaws - -import ( - "testing" -) - -func TestRequest_Span(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - want := `inner` - rq.Span("inner") - if got := rq.BodyString(); got != want { - t.Errorf("Request.Span() = %q, want %q", got, want) - } -} diff --git a/jaws/uitbody.go b/jaws/uitbody.go deleted file mode 100644 index c8c03742..00000000 --- a/jaws/uitbody.go +++ /dev/null @@ -1,25 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiTbody struct { - uiWrapContainer -} - -func NewUiTbody(c Container) *UiTbody { - return &UiTbody{ - uiWrapContainer{ - Container: c, - }, - } -} - -func (ui *UiTbody) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderContainer(e, w, "tbody", params) -} - -func (rq RequestWriter) Tbody(c Container, params ...any) error { - return rq.UI(NewUiTbody(c), params...) -} diff --git a/jaws/uitbody_test.go b/jaws/uitbody_test.go deleted file mode 100644 index b64d4fc4..00000000 --- a/jaws/uitbody_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package jaws - -import ( - "testing" -) - -func TestRequest_Tbody(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - want := `` - rq.Tbody(&testContainer{}) - if got := rq.BodyString(); got != want { - t.Errorf("Request.Span() = %q, want %q", got, want) - } -} diff --git a/jaws/uitd.go b/jaws/uitd.go deleted file mode 100644 index 6c730b96..00000000 --- a/jaws/uitd.go +++ /dev/null @@ -1,25 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiTd struct { - UiHTMLInner -} - -func (ui *UiTd) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderInner(e, w, "td", "", params) -} - -func NewUiTd(innerHTML HTMLGetter) *UiTd { - return &UiTd{ - UiHTMLInner{ - HTMLGetter: innerHTML, - }, - } -} - -func (rq RequestWriter) Td(innerHTML any, params ...any) error { - return rq.UI(NewUiTd(MakeHTMLGetter(innerHTML)), params...) -} diff --git a/jaws/uitd_test.go b/jaws/uitd_test.go deleted file mode 100644 index 952effcc..00000000 --- a/jaws/uitd_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package jaws - -import ( - "testing" -) - -func TestRequest_Td(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - want := `inner` - rq.Td("inner") - if got := rq.BodyString(); got != want { - t.Errorf("Request.Td() = %q, want %q", got, want) - } -} diff --git a/jaws/uitext.go b/jaws/uitext.go deleted file mode 100644 index 9ce4a580..00000000 --- a/jaws/uitext.go +++ /dev/null @@ -1,25 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiText struct { - UiInputText -} - -func (ui *UiText) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderStringInput(e, w, "text", params...) -} - -func NewUiText(vp Setter[string]) (ui *UiText) { - return &UiText{ - UiInputText{ - Setter: vp, - }, - } -} - -func (rq RequestWriter) Text(value any, params ...any) error { - return rq.UI(NewUiText(makeSetter[string](value)), params...) -} diff --git a/jaws/uitext_test.go b/jaws/uitext_test.go deleted file mode 100644 index 17263816..00000000 --- a/jaws/uitext_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package jaws - -import ( - "errors" - "testing" - - "github.com/linkdata/jaws/what" -) - -func TestRequest_Text(t *testing.T) { - th := newTestHelper(t) - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - ss := newTestSetter("foo") - want := `` - rq.Text(ss) - if got := rq.BodyString(); got != want { - t.Errorf("Request.Text() = %q, want %q", got, want) - } - rq.InCh <- wsMsg{Data: "bar", Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case <-ss.setCalled: - } - if ss.Get() != "bar" { - t.Error(ss.Get()) - } - select { - case s := <-rq.OutCh: - t.Errorf("%q", s) - default: - } - ss.Set("quux") - rq.Dirty(ss) - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Value\tJid.1\t\"quux\"\n" { - t.Error("wrong Value") - } - } - if ss.Get() != "quux" { - t.Error("not quux") - } - if ss.SetCount() != 1 { - t.Error("SetCount", ss.SetCount()) - } - - ss.err = errors.New("meh") - rq.InCh <- wsMsg{Data: "omg", Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Alert\t\t\"danger\\nmeh\"\n" { - t.Errorf("wrong Alert: %q", s) - } - } - - if ss.Get() != "quux" { - t.Error("unexpected change", ss.Get()) - } -} diff --git a/jaws/uitextarea.go b/jaws/uitextarea.go deleted file mode 100644 index 0603ec6e..00000000 --- a/jaws/uitextarea.go +++ /dev/null @@ -1,34 +0,0 @@ -package jaws - -import ( - "html/template" - "io" -) - -type UiTextarea struct { - UiInputText -} - -func (ui *UiTextarea) JawsRender(e *Element, w io.Writer, params []any) (err error) { - if err = ui.applyGetter(e, ui.Setter); err == nil { - attrs := e.ApplyParams(params) - err = WriteHTMLInner(w, e.Jid(), "textarea", "", template.HTML(ui.JawsGet(e)), attrs...) // #nosec G203 - } - return -} - -func (ui *UiTextarea) JawsUpdate(e *Element) { - e.SetValue(ui.JawsGet(e)) -} - -func NewUiTextarea(g Setter[string]) (ui *UiTextarea) { - return &UiTextarea{ - UiInputText{ - Setter: g, - }, - } -} - -func (rq RequestWriter) Textarea(value any, params ...any) error { - return rq.UI(NewUiTextarea(makeSetter[string](value)), params...) -} diff --git a/jaws/uitextarea_test.go b/jaws/uitextarea_test.go deleted file mode 100644 index fe4ef77d..00000000 --- a/jaws/uitextarea_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package jaws - -import ( - "testing" - - "github.com/linkdata/jaws/what" -) - -func TestRequest_Textarea(t *testing.T) { - th := newTestHelper(t) - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - ss := newTestSetter("foo") - want := `` - rq.Textarea(ss) - if got := rq.BodyString(); got != want { - t.Errorf("Request.Textarea() = %q, want %q", got, want) - } - rq.InCh <- wsMsg{Data: "bar", Jid: 1, What: what.Input} - select { - case <-th.C: - th.Timeout() - case <-ss.setCalled: - } - if ss.Get() != "bar" { - t.Fail() - } - select { - case s := <-rq.OutCh: - t.Errorf("%q", s) - default: - } - ss.Set("quux") - rq.Dirty(ss) - select { - case <-th.C: - th.Timeout() - case msg := <-rq.OutCh: - s := msg.Format() - if s != "Value\tJid.1\t\"quux\"\n" { - t.Fail() - } - } - if ss.Get() != "quux" { - t.Fail() - } - if ss.SetCount() != 1 { - t.Fail() - } -} diff --git a/jaws/uitr.go b/jaws/uitr.go deleted file mode 100644 index 47ea4534..00000000 --- a/jaws/uitr.go +++ /dev/null @@ -1,25 +0,0 @@ -package jaws - -import ( - "io" -) - -type UiTr struct { - UiHTMLInner -} - -func (ui *UiTr) JawsRender(e *Element, w io.Writer, params []any) error { - return ui.renderInner(e, w, "tr", "", params) -} - -func NewUiTr(innerHTML HTMLGetter) *UiTr { - return &UiTr{ - UiHTMLInner{ - HTMLGetter: innerHTML, - }, - } -} - -func (rq RequestWriter) Tr(innerHTML any, params ...any) error { - return rq.UI(NewUiTr(MakeHTMLGetter(innerHTML)), params...) -} diff --git a/jaws/uitr_test.go b/jaws/uitr_test.go deleted file mode 100644 index 1be3f270..00000000 --- a/jaws/uitr_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package jaws - -import ( - "testing" -) - -func TestRequest_Tr(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - want := `inner` - rq.Tr("inner") - if got := rq.BodyString(); got != want { - t.Errorf("Request.Tr() = %q, want %q", got, want) - } -} diff --git a/jaws/uiwrapcontainer.go b/jaws/uiwrapcontainer.go deleted file mode 100644 index 9fab47b7..00000000 --- a/jaws/uiwrapcontainer.go +++ /dev/null @@ -1,99 +0,0 @@ -package jaws - -import ( - "html/template" - "io" - "slices" - "strings" - - "github.com/linkdata/deadlock" -) - -type uiWrapContainer struct { - Container - Tag any - mu deadlock.Mutex - contents []*Element -} - -func (ui *uiWrapContainer) renderContainer(e *Element, w io.Writer, outerhtmltag string, params []any) (err error) { - if ui.Tag, err = e.ApplyGetter(ui.Container); err == nil { - attrs := e.ApplyParams(params) - b := e.Jid().AppendStartTagAttr(nil, outerhtmltag) - for _, attr := range attrs { - b = append(b, ' ') - b = append(b, attr...) - } - b = append(b, '>') - _, err = w.Write(b) - if err == nil { - var contents []*Element - for _, cui := range ui.Container.JawsContains(e) { - elem := e.Request.NewElement(cui) - if err = elem.JawsRender(w, nil); err != nil { - break - } - contents = append(contents, elem) - } - ui.mu.Lock() - ui.contents = contents - ui.mu.Unlock() - b = b[:0] - b = append(b, "') - if _, err2 := w.Write(b); err == nil { - err = err2 - } - } - } - return -} - -func (ui *uiWrapContainer) JawsUpdate(e *Element) { - var toRemove, toAppend []*Element - var orderData []Jid - - oldMap := make(map[UI]*Element) - newMap := make(map[UI]struct{}) - newContents := ui.Container.JawsContains(e) - for _, t := range newContents { - newMap[t] = struct{}{} - } - - ui.mu.Lock() - oldOrder := make([]Jid, len(ui.contents)) - for i, elem := range ui.contents { - oldOrder[i] = elem.Jid() - oldMap[elem.Ui()] = elem - if _, ok := newMap[elem.Ui()]; !ok { - toRemove = append(toRemove, elem) - } - } - ui.contents = ui.contents[:0] - for _, cui := range newContents { - var elem *Element - if elem = oldMap[cui]; elem == nil { - elem = e.Request.NewElement(cui) - toAppend = append(toAppend, elem) - } - ui.contents = append(ui.contents, elem) - orderData = append(orderData, elem.Jid()) - } - ui.mu.Unlock() - - for _, elem := range toRemove { - e.Remove(elem.Jid().String()) - e.Request.deleteElement(elem) - } - - for _, elem := range toAppend { - var sb strings.Builder - maybePanic(elem.JawsRender(&sb, nil)) - e.Append(template.HTML(sb.String())) // #nosec G203 - } - - if !slices.Equal(oldOrder, orderData) { - e.Order(orderData) - } -} diff --git a/jaws/uiwrapcontainer_test.go b/jaws/uiwrapcontainer_test.go deleted file mode 100644 index fe98a3fa..00000000 --- a/jaws/uiwrapcontainer_test.go +++ /dev/null @@ -1,153 +0,0 @@ -package jaws - -import ( - "errors" - "io" - "strings" - "testing" -) - -func TestUiWrapContainer_RenderError(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - // Create a test UI that returns an error when rendering - renderErr := errors.New("render error") - errorUI := &testUi{ - renderFn: func(e *Element, w io.Writer, params []any) error { - // Write partial content before returning error - _, _ = w.Write([]byte("partial")) - return renderErr - }, - } - - // Create a container with a successful UI followed by an error UI - tc := &testContainer{ - contents: []UI{ - NewUiSpan(testHTMLGetter("first")), - errorUI, - NewUiSpan(testHTMLGetter("third")), // This should not be rendered - }, - } - - ui := NewUiContainer("div", tc) - elem := rq.NewElement(ui) - var sb strings.Builder - - // Render should return the error - err := ui.JawsRender(elem, &sb, nil) - if err == nil { - t.Fatal("expected error from JawsRender, got nil") - } - if !errors.Is(err, renderErr) { - t.Errorf("expected error %v, got %v", renderErr, err) - } - - // Verify the output contains the opening tag and first successful element - output := sb.String() - if !strings.Contains(output, `
    `) { - t.Errorf("expected opening tag in output, got: %s", output) - } - if !strings.Contains(output, `first`) { - t.Errorf("expected first span in output, got: %s", output) - } - if !strings.Contains(output, "partial") { - t.Errorf("expected partial content from error UI, got: %s", output) - } - - // The third element should not be rendered - if strings.Contains(output, "third") { - t.Errorf("third element should not be rendered, got: %s", output) - } - - // Verify that ui.contents contains only the successfully rendered elements - ui.mu.Lock() - contentsLen := len(ui.contents) - ui.mu.Unlock() - - if contentsLen != 1 { - t.Errorf("expected ui.contents to have 1 element (only first successful), got %d", contentsLen) - } -} - -func TestUiWrapContainer_RenderErrorFirstElement(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - renderErr := errors.New("immediate error") - errorUI := &testUi{ - renderFn: func(e *Element, w io.Writer, params []any) error { - return renderErr - }, - } - - // Container with only an error-producing UI as the first element - tc := &testContainer{ - contents: []UI{errorUI}, - } - - ui := NewUiContainer("div", tc) - elem := rq.NewElement(ui) - var sb strings.Builder - - // Render should return the error immediately - err := ui.JawsRender(elem, &sb, nil) - if err == nil { - t.Fatal("expected error from JawsRender, got nil") - } - if !errors.Is(err, renderErr) { - t.Errorf("expected error %v, got %v", renderErr, err) - } - - // Verify ui.contents is empty since no elements were successfully rendered - ui.mu.Lock() - contentsLen := len(ui.contents) - ui.mu.Unlock() - - if contentsLen != 0 { - t.Errorf("expected ui.contents to be empty, got %d elements", contentsLen) - } -} - -func TestUiWrapContainer_RenderAllSuccess(t *testing.T) { - nextJid = 0 - rq := newTestRequest(t) - defer rq.Close() - - // Container with all successful renders - tc := &testContainer{ - contents: []UI{ - NewUiSpan(testHTMLGetter("first")), - NewUiSpan(testHTMLGetter("second")), - NewUiSpan(testHTMLGetter("third")), - }, - } - - ui := NewUiContainer("div", tc) - elem := rq.NewElement(ui) - var sb strings.Builder - - // Render should succeed - err := ui.JawsRender(elem, &sb, nil) - if err != nil { - t.Fatalf("unexpected error from JawsRender: %v", err) - } - - // Verify all elements are rendered - output := sb.String() - expected := `
    firstsecondthird
    ` - if output != expected { - t.Errorf("output mismatch\nwant: %s\ngot: %s", expected, output) - } - - // Verify ui.contents has all three elements - ui.mu.Lock() - contentsLen := len(ui.contents) - ui.mu.Unlock() - - if contentsLen != 3 { - t.Errorf("expected ui.contents to have 3 elements, got %d", contentsLen) - } -} diff --git a/jaws_test.go b/jaws_test.go index b6e47eb8..a7c06d8d 100644 --- a/jaws_test.go +++ b/jaws_test.go @@ -1,7 +1,6 @@ package jaws_test -// this is just to satisfy coverage, -// proper tests are in jaws/jaws +// just to satisfy test coverage import ( "html/template" @@ -13,6 +12,7 @@ import ( "time" "github.com/linkdata/jaws" + "github.com/linkdata/jaws/ui" ) const testPageTmplText = "({{with .Dot}}" + @@ -108,7 +108,7 @@ func TestNewTemplate(t *testing.T) { rq := jw.NewRequest(hr) jw.UseRequest(rq.JawsKey, hr) var sb strings.Builder - rqwr := rq.Writer(&sb) + rqwr := ui.RequestWriter{Request: rq, Writer: &sb} var mu sync.RWMutex vbool := true @@ -196,15 +196,3 @@ func TestNewUi(t *testing.T) { jaws.NewUiText(jaws.Bind(&mu, &vstring)) jaws.NewUiTr(htmlGetter) } - -func TestNewTestRequest(t *testing.T) { - jw, err := jaws.New() - maybeFatal(t, err) - defer jw.Close() - go jw.Serve() - if tr := jaws.NewTestRequest(jw, nil); tr == nil { - t.Fatal("got nil") - } else { - tr.Close() - } -} diff --git a/jawsboot/README.md b/jawsboot/README.md index 84517d7b..5861d9ca 100644 --- a/jawsboot/README.md +++ b/jawsboot/README.md @@ -18,6 +18,7 @@ import ( "github.com/linkdata/jaws/jawsboot" "github.com/linkdata/jaws/staticserve" "github.com/linkdata/jaws/templatereloader" + "github.com/linkdata/jaws/ui" ) //go:embed assets @@ -30,7 +31,7 @@ func setupJaws(jw *jaws.Jaws, mux *http.ServeMux) (err error) { jw.AddTemplateLookuper(tmpl) // Initialize jawsboot, we will serve the Javascript and CSS from /static/*.[js|css] // All files under assets/static will be available under /static. Any favicon loaded - // this way will have it's URL available using jaws.FaviconURL(). + // this way will have its URL available using jaws.FaviconURL(). if err = jw.Setup(mux.Handle, "/static", jawsboot.Setup, staticserve.MustNewFS(assetsFS, "assets/static", "images/favicon.png"), @@ -38,7 +39,7 @@ func setupJaws(jw *jaws.Jaws, mux *http.ServeMux) (err error) { // Add a route to our index template with a bound variable accessible as '.Dot' in the template var mu sync.Mutex var f float64 - mux.Handle("/", jw.Handler("index.html", jaws.Bind(&mu, &f))) + mux.Handle("/", ui.Handler(jw, "index.html", jaws.Bind(&mu, &f))) } } return diff --git a/jawsboot/example_test.go b/jawsboot/example_test.go index 2ea55174..595e0f8c 100644 --- a/jawsboot/example_test.go +++ b/jawsboot/example_test.go @@ -10,6 +10,7 @@ import ( "github.com/linkdata/jaws/jawsboot" "github.com/linkdata/jaws/staticserve" "github.com/linkdata/jaws/templatereloader" + "github.com/linkdata/jaws/ui" ) // This example assumes an 'assets' directory: @@ -39,7 +40,7 @@ func setupJaws(jw *jaws.Jaws, mux *http.ServeMux) (err error) { // Add a route to our index template with a bound variable accessible as '.Dot' in the template var mu sync.Mutex var f float64 - mux.Handle("/", jw.Handler("index.html", jaws.Bind(&mu, &f))) + mux.Handle("/", ui.Handler(jw, "index.html", jaws.Bind(&mu, &f))) } } return diff --git a/jawsboot/jawsboot_test.go b/jawsboot/jawsboot_test.go index 5cf7b486..3aaa5e0d 100644 --- a/jawsboot/jawsboot_test.go +++ b/jawsboot/jawsboot_test.go @@ -7,6 +7,7 @@ import ( "github.com/linkdata/jaws" "github.com/linkdata/jaws/jawsboot" + "github.com/linkdata/jaws/ui" ) func TestJawsBoot_Setup(t *testing.T) { @@ -24,7 +25,7 @@ func TestJawsBoot_Setup(t *testing.T) { rq := jw.NewRequest(nil) var sb strings.Builder - rq.Writer(&sb).HeadHTML() + ui.RequestWriter{Request: rq, Writer: &sb}.HeadHTML() txt := sb.String() if !strings.Contains(txt, rq.JawsKeyString()) { t.Error(txt) diff --git a/jawstest/README.md b/jawstest/README.md new file mode 100644 index 00000000..60cb5ebc --- /dev/null +++ b/jawstest/README.md @@ -0,0 +1 @@ +Integration tests \ No newline at end of file diff --git a/jaws/handler_test.go b/jawstest/handler_test.go similarity index 62% rename from jaws/handler_test.go rename to jawstest/handler_test.go index 1c4d7a65..7d718ee9 100644 --- a/jaws/handler_test.go +++ b/jawstest/handler_test.go @@ -1,18 +1,22 @@ -package jaws +package jawstest import ( "bytes" "net/http/httptest" "testing" + + "github.com/linkdata/jaws" + "github.com/linkdata/jaws/core" + "github.com/linkdata/jaws/ui" ) func TestHandler_ServeHTTP(t *testing.T) { - nextJid = 0 + core.NextJid = 0 rq := newTestRequest(t) defer rq.Close() - dot := Tag("123") - h := rq.Jaws.Handler("testtemplate", dot) + dot := jaws.Tag("123") + h := ui.Handler(rq.TestRequest.Request.Jaws, "testtemplate", dot) var buf bytes.Buffer var rr httptest.ResponseRecorder rr.Body = &buf diff --git a/jawstest/jawsjaws_test.go b/jawstest/jawsjaws_test.go new file mode 100644 index 00000000..b87fc92b --- /dev/null +++ b/jawstest/jawsjaws_test.go @@ -0,0 +1,95 @@ +package jawstest + +import ( + "html/template" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/linkdata/jaws" +) + +func TestJaws_RequestLifecycle(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + + hr := httptest.NewRequest(http.MethodGet, "/", nil) + rq := jw.NewRequest(hr) + if rq == nil { + t.Fatal("nil request") + } + if jw.RequestCount() != 1 { + t.Fatalf("unexpected request count: %d", jw.RequestCount()) + } + + if got := jw.UseRequest(0, hr); got != nil { + t.Fatal("expected nil for invalid key") + } + if got := jw.UseRequest(rq.JawsKey, hr); got != rq { + t.Fatal("expected claimed request") + } + if got := jw.UseRequest(rq.JawsKey, hr); got != nil { + t.Fatal("expected nil for already-claimed request") + } +} + +func TestJaws_TemplateLookupers(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + + tmpl := template.Must(template.New("it").Parse(`ok`)) + jw.AddTemplateLookuper(tmpl) + if got := jw.LookupTemplate("it"); got == nil { + t.Fatal("expected template") + } + jw.RemoveTemplateLookuper(tmpl) + if got := jw.LookupTemplate("it"); got != nil { + t.Fatal("expected template removed") + } +} + +func TestNewTestRequestHarness(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + go jw.Serve() + + rq := NewTestRequest(jw, nil) + if rq == nil { + t.Fatal("nil test request") + } + defer rq.Close() + + if rq.Initial() == nil { + t.Fatal("expected initial request") + } + + if err := rq.Template("missingtemplate", nil); err == nil { + t.Fatal("expected missing template error") + } +} + +func TestRequestWriterHelpersFromTemplate(t *testing.T) { + tj := newTestJaws() + defer tj.Close() + + tj.AddTemplateLookuper(template.Must(template.New("rwhelper").Parse(`{{$.Span "ok"}}`))) + rq := tj.newRequest(nil) + defer rq.Close() + + if err := rq.Template("rwhelper", nil); err != nil { + t.Fatal(err) + } + if got := rq.BodyString(); !strings.Contains(got, `{{.}}
    {{end}}`)) + tj.AddTemplateLookuper(tj.testtmpl) + go tj.Serve() + return +} + +func (tj *testJaws) newRequest(hr *http.Request) (tr *TestRequest) { + return NewTestRequest(tj.Jaws, hr) +} + +func newTestRequest(t *testing.T) (tr *TestRequest) { + tj := newTestJaws() + if t != nil { + t.Helper() + t.Cleanup(tj.Close) + } + return NewTestRequest(tj.Jaws, nil) +} + +type testAuth struct{} + +func (testAuth) Data() map[string]any { return nil } +func (testAuth) Email() string { return "" } +func (testAuth) IsAdmin() bool { return true } diff --git a/jawstest/testrequest.go b/jawstest/testrequest.go new file mode 100644 index 00000000..fbf521b9 --- /dev/null +++ b/jawstest/testrequest.go @@ -0,0 +1,42 @@ +package jawstest + +import ( + "net/http" + + "github.com/linkdata/jaws" + "github.com/linkdata/jaws/ui" +) + +// TestRequest wraps jaws.TestRequest with ui.RequestWriter helpers. +type TestRequest struct { + *jaws.TestRequest + rw ui.RequestWriter +} + +// NewTestRequest forwards to jaws.NewTestRequest. +func NewTestRequest(jw *jaws.Jaws, hr *http.Request) *TestRequest { + tr := jaws.NewTestRequest(jw, hr) + return &TestRequest{ + TestRequest: tr, + rw: ui.RequestWriter{ + Request: tr.Request, + Writer: tr.ResponseRecorder, + }, + } +} + +func (tr *TestRequest) UI(widget jaws.UI, params ...any) error { + return tr.rw.UI(widget, params...) +} + +func (tr *TestRequest) Template(name string, dot any, params ...any) error { + return tr.rw.Template(name, dot, params...) +} + +func (tr *TestRequest) JsVar(name string, jsvar any, params ...any) error { + return tr.rw.JsVar(name, jsvar, params...) +} + +func (tr *TestRequest) Register(updater jaws.Updater, params ...any) jaws.Jid { + return tr.rw.Register(updater, params...) +} diff --git a/jawstest/testrequest_test.go b/jawstest/testrequest_test.go new file mode 100644 index 00000000..0ef863c2 --- /dev/null +++ b/jawstest/testrequest_test.go @@ -0,0 +1,41 @@ +package jawstest + +import ( + "strings" + "testing" + + "github.com/linkdata/jaws" + "github.com/linkdata/jaws/ui" +) + +type testRequestUpdater struct { + called int +} + +func (u *testRequestUpdater) JawsUpdate(*jaws.Element) { + u.called++ +} + +func TestTestRequest_WrapperUIAndRegister(t *testing.T) { + tj := newTestJaws() + defer tj.Close() + + rq := tj.newRequest(nil) + defer rq.Close() + + if err := rq.UI(ui.NewSpan(jaws.MakeHTMLGetter("ok"))); err != nil { + t.Fatal(err) + } + if got := rq.BodyString(); !strings.Contains(got, ` `ui.A` +- `jaws.NewUiA(...)` -> `ui.NewA(...)` +- `jaws.UiSpan` -> `ui.Span` +- `jaws.NewUiSpan(...)` -> `ui.NewSpan(...)` +- `jaws.UiSelect` -> `ui.Select` +- `jaws.NewUiSelect(...)` -> `ui.NewSelect(...)` + +### RequestWriter helper calls + +`ui.RequestWriter` exposes helper methods like `rw.Span(...)`, +`rw.Text(...)`, and `rw.Select(...)` for concise template use. + +You can also use explicit constructors through: + +```go +rw.UI(ui.NewX(...), params...) +``` + +Examples: + +```go +rw.UI(ui.NewDiv(jaws.MakeHTMLGetter("content"))) +rw.UI(ui.NewCheckbox(myBoolSetter), "disabled") +rw.UI(ui.NewRange(myFloatSetter)) +``` + +## Building blocks + +- `HTMLInner` + - For tags like `
    ...
    `, `...`, `...`. +- `Input`, `InputText`, `InputBool`, `InputFloat`, `InputDate` + - For interactive inputs with typed parse/update behavior. +- `WrapContainer` + - For widgets that render and maintain dynamic child lists. + +## Adding a simple static widget + +Use `HTMLInner`: + +```go +type Article struct{ ui.HTMLInner } + +func NewArticle(inner jaws.HTMLGetter) *Article { + return &Article{HTMLInner: ui.HTMLInner{HTMLGetter: inner}} +} + +func (w *Article) JawsRender(e *jaws.Element, wr io.Writer, params []any) error { + return w.renderInner(e, wr, "article", "", params) +} +``` + +## Adding an interactive input widget + +Use one of the typed input bases: + +- `InputText` for string-based inputs +- `InputBool` for boolean inputs +- `InputFloat` for numeric inputs +- `InputDate` for `time.Time` inputs + +Each base handles: + +- tracking last rendered value +- receiving `what.Input` +- applying dirty tags on successful set +- update-driven `SetValue` pushes + +## Adding a container widget + +Use `WrapContainer`: + +```go +type UList struct{ ui.WrapContainer } + +func NewUList(c jaws.Container) *UList { + return &UList{WrapContainer: ui.NewWrapContainer(c)} +} + +func (w *UList) JawsRender(e *jaws.Element, wr io.Writer, params []any) error { + return w.RenderContainer(e, wr, "ul", params) +} + +func (w *UList) JawsUpdate(e *jaws.Element) { + w.UpdateContainer(e) +} +``` diff --git a/ui/a.go b/ui/a.go new file mode 100644 index 00000000..326cc885 --- /dev/null +++ b/ui/a.go @@ -0,0 +1,18 @@ +package ui + +import ( + "io" + + "github.com/linkdata/jaws/core" +) + +type A struct{ HTMLInner } + +func NewA(innerHTML core.HTMLGetter) *A { return &A{HTMLInner{HTMLGetter: innerHTML}} } +func (rw RequestWriter) A(innerHTML any, params ...any) error { + return rw.UI(NewA(core.MakeHTMLGetter(innerHTML)), params...) +} + +func (ui *A) JawsRender(e *core.Element, w io.Writer, params []any) error { + return ui.renderInner(e, w, "a", "", params) +} diff --git a/ui/button.go b/ui/button.go new file mode 100644 index 00000000..90721ec8 --- /dev/null +++ b/ui/button.go @@ -0,0 +1,18 @@ +package ui + +import ( + "io" + + "github.com/linkdata/jaws/core" +) + +type Button struct{ HTMLInner } + +func NewButton(innerHTML core.HTMLGetter) *Button { return &Button{HTMLInner{HTMLGetter: innerHTML}} } +func (rw RequestWriter) Button(innerHTML any, params ...any) error { + return rw.UI(NewButton(core.MakeHTMLGetter(innerHTML)), params...) +} + +func (ui *Button) JawsRender(e *core.Element, w io.Writer, params []any) error { + return ui.renderInner(e, w, "button", "button", params) +} diff --git a/ui/checkbox.go b/ui/checkbox.go new file mode 100644 index 00000000..dd12a39e --- /dev/null +++ b/ui/checkbox.go @@ -0,0 +1,18 @@ +package ui + +import ( + "io" + + "github.com/linkdata/jaws/core" +) + +type Checkbox struct{ InputBool } + +func NewCheckbox(g core.Setter[bool]) *Checkbox { return &Checkbox{InputBool{Setter: g}} } +func (rw RequestWriter) Checkbox(value any, params ...any) error { + return rw.UI(NewCheckbox(core.MakeSetter[bool](value)), params...) +} + +func (ui *Checkbox) JawsRender(e *core.Element, w io.Writer, params []any) error { + return ui.renderBoolInput(e, w, "checkbox", params...) +} diff --git a/ui/common.go b/ui/common.go new file mode 100644 index 00000000..ee99da29 --- /dev/null +++ b/ui/common.go @@ -0,0 +1,21 @@ +package ui + +import "github.com/linkdata/jaws/core" + +func must(err error) { + if err != nil { + panic(err) + } +} + +func applyDirty(tag any, e *core.Element, err error) (changed bool, retErr error) { + switch err { + case nil: + e.Dirty(tag) + return true, nil + case core.ErrValueUnchanged: + return false, nil + default: + return false, err + } +} diff --git a/ui/common_test.go b/ui/common_test.go new file mode 100644 index 00000000..f3da675e --- /dev/null +++ b/ui/common_test.go @@ -0,0 +1,40 @@ +package ui + +import ( + "errors" + "testing" + + "github.com/linkdata/jaws/core" +) + +func TestCommon_applyDirty(t *testing.T) { + _, rq := newRequest(t) + elem, _ := renderUI(t, rq, NewSpan(testHTMLGetter("x"))) + tag := &struct{}{} + + changed, err := applyDirty(tag, elem, nil) + if err != nil || !changed { + t.Fatalf("want changed,nil got %v,%v", changed, err) + } + + changed, err = applyDirty(tag, elem, core.ErrValueUnchanged) + if err != nil || changed { + t.Fatalf("want unchanged,nil got %v,%v", changed, err) + } + + wantErr := errors.New("boom") + changed, err = applyDirty(tag, elem, wantErr) + if !errors.Is(err, wantErr) || changed { + t.Fatalf("want unchanged,%v got %v,%v", wantErr, changed, err) + } +} + +func TestCommon_must(t *testing.T) { + must(nil) + defer func() { + if recover() == nil { + t.Fatal("expected panic") + } + }() + must(errors.New("panic")) +} diff --git a/ui/constructors_test.go b/ui/constructors_test.go new file mode 100644 index 00000000..72ae4223 --- /dev/null +++ b/ui/constructors_test.go @@ -0,0 +1,56 @@ +package ui + +import ( + "html/template" + "sync" + "testing" + "time" + + "github.com/linkdata/jaws/core" +) + +func TestConstructors(t *testing.T) { + var mu sync.Mutex + txt := "" + checked := false + num := 0.0 + when := time.Now() + + textSetter := core.Bind(&mu, &txt) + boolSetter := core.Bind(&mu, &checked) + numSetter := core.Bind(&mu, &num) + timeSetter := core.Bind(&mu, &when) + + htmlGetter := core.MakeHTMLGetter("x") + imgGetter := core.StringGetterFunc(func(*core.Element) string { return "img" }) + nba := core.NewNamedBoolArray().Add("a", template.HTML("A")) + tc := testContainer{contents: []core.UI{NewSpan(htmlGetter)}} + + all := []core.UI{ + NewA(htmlGetter), + NewButton(htmlGetter), + NewCheckbox(boolSetter), + NewContainer("div", &tc), + NewDate(timeSetter), + NewDiv(htmlGetter), + NewImg(imgGetter), + NewLabel(htmlGetter), + NewLi(htmlGetter), + NewNumber(numSetter), + NewPassword(textSetter), + NewRadio(boolSetter), + NewRange(numSetter), + NewSelect(nba), + NewSpan(htmlGetter), + NewTbody(&tc), + NewTd(htmlGetter), + NewText(textSetter), + NewTextarea(textSetter), + NewTr(htmlGetter), + } + for i, ui := range all { + if ui == nil { + t.Fatalf("constructor[%d] returned nil", i) + } + } +} diff --git a/ui/container.go b/ui/container.go new file mode 100644 index 00000000..6b06937d --- /dev/null +++ b/ui/container.go @@ -0,0 +1,31 @@ +package ui + +import ( + "io" + + "github.com/linkdata/jaws/core" +) + +type Container struct { + OuterHTMLTag string + WrapContainer +} + +func NewContainer(outerHTMLTag string, c core.Container) *Container { + return &Container{ + OuterHTMLTag: outerHTMLTag, + WrapContainer: NewWrapContainer(c), + } +} + +func (rw RequestWriter) Container(outerHTMLTag string, c core.Container, params ...any) error { + return rw.UI(NewContainer(outerHTMLTag, c), params...) +} + +func (ui *Container) JawsRender(e *core.Element, w io.Writer, params []any) error { + return ui.RenderContainer(e, w, ui.OuterHTMLTag, params) +} + +func (ui *Container) JawsUpdate(e *core.Element) { + ui.UpdateContainer(e) +} diff --git a/ui/container_widgets.go b/ui/container_widgets.go new file mode 100644 index 00000000..558eb8f6 --- /dev/null +++ b/ui/container_widgets.go @@ -0,0 +1,108 @@ +package ui + +import ( + "html/template" + "io" + "slices" + "strings" + "sync" + + "github.com/linkdata/jaws/core" +) + +// WrapContainer is a helper for widgets that render dynamic child collections. +// +// It tracks previously rendered child elements and performs append/remove/order +// updates during JawsUpdate. +type WrapContainer struct { + Container core.Container + Tag any + mu sync.Mutex + contents []*core.Element +} + +func NewWrapContainer(c core.Container) WrapContainer { + return WrapContainer{Container: c} +} + +func (ui *WrapContainer) RenderContainer(e *core.Element, w io.Writer, outerHTMLTag string, params []any) (err error) { + if ui.Tag, err = e.ApplyGetter(ui.Container); err == nil { + attrs := e.ApplyParams(params) + b := e.Jid().AppendStartTagAttr(nil, outerHTMLTag) + for _, attr := range attrs { + b = append(b, ' ') + b = append(b, attr...) + } + b = append(b, '>') + _, err = w.Write(b) + if err == nil { + var contents []*core.Element + for _, childUI := range ui.Container.JawsContains(e) { + elem := e.Request.NewElement(childUI) + if err = elem.JawsRender(w, nil); err != nil { + break + } + contents = append(contents, elem) + } + ui.mu.Lock() + ui.contents = contents + ui.mu.Unlock() + b = b[:0] + b = append(b, "') + if _, err2 := w.Write(b); err == nil { + err = err2 + } + } + } + return +} + +func (ui *WrapContainer) UpdateContainer(e *core.Element) { + var toRemove, toAppend []*core.Element + var orderData []core.Jid + + oldMap := make(map[core.UI]*core.Element) + newMap := make(map[core.UI]struct{}) + newContents := ui.Container.JawsContains(e) + for _, childUI := range newContents { + newMap[childUI] = struct{}{} + } + + ui.mu.Lock() + oldOrder := make([]core.Jid, len(ui.contents)) + for i, elem := range ui.contents { + oldOrder[i] = elem.Jid() + oldMap[elem.Ui()] = elem + if _, ok := newMap[elem.Ui()]; !ok { + toRemove = append(toRemove, elem) + } + } + ui.contents = ui.contents[:0] + for _, childUI := range newContents { + elem := oldMap[childUI] + if elem == nil { + elem = e.Request.NewElement(childUI) + toAppend = append(toAppend, elem) + } + ui.contents = append(ui.contents, elem) + orderData = append(orderData, elem.Jid()) + } + ui.mu.Unlock() + + for _, elem := range toRemove { + e.Remove(elem.Jid().String()) + e.Request.DeleteElement(elem) + } + + for _, elem := range toAppend { + var sb strings.Builder + must(elem.JawsRender(&sb, nil)) + e.Append(template.HTML(sb.String())) // #nosec G203 + } + + if !slices.Equal(oldOrder, orderData) { + e.Order(orderData) + } +} diff --git a/ui/container_widgets_test.go b/ui/container_widgets_test.go new file mode 100644 index 00000000..ef4a3a7e --- /dev/null +++ b/ui/container_widgets_test.go @@ -0,0 +1,134 @@ +package ui + +import ( + "errors" + "io" + "strings" + "testing" + + "github.com/linkdata/jaws/core" + "github.com/linkdata/jaws/what" +) + +func TestContainerAndTbodyRender(t *testing.T) { + _, rq := newRequest(t) + tc := &testContainer{contents: []core.UI{NewSpan(testHTMLGetter("foo")), NewSpan(testHTMLGetter("bar"))}} + + container := NewContainer("div", tc) + _, got := renderUI(t, rq, container, "hidden") + mustMatch(t, `^$`, got) + + tbody := NewTbody(tc) + elem, got := renderUI(t, rq, tbody) + mustMatch(t, `^foobar$`, got) + tbody.JawsUpdate(elem) +} + +func TestWrapContainerUpdateContainer(t *testing.T) { + _, rq := newRequest(t) + span1 := NewSpan(testHTMLGetter("span1")) + span2 := NewSpan(testHTMLGetter("span2")) + span3 := NewSpan(testHTMLGetter("span3")) + + tc := &testContainer{contents: []core.UI{span1}} + container := NewContainer("div", tc) + elem, _ := renderUI(t, rq, container) + + if len(container.contents) != 1 { + t.Fatalf("want 1 content got %d", len(container.contents)) + } + + // append + reorder path + tc.contents = []core.UI{span1, span2, span3} + container.JawsUpdate(elem) + if len(container.contents) != 3 { + t.Fatalf("want 3 contents got %d", len(container.contents)) + } + + // remove path + removedJid := container.contents[0].Jid() + tc.contents = []core.UI{span2, span3} + container.JawsUpdate(elem) + if got := rq.GetElementByJid(removedJid); got != nil { + t.Fatal("expected removed element to be deleted from request") + } + + // reorder + replace path + tc.contents = []core.UI{span3, span1} + container.JawsUpdate(elem) + if len(container.contents) != 2 { + t.Fatalf("want 2 contents got %d", len(container.contents)) + } +} + +func TestWrapContainerRenderErrorPaths(t *testing.T) { + _, rq := newRequest(t) + renderErr := errors.New("render error") + errChild := testRenderErrorUI{err: renderErr} + tc := &testContainer{contents: []core.UI{NewSpan(testHTMLGetter("first")), errChild, NewSpan(testHTMLGetter("third"))}} + + container := NewContainer("div", tc) + elem := rq.NewElement(container) + var sb strings.Builder + err := elem.JawsRender(&sb, nil) + if !errors.Is(err, renderErr) { + t.Fatalf("want %v got %v", renderErr, err) + } + if len(container.contents) != 1 { + t.Fatalf("want 1 successful child got %d", len(container.contents)) + } + + // panic path from must() during append + tc2 := &testContainer{} + container2 := NewContainer("div", tc2) + elem2, _ := renderUI(t, rq, container2) + tc2.contents = []core.UI{testRenderErrorUI{err: errors.New("append fail")}} + defer func() { + if recover() == nil { + t.Fatal("expected panic from must") + } + }() + container2.JawsUpdate(elem2) +} + +type testRenderErrorUI struct { + err error +} + +func (ui testRenderErrorUI) JawsRender(*core.Element, io.Writer, []any) error { + return ui.err +} + +func (testRenderErrorUI) JawsUpdate(*core.Element) {} + +type testSelectHandler struct { + *testContainer + *testSetter[string] +} + +func TestSelectWidget(t *testing.T) { + _, rq := newRequest(t) + sh := &testSelectHandler{ + testContainer: &testContainer{contents: []core.UI{NewOption(core.NewNamedBool(nil, "1", "one", true))}}, + testSetter: newTestSetter("1"), + } + selectUI := NewSelect(sh) + elem, got := renderUI(t, rq, selectUI) + mustMatch(t, `^$`, got) + + selectUI.JawsUpdate(elem) + + if err := selectUI.JawsEvent(elem, what.Click, "noop"); !errors.Is(err, core.ErrEventUnhandled) { + t.Fatalf("want ErrEventUnhandled got %v", err) + } + if err := selectUI.JawsEvent(elem, what.Input, "2"); err != nil { + t.Fatal(err) + } + if sh.Get() != "2" { + t.Fatalf("want 2 got %q", sh.Get()) + } + sh.SetErr(errors.New("meh")) + if err := selectUI.JawsEvent(elem, what.Input, "3"); err == nil || err.Error() != "meh" { + t.Fatalf("want meh got %v", err) + } +} diff --git a/ui/date.go b/ui/date.go new file mode 100644 index 00000000..18d8e7ef --- /dev/null +++ b/ui/date.go @@ -0,0 +1,19 @@ +package ui + +import ( + "io" + "time" + + "github.com/linkdata/jaws/core" +) + +type Date struct{ InputDate } + +func NewDate(g core.Setter[time.Time]) *Date { return &Date{InputDate{Setter: g}} } +func (rw RequestWriter) Date(value any, params ...any) error { + return rw.UI(NewDate(core.MakeSetter[time.Time](value)), params...) +} + +func (ui *Date) JawsRender(e *core.Element, w io.Writer, params []any) error { + return ui.renderDateInput(e, w, "date", params...) +} diff --git a/ui/div.go b/ui/div.go new file mode 100644 index 00000000..9bfb22aa --- /dev/null +++ b/ui/div.go @@ -0,0 +1,18 @@ +package ui + +import ( + "io" + + "github.com/linkdata/jaws/core" +) + +type Div struct{ HTMLInner } + +func NewDiv(innerHTML core.HTMLGetter) *Div { return &Div{HTMLInner{HTMLGetter: innerHTML}} } +func (rw RequestWriter) Div(innerHTML any, params ...any) error { + return rw.UI(NewDiv(core.MakeHTMLGetter(innerHTML)), params...) +} + +func (ui *Div) JawsRender(e *core.Element, w io.Writer, params []any) error { + return ui.renderInner(e, w, "div", "", params) +} diff --git a/ui/doc.go b/ui/doc.go new file mode 100644 index 00000000..2e69e4e3 --- /dev/null +++ b/ui/doc.go @@ -0,0 +1,13 @@ +// Package ui contains the standard JaWS widget implementations. +// +// The package is intentionally organized around extension-oriented building +// blocks so new widgets can be authored here without reading JaWS core code: +// +// - `HTMLInner`: base renderer for tags with inner HTML content. +// - `Input`, `InputText`, `InputBool`, `InputFloat`, `InputDate`: +// typed input helpers that handle event/update flow. +// - `WrapContainer`: helper for widgets that render dynamic child UI lists. +// +// Naming follows short widget names (`Span`, `NewSpan`) instead of the +// legacy core names (`UiSpan`, `NewUiSpan`). +package ui diff --git a/jaws/errmissingtemplate.go b/ui/errmissingtemplate.go similarity index 96% rename from jaws/errmissingtemplate.go rename to ui/errmissingtemplate.go index a175eb38..2886ece4 100644 --- a/jaws/errmissingtemplate.go +++ b/ui/errmissingtemplate.go @@ -1,4 +1,4 @@ -package jaws +package ui import ( "strconv" diff --git a/jaws/handler.go b/ui/handler.go similarity index 52% rename from jaws/handler.go rename to ui/handler.go index e00fff48..74c94315 100644 --- a/jaws/handler.go +++ b/ui/handler.go @@ -1,19 +1,23 @@ -package jaws +package ui -import "net/http" +import ( + "net/http" -// Handler is an http.Handler that renders a template for every request. + "github.com/linkdata/jaws/core" +) + +// uiHandler is an http.uiHandler that renders a template for every request. // // It wires the incoming HTTP request through the JaWS rendering pipeline by // creating a Request, instantiating the configured Template and streaming the -// resulting HTML to the caller. Applications typically obtain a Handler via the -// Jaws.Handler helper. -type Handler struct { - *Jaws +// resulting HTML to the caller. Applications typically construct handlers with +// Handler. +type uiHandler struct { + *core.Jaws Template } -func (h Handler) ServeHTTP(wr http.ResponseWriter, r *http.Request) { +func (h uiHandler) ServeHTTP(wr http.ResponseWriter, r *http.Request) { _ = h.Log(h.NewRequest(r).NewElement(h.Template).JawsRender(wr, nil)) } @@ -22,6 +26,6 @@ func (h Handler) ServeHTTP(wr http.ResponseWriter, r *http.Request) { // The returned handler can be registered directly with a router. Each request // results in the template being looked up through the configured Template // lookupers and rendered with dot as the template data. -func (jw *Jaws) Handler(name string, dot any) http.Handler { - return Handler{Jaws: jw, Template: Template{Name: name, Dot: dot}} +func Handler(jw *core.Jaws, name string, dot any) http.Handler { + return uiHandler{Jaws: jw, Template: Template{Name: name, Dot: dot}} } diff --git a/ui/html_widgets.go b/ui/html_widgets.go new file mode 100644 index 00000000..c105cdbb --- /dev/null +++ b/ui/html_widgets.go @@ -0,0 +1,23 @@ +package ui + +import ( + "io" + + "github.com/linkdata/jaws/core" +) + +// HTMLInner is a reusable base for widgets that render as `inner`. +type HTMLInner struct { + HTMLGetter core.HTMLGetter +} + +func (ui *HTMLInner) renderInner(e *core.Element, w io.Writer, htmlTag, htmlType string, params []any) (err error) { + if _, err = e.ApplyGetter(ui.HTMLGetter); err == nil { + err = core.WriteHTMLInner(w, e.Jid(), htmlTag, htmlType, ui.HTMLGetter.JawsGetHTML(e), e.ApplyParams(params)...) + } + return +} + +func (ui *HTMLInner) JawsUpdate(e *core.Element) { + e.SetInner(ui.HTMLGetter.JawsGetHTML(e)) +} diff --git a/ui/html_widgets_test.go b/ui/html_widgets_test.go new file mode 100644 index 00000000..44f5846e --- /dev/null +++ b/ui/html_widgets_test.go @@ -0,0 +1,90 @@ +package ui + +import ( + "errors" + "html/template" + "strings" + "testing" + + "github.com/linkdata/jaws/core" +) + +func TestHTMLWidgets_ConstructorsAndRender(t *testing.T) { + _, rq := newRequest(t) + + tests := []struct { + name string + ui core.UI + params []any + pattern string + }{ + {"A", NewA(testHTMLGetter("inner")), nil, `^inner$`}, + {"Button", NewButton(testHTMLGetter("inner")), nil, `^$`}, + {"Div", NewDiv(testHTMLGetter("inner")), nil, `^
    inner
    $`}, + {"Label", NewLabel(testHTMLGetter("inner")), nil, `^$`}, + {"Li", NewLi(testHTMLGetter("inner")), nil, `^
  • inner
  • $`}, + {"Span", NewSpan(testHTMLGetter("inner")), nil, `^inner$`}, + {"Td", NewTd(testHTMLGetter("inner")), nil, `^inner$`}, + {"Tr", NewTr(testHTMLGetter("inner")), nil, `^inner$`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + elem, got := renderUI(t, rq, tt.ui, tt.params...) + mustMatch(t, tt.pattern, got) + tt.ui.JawsUpdate(elem) + }) + } +} + +func TestHTMLInner_RenderInnerApplyGetterError(t *testing.T) { + _, rq := newRequest(t) + + wantErr := errors.New("init fail") + g := &initFailGetter{err: wantErr} + elem := rq.NewElement(NewA(g)) + var sb strings.Builder + if err := elem.JawsRender(&sb, nil); !errors.Is(err, wantErr) { + t.Fatalf("want %v got %v", wantErr, err) + } +} + +type initFailGetter struct { + err error +} + +func (g *initFailGetter) JawsGetHTML(*core.Element) template.HTML { return "x" } +func (g *initFailGetter) JawsGetTag(*core.Request) any { return g } +func (g *initFailGetter) JawsInit(*core.Element) error { return g.err } + +func TestImg_RenderAndUpdate(t *testing.T) { + _, rq := newRequest(t) + src := newTestSetter("image.png") + ui := NewImg(src) + elem, got := renderUI(t, rq, ui, "hidden") + mustMatch(t, `^$`, got) + src.Set("image2.jpg") + ui.JawsUpdate(elem) +} + +func TestOption_RenderAndUpdate(t *testing.T) { + _, rq := newRequest(t) + nba := core.NewNamedBoolArray() + nb := core.NewNamedBool(nba, `escape"me`, "", true) + ui := NewOption(nb) + elem, got := renderUI(t, rq, ui, "hidden") + mustMatch(t, `^$`, got) + + nb.Set(false) + ui.JawsUpdate(elem) + nb.Set(true) + ui.JawsUpdate(elem) +} + +func TestRegister_Render(t *testing.T) { + _, rq := newRequest(t) + ui := NewRegister(NewSpan(testHTMLGetter("x"))) + _, got := renderUI(t, rq, ui) + if got != "" { + t.Fatalf("expected empty output got %q", got) + } +} diff --git a/ui/img.go b/ui/img.go new file mode 100644 index 00000000..21f2c001 --- /dev/null +++ b/ui/img.go @@ -0,0 +1,26 @@ +package ui + +import ( + "html/template" + "io" + "strconv" + + "github.com/linkdata/jaws/core" +) + +type Img struct{ core.Getter[string] } + +func NewImg(g core.Getter[string]) *Img { return &Img{Getter: g} } +func (rw RequestWriter) Img(imageSrc any, params ...any) error { + return rw.UI(NewImg(core.MakeGetter[string](imageSrc)), params...) +} + +func (ui *Img) JawsRender(e *core.Element, w io.Writer, params []any) (err error) { + if _, err = e.ApplyGetter(ui.Getter); err == nil { + srcAttr := template.HTMLAttr("src=" + strconv.Quote(ui.JawsGet(e))) // #nosec G203 + attrs := append(e.ApplyParams(params), srcAttr) + err = core.WriteHTMLInner(w, e.Jid(), "img", "", "", attrs...) + } + return +} +func (ui *Img) JawsUpdate(e *core.Element) { e.SetAttr("src", ui.JawsGet(e)) } diff --git a/ui/input_widgets.go b/ui/input_widgets.go new file mode 100644 index 00000000..aced1180 --- /dev/null +++ b/ui/input_widgets.go @@ -0,0 +1,179 @@ +package ui + +import ( + "io" + "strconv" + "sync/atomic" + "time" + + "github.com/linkdata/jaws/core" + "github.com/linkdata/jaws/what" +) + +// Input stores common state for interactive input widgets. +type Input struct { + Tag any + Last atomic.Value +} + +func (ui *Input) applyGetter(e *core.Element, getter any) (err error) { + ui.Tag, err = e.ApplyGetter(getter) + return +} + +func (ui *Input) maybeDirty(val any, e *core.Element, err error) error { + if changed, err := applyDirty(ui.Tag, e, err); err != nil { + return err + } else if changed { + ui.Last.Store(val) + } + return nil +} + +type InputText struct { + Input + core.Setter[string] +} + +func (ui *InputText) renderStringInput(e *core.Element, w io.Writer, htmlType string, params ...any) (err error) { + if err = ui.applyGetter(e, ui.Setter); err == nil { + attrs := e.ApplyParams(params) + v := ui.JawsGet(e) + ui.Last.Store(v) + err = core.WriteHTMLInput(w, e.Jid(), htmlType, v, attrs) + } + return +} + +func (ui *InputText) JawsUpdate(e *core.Element) { + if v := ui.JawsGet(e); ui.Last.Swap(v) != v { + e.SetValue(v) + } +} + +func (ui *InputText) JawsEvent(e *core.Element, wht what.What, val string) (err error) { + err = core.ErrEventUnhandled + if wht == what.Input { + err = ui.maybeDirty(val, e, ui.Setter.JawsSet(e, val)) + } + return +} + +type InputBool struct { + Input + core.Setter[bool] +} + +func (ui *InputBool) renderBoolInput(e *core.Element, w io.Writer, htmlType string, params ...any) (err error) { + if err = ui.applyGetter(e, ui.Setter); err == nil { + attrs := e.ApplyParams(params) + v := ui.JawsGet(e) + ui.Last.Store(v) + if v { + attrs = append(attrs, "checked") + } + err = core.WriteHTMLInput(w, e.Jid(), htmlType, "", attrs) + } + return +} + +func (ui *InputBool) JawsUpdate(e *core.Element) { + v := ui.JawsGet(e) + if ui.Last.Swap(v) != v { + txt := "false" + if v { + txt = "true" + } + e.SetValue(txt) + } +} + +func (ui *InputBool) JawsEvent(e *core.Element, wht what.What, val string) (err error) { + err = core.ErrEventUnhandled + if wht == what.Input { + var v bool + if val != "" { + if v, err = strconv.ParseBool(val); err != nil { + return + } + } + err = ui.maybeDirty(v, e, ui.Setter.JawsSet(e, v)) + } + return +} + +type InputFloat struct { + Input + core.Setter[float64] +} + +func (ui *InputFloat) str() string { + return strconv.FormatFloat(ui.Last.Load().(float64), 'f', -1, 64) +} + +func (ui *InputFloat) renderFloatInput(e *core.Element, w io.Writer, htmlType string, params ...any) (err error) { + if err = ui.applyGetter(e, ui.Setter); err == nil { + attrs := e.ApplyParams(params) + ui.Last.Store(ui.JawsGet(e)) + err = core.WriteHTMLInput(w, e.Jid(), htmlType, ui.str(), attrs) + } + return +} + +func (ui *InputFloat) JawsUpdate(e *core.Element) { + if f := ui.JawsGet(e); ui.Last.Swap(f) != f { + e.SetValue(ui.str()) + } +} + +func (ui *InputFloat) JawsEvent(e *core.Element, wht what.What, val string) (err error) { + err = core.ErrEventUnhandled + if wht == what.Input { + var v float64 + if val != "" { + if v, err = strconv.ParseFloat(val, 64); err != nil { + return + } + } + err = ui.maybeDirty(v, e, ui.Setter.JawsSet(e, v)) + } + return +} + +type InputDate struct { + Input + core.Setter[time.Time] +} + +func (ui *InputDate) str() string { + return ui.Last.Load().(time.Time).Format(core.ISO8601) +} + +func (ui *InputDate) renderDateInput(e *core.Element, w io.Writer, htmlType string, params ...any) (err error) { + if err = ui.applyGetter(e, ui.Setter); err == nil { + attrs := e.ApplyParams(params) + ui.Last.Store(ui.JawsGet(e)) + err = core.WriteHTMLInput(w, e.Jid(), htmlType, ui.str(), attrs) + } + return +} + +func (ui *InputDate) JawsUpdate(e *core.Element) { + if t := ui.JawsGet(e); ui.Last.Swap(t) != t { + e.SetValue(ui.str()) + } +} + +func (ui *InputDate) JawsEvent(e *core.Element, wht what.What, val string) (err error) { + err = core.ErrEventUnhandled + if wht == what.Input { + var v time.Time + if val != "" { + if v, err = time.Parse(core.ISO8601, val); err != nil { + return + } + } + err = ui.maybeDirty(v, e, ui.Setter.JawsSet(e, v)) + } + return +} diff --git a/ui/input_widgets_test.go b/ui/input_widgets_test.go new file mode 100644 index 00000000..4c868235 --- /dev/null +++ b/ui/input_widgets_test.go @@ -0,0 +1,126 @@ +package ui + +import ( + "errors" + "testing" + "time" + + "github.com/linkdata/jaws/core" + "github.com/linkdata/jaws/what" +) + +func TestInputTextWidgets(t *testing.T) { + _, rq := newRequest(t) + ss := newTestSetter("foo") + + text := NewText(ss) + elem, got := renderUI(t, rq, text) + mustMatch(t, `^$`, got) + + if err := text.JawsEvent(elem, what.Input, "bar"); err != nil { + t.Fatal(err) + } + if ss.Get() != "bar" { + t.Fatalf("want bar got %q", ss.Get()) + } + if err := text.JawsEvent(elem, what.Click, "noop"); !errors.Is(err, core.ErrEventUnhandled) { + t.Fatalf("want ErrEventUnhandled got %v", err) + } + ss.SetErr(errors.New("meh")) + if err := text.JawsEvent(elem, what.Input, "omg"); err == nil || err.Error() != "meh" { + t.Fatalf("want meh got %v", err) + } + ss.SetErr(nil) + ss.Set("quux") + text.JawsUpdate(elem) + + password := NewPassword(ss) + _, got = renderUI(t, rq, password) + mustMatch(t, `^$`, got) + + textarea := NewTextarea(ss) + _, got = renderUI(t, rq, textarea) + mustMatch(t, `^$`, got) + textarea.JawsUpdate(elem) +} + +func TestInputBoolWidgets(t *testing.T) { + _, rq := newRequest(t) + sb := newTestSetter(true) + + checkbox := NewCheckbox(sb) + elem, got := renderUI(t, rq, checkbox) + mustMatch(t, `^$`, got) + if err := checkbox.JawsEvent(elem, what.Input, "false"); err != nil { + t.Fatal(err) + } + if sb.Get() { + t.Fatal("expected false") + } + if err := checkbox.JawsEvent(elem, what.Input, "bad"); err == nil { + t.Fatal("expected parse error") + } + sb.Set(true) + checkbox.JawsUpdate(elem) + + radio := NewRadio(sb) + _, got = renderUI(t, rq, radio) + mustMatch(t, `^$`, got) +} + +func TestInputFloatWidgets(t *testing.T) { + _, rq := newRequest(t) + sf := newTestSetter(1.2) + + number := NewNumber(sf) + elem, got := renderUI(t, rq, number) + mustMatch(t, `^$`, got) + if err := number.JawsEvent(elem, what.Input, "2.3"); err != nil { + t.Fatal(err) + } + if sf.Get() != 2.3 { + t.Fatalf("want 2.3 got %v", sf.Get()) + } + if err := number.JawsEvent(elem, what.Input, "bad"); err == nil { + t.Fatal("expected parse error") + } + sf.Set(3.4) + number.JawsUpdate(elem) + + rng := NewRange(sf) + _, got = renderUI(t, rq, rng) + mustMatch(t, `^$`, got) +} + +func TestInputDateWidget(t *testing.T) { + _, rq := newRequest(t) + d0, _ := time.Parse(core.ISO8601, "2020-01-02") + sd := newTestSetter(d0) + + date := NewDate(sd) + elem, got := renderUI(t, rq, date, "dateattr") + mustMatch(t, `^$`, got) + + if err := date.JawsEvent(elem, what.Input, "2021-02-03"); err != nil { + t.Fatal(err) + } + if sd.Get().Format(core.ISO8601) != "2021-02-03" { + t.Fatalf("unexpected date %v", sd.Get()) + } + if err := date.JawsEvent(elem, what.Input, "bad"); err == nil { + t.Fatal("expected parse error") + } + d1, _ := time.Parse(core.ISO8601, "2022-03-04") + sd.Set(d1) + date.JawsUpdate(elem) +} + +func TestInputMaybeDirtyErrValueUnchanged(t *testing.T) { + _, rq := newRequest(t) + ss := newTestSetter("foo") + text := NewText(ss) + elem, _ := renderUI(t, rq, text) + if err := text.JawsEvent(elem, what.Input, "foo"); err != nil { + t.Fatalf("want nil got %v", err) + } +} diff --git a/jaws/jsvar.go b/ui/jsvar.go similarity index 63% rename from jaws/jsvar.go rename to ui/jsvar.go index d810d077..a9367466 100644 --- a/jaws/jsvar.go +++ b/ui/jsvar.go @@ -1,14 +1,16 @@ -package jaws +package ui import ( "bytes" "encoding/json" + "html/template" "io" "reflect" "strconv" "strings" "sync" + "github.com/linkdata/jaws/core" "github.com/linkdata/jaws/what" "github.com/linkdata/jq" ) @@ -17,38 +19,38 @@ type PathSetter interface { // JawsSetPath should set the JSON object member identified by jspath to the given value. // // If the member is already the given value, it should return ErrValueUnchanged. - JawsSetPath(elem *Element, jspath string, value any) (err error) + JawsSetPath(elem *core.Element, jspath string, value any) (err error) } type SetPather interface { // JawsPathSet notifies that a JSON object member identified by jspath has been set // to the given value and the change has been queued for broadcast. - JawsPathSet(elem *Element, jspath string, value any) + JawsPathSet(elem *core.Element, jspath string, value any) } type IsJsVar interface { - RWLocker - UI - EventHandler + core.RWLocker + core.UI + core.EventHandler PathSetter } type JsVarMaker interface { - JawsMakeJsVar(rq *Request) (v IsJsVar, err error) + JawsMakeJsVar(rq *core.Request) (v IsJsVar, err error) } var ( - _ IsJsVar = &JsVar[int]{} - _ Setter[int] = &JsVar[int]{} + _ IsJsVar = &JsVar[int]{} + _ core.Setter[int] = &JsVar[int]{} ) type JsVar[T any] struct { - RWLocker + core.RWLocker Ptr *T Tag any } -func (ui *JsVar[T]) JawsGetPath(elem *Element, jspath string) (value any) { +func (ui *JsVar[T]) JawsGetPath(elem *core.Element, jspath string) (value any) { ui.RLock() defer ui.RUnlock() var err error @@ -59,25 +61,25 @@ func (ui *JsVar[T]) JawsGetPath(elem *Element, jspath string) (value any) { return } -func (ui *JsVar[T]) JawsGet(elem *Element) (value T) { +func (ui *JsVar[T]) JawsGet(elem *core.Element) (value T) { anyval := ui.JawsGetPath(elem, "") value = *((anyval).(*T)) return } -func (ui *JsVar[T]) setPathLocked(elem *Element, jspath string, value any) (err error) { +func (ui *JsVar[T]) setPathLocked(elem *core.Element, jspath string, value any) (err error) { if ps, ok := ((any)(ui.Ptr).(PathSetter)); ok { err = ps.JawsSetPath(elem, jspath, value) } else { var changed bool if changed, err = jq.Set(ui.Ptr, jspath, value); err == nil && !changed { - err = ErrValueUnchanged + err = core.ErrValueUnchanged } } if err == nil && elem != nil { var data []byte if data, err = json.Marshal(value); err == nil { - elem.Jaws.Broadcast(Message{ + elem.Jaws.Broadcast(core.Message{ Dest: ui.Tag, What: what.Set, Data: jspath + "=" + string(data), @@ -87,14 +89,14 @@ func (ui *JsVar[T]) setPathLocked(elem *Element, jspath string, value any) (err return } -func (ui *JsVar[T]) setPathLock(elem *Element, jspath string, value any) (err error) { +func (ui *JsVar[T]) setPathLock(elem *core.Element, jspath string, value any) (err error) { ui.Lock() defer ui.Unlock() err = ui.setPathLocked(elem, jspath, value) return } -func (ui *JsVar[T]) setPath(elem *Element, jspath string, value any) (err error) { +func (ui *JsVar[T]) setPath(elem *core.Element, jspath string, value any) (err error) { if err = ui.setPathLock(elem, jspath, value); err == nil { if sp, ok := ((any)(ui.Ptr).(SetPather)); ok { sp.JawsPathSet(elem, jspath, value) @@ -103,15 +105,25 @@ func (ui *JsVar[T]) setPath(elem *Element, jspath string, value any) (err error) return } -func (ui *JsVar[T]) JawsSetPath(elem *Element, jspath string, value any) (err error) { +func (ui *JsVar[T]) JawsSetPath(elem *core.Element, jspath string, value any) (err error) { return ui.setPath(elem, jspath, value) } -func (ui *JsVar[T]) JawsSet(elem *Element, value T) (err error) { +func (ui *JsVar[T]) JawsSet(elem *core.Element, value T) (err error) { return ui.JawsSetPath(elem, "", value) } -func (ui *JsVar[T]) JawsRender(e *Element, w io.Writer, params []any) (err error) { +func appendAttrs(b []byte, attrs []template.HTMLAttr) []byte { + for _, s := range attrs { + if s != "" { + b = append(b, ' ') + b = append(b, s...) + } + } + return b +} + +func (ui *JsVar[T]) JawsRender(e *core.Element, w io.Writer, params []any) (err error) { ui.Lock() defer ui.Unlock() if ui.Tag, err = e.ApplyGetter(ui.Ptr); err == nil { @@ -142,21 +154,21 @@ func (ui *JsVar[T]) JawsRender(e *Element, w io.Writer, params []any) (err error return } -func (ui *JsVar[T]) JawsGetTag(rq *Request) any { +func (ui *JsVar[T]) JawsGetTag(rq *core.Request) any { return ui.Tag } -func (ui *JsVar[T]) JawsUpdate(e *Element) {} // no-op for JsVar[T] +func (ui *JsVar[T]) JawsUpdate(e *core.Element) {} // no-op for JsVar[T] func elideErrValueUnchanged(err error) error { - if err == ErrValueUnchanged { + if err == core.ErrValueUnchanged { return nil } return err } -func (ui *JsVar[T]) JawsEvent(e *Element, wht what.What, val string) (err error) { - err = ErrEventUnhandled +func (ui *JsVar[T]) JawsEvent(e *core.Element, wht what.What, val string) (err error) { + err = core.ErrEventUnhandled if wht == what.Set { if jspath, jsval, found := strings.Cut(val, "="); found { var v any @@ -169,7 +181,7 @@ func (ui *JsVar[T]) JawsEvent(e *Element, wht what.What, val string) (err error) } func NewJsVar[T any](l sync.Locker, v *T) *JsVar[T] { - if rl, ok := l.(RWLocker); ok { + if rl, ok := l.(core.RWLocker); ok { return &JsVar[T]{RWLocker: rl, Ptr: v} } return &JsVar[T]{RWLocker: rwlocker{l}, Ptr: v} @@ -178,15 +190,15 @@ func NewJsVar[T any](l sync.Locker, v *T) *JsVar[T] { // JsVar binds a JsVar[T] to a named Javascript variable. // // You can also pass a JsVarMaker instead of a JsVar[T]. -func (rq RequestWriter) JsVar(jsvarname string, jsvar any, params ...any) (err error) { +func (rqw RequestWriter) JsVar(jsvarname string, jsvar any, params ...any) (err error) { if jvm, ok := jsvar.(JsVarMaker); ok { - jsvar, err = jvm.JawsMakeJsVar(rq.Request()) + jsvar, err = jvm.JawsMakeJsVar(rqw.Request) } if err == nil { var newparams []any newparams = append(newparams, jsvarname) newparams = append(newparams, params...) - err = rq.UI(jsvar.(UI), newparams...) + err = rqw.UI(jsvar.(core.UI), newparams...) } return } diff --git a/ui/jsvar_test.go b/ui/jsvar_test.go new file mode 100644 index 00000000..3dc96f40 --- /dev/null +++ b/ui/jsvar_test.go @@ -0,0 +1,182 @@ +package ui + +import ( + "bytes" + "errors" + "fmt" + "html/template" + "strings" + "sync" + "testing" + + "github.com/linkdata/jaws/core" + "github.com/linkdata/jaws/what" +) + +type jsVarData struct { + Text string `json:"text"` + Num float64 `json:"num"` +} + +type jsVarPathHooks struct { + Value string `json:"value"` + setCalls int + pathSetCall int +} + +func (d *jsVarPathHooks) JawsSetPath(_ *core.Element, _ string, v any) error { + s := fmt.Sprint(v) + if d.Value == s { + return core.ErrValueUnchanged + } + d.Value = s + d.setCalls++ + return nil +} + +func (d *jsVarPathHooks) JawsPathSet(*core.Element, string, any) { + d.pathSetCall++ +} + +type testJsVarMaker struct{} + +func (testJsVarMaker) JawsMakeJsVar(*core.Request) (IsJsVar, error) { + var mu sync.Mutex + v := jsVarData{Text: "maker", Num: 1} + return NewJsVar(&mu, &v), nil +} + +type errorJsVarMaker struct{} + +func (errorJsVarMaker) JawsMakeJsVar(*core.Request) (IsJsVar, error) { + return nil, errors.New("maker error") +} + +func TestJsVar_RenderSetAndEvent(t *testing.T) { + jw, rq := newRequest(t) + go jw.Serve() + + var mu sync.Mutex + v := jsVarData{Text: "quote(')", Num: 1} + jsv := NewJsVar(&mu, &v) + elem := rq.NewElement(jsv) + + var sb bytes.Buffer + if err := jsv.JawsRender(elem, &sb, []any{"myjsvar", template.HTMLAttr(`data-x="1"`)}); err != nil { + t.Fatal(err) + } + got := sb.String() + if !strings.Contains(got, `data-jawsname="myjsvar"`) || + !strings.Contains(got, `\u0027`) || + !strings.Contains(got, `data-x="1"`) { + t.Fatalf("unexpected jsvar render: %q", got) + } + + if jsv.JawsGetTag(rq) == nil { + t.Fatal("expected non-nil tag after render") + } + if gotV := jsv.JawsGet(nil); gotV.Text != "quote(')" || gotV.Num != 1 { + t.Fatalf("unexpected value %#v", gotV) + } + if gotPath := jsv.JawsGetPath(nil, "text"); gotPath != "quote(')" { + t.Fatalf("unexpected path value %#v", gotPath) + } + _ = jsv.JawsGetPath(elem, "[") + jsv.JawsUpdate(elem) + + if err := jsv.JawsSetPath(elem, "text", "new"); err != nil { + t.Fatal(err) + } + if err := jsv.JawsSetPath(elem, "text", "new"); !errors.Is(err, core.ErrValueUnchanged) { + t.Fatalf("expected ErrValueUnchanged, got %v", err) + } + if err := jsv.JawsSet(elem, jsVarData{Text: "obj", Num: 2}); err != nil { + t.Fatal(err) + } + + if err := jsv.JawsEvent(elem, what.Set, `text="evt"`); err != nil { + t.Fatal(err) + } + if v.Text != "evt" { + t.Fatalf("expected updated value, got %#v", v) + } + if err := jsv.JawsEvent(elem, what.Set, `text="evt"`); err != nil { + t.Fatalf("expected unchanged error elided, got %v", err) + } + if err := jsv.JawsEvent(elem, what.Set, `text=`); err == nil { + t.Fatal("expected unmarshal error") + } + if err := jsv.JawsEvent(elem, what.Set, `badpayload`); !errors.Is(err, core.ErrEventUnhandled) { + t.Fatalf("expected ErrEventUnhandled, got %v", err) + } + if err := jsv.JawsEvent(elem, what.Click, `text="x"`); !errors.Is(err, core.ErrEventUnhandled) { + t.Fatalf("expected ErrEventUnhandled, got %v", err) + } + + if err := elideErrValueUnchanged(core.ErrValueUnchanged); err != nil { + t.Fatalf("expected nil, got %v", err) + } + other := errors.New("other") + if err := elideErrValueUnchanged(other); !errors.Is(err, other) { + t.Fatalf("expected passthrough error, got %v", err) + } +} + +func TestJsVar_PathHooksAndRequestWriter(t *testing.T) { + jw, rq := newRequest(t) + go jw.Serve() + + var mu sync.Mutex + v := jsVarPathHooks{Value: "a"} + jsv := NewJsVar(&mu, &v) + elem := rq.NewElement(jsv) + + var sb bytes.Buffer + if err := jsv.JawsRender(elem, &sb, []any{"pvar"}); err != nil { + t.Fatal(err) + } + if err := jsv.setPathLock(elem, "value", "b"); err != nil { + t.Fatal(err) + } + if v.Value != "b" || v.setCalls == 0 { + t.Fatalf("expected path hooks to run, got %#v", v) + } + if err := jsv.JawsSetPath(elem, "value", "c"); err != nil { + t.Fatal(err) + } + if v.pathSetCall == 0 { + t.Fatalf("expected JawsPathSet callback, got %#v", v) + } + if err := jsv.JawsSetPath(elem, "value", "c"); !errors.Is(err, core.ErrValueUnchanged) { + t.Fatalf("expected ErrValueUnchanged, got %v", err) + } + + if got := string(appendAttrs(nil, []template.HTMLAttr{"x", "", "y"})); got != " x y" { + t.Fatalf("unexpected attrs %q", got) + } + + var rwmu sync.RWMutex + jsvRW := NewJsVar(&rwmu, &v) + if _, ok := jsvRW.RWLocker.(*sync.RWMutex); !ok { + t.Fatalf("expected RWMutex locker, got %T", jsvRW.RWLocker) + } + var plainMu sync.Mutex + jsvPlain := NewJsVar(&plainMu, &v) + if _, ok := jsvPlain.RWLocker.(rwlocker); !ok { + t.Fatalf("expected rwlocker wrapper, got %T", jsvPlain.RWLocker) + } + + rw := RequestWriter{Request: rq, Writer: &sb} + if err := rw.JsVar("direct", jsv); err != nil { + t.Fatal(err) + } + if err := rw.JsVar("maker", testJsVarMaker{}); err != nil { + t.Fatal(err) + } + if err := rw.JsVar("bad", errorJsVarMaker{}); err == nil || err.Error() != "maker error" { + t.Fatalf("expected maker error, got %v", err) + } + if got := sb.String(); !strings.Contains(got, `data-jawsname="direct"`) || !strings.Contains(got, `data-jawsname="maker"`) { + t.Fatalf("unexpected jsvar output %q", got) + } +} diff --git a/ui/label.go b/ui/label.go new file mode 100644 index 00000000..dbe91229 --- /dev/null +++ b/ui/label.go @@ -0,0 +1,18 @@ +package ui + +import ( + "io" + + "github.com/linkdata/jaws/core" +) + +type Label struct{ HTMLInner } + +func NewLabel(innerHTML core.HTMLGetter) *Label { return &Label{HTMLInner{HTMLGetter: innerHTML}} } +func (rw RequestWriter) Label(innerHTML any, params ...any) error { + return rw.UI(NewLabel(core.MakeHTMLGetter(innerHTML)), params...) +} + +func (ui *Label) JawsRender(e *core.Element, w io.Writer, params []any) error { + return ui.renderInner(e, w, "label", "", params) +} diff --git a/ui/li.go b/ui/li.go new file mode 100644 index 00000000..5eee9192 --- /dev/null +++ b/ui/li.go @@ -0,0 +1,18 @@ +package ui + +import ( + "io" + + "github.com/linkdata/jaws/core" +) + +type Li struct{ HTMLInner } + +func NewLi(innerHTML core.HTMLGetter) *Li { return &Li{HTMLInner{HTMLGetter: innerHTML}} } +func (rw RequestWriter) Li(innerHTML any, params ...any) error { + return rw.UI(NewLi(core.MakeHTMLGetter(innerHTML)), params...) +} + +func (ui *Li) JawsRender(e *core.Element, w io.Writer, params []any) error { + return ui.renderInner(e, w, "li", "", params) +} diff --git a/ui/number.go b/ui/number.go new file mode 100644 index 00000000..bfc74c28 --- /dev/null +++ b/ui/number.go @@ -0,0 +1,18 @@ +package ui + +import ( + "io" + + "github.com/linkdata/jaws/core" +) + +type Number struct{ InputFloat } + +func NewNumber(g core.Setter[float64]) *Number { return &Number{InputFloat{Setter: g}} } +func (rw RequestWriter) Number(value any, params ...any) error { + return rw.UI(NewNumber(core.MakeSetterFloat64(value)), params...) +} + +func (ui *Number) JawsRender(e *core.Element, w io.Writer, params []any) error { + return ui.renderFloatInput(e, w, "number", params...) +} diff --git a/ui/option.go b/ui/option.go new file mode 100644 index 00000000..46f42087 --- /dev/null +++ b/ui/option.go @@ -0,0 +1,30 @@ +package ui + +import ( + "html" + "html/template" + "io" + + "github.com/linkdata/jaws/core" +) + +type Option struct{ *core.NamedBool } + +func NewOption(nb *core.NamedBool) Option { return Option{NamedBool: nb} } +func (ui Option) JawsRender(e *core.Element, w io.Writer, params []any) error { + e.Tag(ui.NamedBool) + attrs := e.ApplyParams(params) + valAttr := template.HTMLAttr(`value="` + html.EscapeString(ui.Name()) + `"`) // #nosec G203 + attrs = append(attrs, valAttr) + if ui.Checked() { + attrs = append(attrs, "selected") + } + return core.WriteHTMLInner(w, e.Jid(), "option", "", ui.JawsGetHTML(e), attrs...) +} +func (ui Option) JawsUpdate(e *core.Element) { + if ui.Checked() { + e.SetAttr("selected", "") + } else { + e.RemoveAttr("selected") + } +} diff --git a/ui/password.go b/ui/password.go new file mode 100644 index 00000000..5cc617ee --- /dev/null +++ b/ui/password.go @@ -0,0 +1,18 @@ +package ui + +import ( + "io" + + "github.com/linkdata/jaws/core" +) + +type Password struct{ InputText } + +func NewPassword(g core.Setter[string]) *Password { return &Password{InputText{Setter: g}} } +func (rw RequestWriter) Password(value any, params ...any) error { + return rw.UI(NewPassword(core.MakeSetter[string](value)), params...) +} + +func (ui *Password) JawsRender(e *core.Element, w io.Writer, params []any) error { + return ui.renderStringInput(e, w, "password", params...) +} diff --git a/ui/radio.go b/ui/radio.go new file mode 100644 index 00000000..4994daad --- /dev/null +++ b/ui/radio.go @@ -0,0 +1,18 @@ +package ui + +import ( + "io" + + "github.com/linkdata/jaws/core" +) + +type Radio struct{ InputBool } + +func NewRadio(vp core.Setter[bool]) *Radio { return &Radio{InputBool{Setter: vp}} } +func (rw RequestWriter) Radio(value any, params ...any) error { + return rw.UI(NewRadio(core.MakeSetter[bool](value)), params...) +} + +func (ui *Radio) JawsRender(e *core.Element, w io.Writer, params []any) error { + return ui.renderBoolInput(e, w, "radio", params...) +} diff --git a/ui/range.go b/ui/range.go new file mode 100644 index 00000000..f35b637f --- /dev/null +++ b/ui/range.go @@ -0,0 +1,18 @@ +package ui + +import ( + "io" + + "github.com/linkdata/jaws/core" +) + +type Range struct{ InputFloat } + +func NewRange(g core.Setter[float64]) *Range { return &Range{InputFloat{Setter: g}} } +func (rw RequestWriter) Range(value any, params ...any) error { + return rw.UI(NewRange(core.MakeSetterFloat64(value)), params...) +} + +func (ui *Range) JawsRender(e *core.Element, w io.Writer, params []any) error { + return ui.renderFloatInput(e, w, "range", params...) +} diff --git a/jaws/uiregister.go b/ui/register.go similarity index 53% rename from jaws/uiregister.go rename to ui/register.go index d8c2b46f..7ff903e5 100644 --- a/jaws/uiregister.go +++ b/ui/register.go @@ -1,17 +1,18 @@ -package jaws +package ui import ( "io" + "github.com/linkdata/jaws/core" "github.com/linkdata/jaws/jid" ) -type UiRegister struct { - Updater -} +// Register creates an element used for update-only registration. +type Register struct{ core.Updater } -func (ui UiRegister) JawsRender(e *Element, w io.Writer, params []any) (err error) { - return +func NewRegister(updater core.Updater) Register { return Register{Updater: updater} } +func (ui Register) JawsRender(*core.Element, io.Writer, []any) error { + return nil } // Register creates a new Element with the given Updater as a tag @@ -22,8 +23,8 @@ func (ui UiRegister) JawsRender(e *Element, w io.Writer, params []any) (err erro // Returns a Jid, suitable for including as a HTML "id" attribute: // //
    ...
    -func (rq RequestWriter) Register(updater Updater, params ...any) jid.Jid { - elem := rq.rq.NewElement(UiRegister{Updater: updater}) +func (rqw RequestWriter) Register(updater core.Updater, params ...any) jid.Jid { + elem := rqw.NewElement(Register{Updater: updater}) elem.Tag(updater) elem.ApplyParams(params) updater.JawsUpdate(elem) diff --git a/ui/requestwriter.go b/ui/requestwriter.go new file mode 100644 index 00000000..93ea7324 --- /dev/null +++ b/ui/requestwriter.go @@ -0,0 +1,53 @@ +package ui + +import ( + "io" + "net/http" + + "github.com/linkdata/jaws/core" +) + +type RequestWriter struct { + *core.Request + io.Writer +} + +func (rqw RequestWriter) UI(ui core.UI, params ...any) error { + return rqw.NewElement(ui).JawsRender(rqw, params) +} + +func (rqw RequestWriter) Write(p []byte) (n int, err error) { + rqw.Rendering.Store(true) + return rqw.Writer.Write(p) +} + +// Initial returns the initial http.Request. +func (rqw RequestWriter) Initial() *http.Request { + return rqw.Request.Initial() +} + +// Session returns the Requests's Session, or nil. +func (rqw RequestWriter) Session() *core.Session { + return rqw.Request.Session() +} + +// Get calls Request().Get() +func (rqw RequestWriter) Get(key string) (val any) { + return rqw.Request.Get(key) +} + +// Set calls Request().Set() +func (rqw RequestWriter) Set(key string, val any) { + rqw.Request.Set(key, val) +} + +// HeadHTML outputs the HTML code needed in the HEAD section. +func (rqw RequestWriter) HeadHTML() error { + return rqw.Request.HeadHTML(rqw) +} + +// TailHTML writes optional HTML code at the end of the page's BODY section that +// will immediately apply updates made during initial rendering. +func (rqw RequestWriter) TailHTML() error { + return rqw.Request.TailHTML(rqw) +} diff --git a/ui/requestwriter_test.go b/ui/requestwriter_test.go new file mode 100644 index 00000000..f2590be0 --- /dev/null +++ b/ui/requestwriter_test.go @@ -0,0 +1,145 @@ +package ui + +import ( + "bytes" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/linkdata/jaws/core" +) + +type testRWUpdater struct { + called int +} + +func (u *testRWUpdater) JawsUpdate(*core.Element) { + u.called++ +} + +func newSessionBoundRequest(t *testing.T) (*core.Jaws, *core.Request) { + t.Helper() + jw, err := core.New() + if err != nil { + t.Fatal(err) + } + t.Cleanup(jw.Close) + + hr := httptest.NewRequest(http.MethodGet, "/", nil) + rr := httptest.NewRecorder() + if sess := jw.NewSession(rr, hr); sess == nil { + t.Fatal("expected session") + } + rq := jw.NewRequest(hr) + if rq == nil { + t.Fatal("expected request") + } + return jw, rq +} + +func TestRequestWriter_MethodsAndWidgetHelpers(t *testing.T) { + jw, rq := newSessionBoundRequest(t) + var buf bytes.Buffer + rw := RequestWriter{Request: rq, Writer: &buf} + + if _, err := rw.Write([]byte("prefix")); err != nil { + t.Fatal(err) + } + if got := buf.String(); got != "prefix" { + t.Fatalf("unexpected write output %q", got) + } + if !rq.Rendering.Load() { + t.Fatal("expected rendering=true after write") + } + + if rw.Initial() == nil { + t.Fatal("expected initial request") + } + if rw.Session() == nil { + t.Fatal("expected session") + } + rw.Set("k", "v") + if got := rw.Get("k"); got != "v" { + t.Fatalf("unexpected session value %#v", got) + } + + if err := rw.HeadHTML(); err != nil { + t.Fatal(err) + } + if err := rw.TailHTML(); err != nil { + t.Fatal(err) + } + + if err := rw.UI(NewSpan(testHTMLGetter("ui"))); err != nil { + t.Fatal(err) + } + + tc := &testContainer{contents: []core.UI{NewSpan(testHTMLGetter("in"))}} + date := time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC) + sh := &testSelectHandler{ + testContainer: &testContainer{contents: []core.UI{NewOption(core.NewNamedBool(nil, "x", "X", true))}}, + testSetter: newTestSetter("x"), + } + + calls := []func() error{ + func() error { return rw.A("a") }, + func() error { return rw.Button("b") }, + func() error { return rw.Checkbox(true) }, + func() error { return rw.Container("section", tc) }, + func() error { return rw.Date(date) }, + func() error { return rw.Div("d") }, + func() error { return rw.Img("img.png") }, + func() error { return rw.Label("l") }, + func() error { return rw.Li("li") }, + func() error { return rw.Number(1.2) }, + func() error { return rw.Password("pw") }, + func() error { return rw.Radio(false) }, + func() error { return rw.Range(2.3) }, + func() error { return rw.Select(sh) }, + func() error { return rw.Span("sp") }, + func() error { return rw.Tbody(tc) }, + func() error { return rw.Td("td") }, + func() error { return rw.Text("txt") }, + func() error { return rw.Textarea("ta") }, + func() error { return rw.Tr("tr") }, + } + for i, call := range calls { + if err := call(); err != nil { + t.Fatalf("helper[%d] failed: %v", i, err) + } + } + + up := &testRWUpdater{} + id := rw.Register(up) + if !id.IsValid() { + t.Fatalf("invalid register id %v", id) + } + if up.called != 1 { + t.Fatalf("expected updater called once, got %d", up.called) + } + + got := buf.String() + if !strings.Contains(got, `{{.}}{{end}}`, + ))) + jw.AddTemplateLookuper(template.Must(template.New("warn").Parse(`plain`))) + + var sb bytes.Buffer + rw := RequestWriter{Request: rq, Writer: &sb} + + if err := rw.Template("uitempl", core.Tag("dot"), "hidden"); err != nil { + t.Fatal(err) + } + got := sb.String() + if !strings.Contains(got, `
    dot
    `) { + t.Fatalf("unexpected template output: %q", got) + } + + td := &templateDot{} + tpl := NewTemplate("uitempl", td) + if got := tpl.String(); !strings.Contains(got, `{"uitempl", *ui.templateDot(`) { + t.Fatalf("unexpected template string %q", got) + } + elem := rq.NewElement(tpl) + tpl.JawsUpdate(elem) + if td.updated != 1 { + t.Fatalf("expected updater called once, got %d", td.updated) + } + if err := tpl.JawsEvent(elem, what.Input, "x"); err != nil { + t.Fatal(err) + } + if td.events != 1 { + t.Fatalf("expected event call count 1, got %d", td.events) + } + + if err := rw.Template("warn", core.Tag("x")); err != nil { + t.Fatal(err) + } + if deadlock.Debug && log.warns == 0 { + t.Fatal("expected warning for template without Jid/Js references") + } + + if err := rw.Template("missingtemplate", nil); !errors.Is(err, ErrMissingTemplate) { + t.Fatalf("expected ErrMissingTemplate, got %v", err) + } +} + +func TestTemplate_findJidOrJsOrHTMLNode(t *testing.T) { + if findJidOrJsOrHTMLNode(nil) { + t.Fatal("nil node should not match") + } + + plain := template.Must(template.New("plain").Parse(`plain text`)) + if findJidOrJsOrHTMLNode(plain.Tree.Root) { + t.Fatal("plain text should not match") + } + + htmlNode := template.Must(template.New("html").Parse(``)) + if !findJidOrJsOrHTMLNode(htmlNode.Tree.Root) { + t.Fatal("expected html close marker match") + } + + complex := template.Must(template.New("complex").Parse( + `{{if .}}{{with .}}{{$.Jid}}{{$.JsVar}}{{$.JsFunc}}{{end}}{{end}}`, + )) + if !findJidOrJsOrHTMLNode(complex.Tree.Root) { + t.Fatal("expected Jid/Js node match") + } +} + +func TestHandler_HandlerServeHTTP(t *testing.T) { + jw, err := core.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + jw.AddTemplateLookuper(template.Must(template.New("handler").Parse(`{{with $.Dot}}
    {{.}}
    {{end}}`))) + + h := Handler(jw, "handler", core.Tag("ok")) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + h.ServeHTTP(rr, req) + + if got := rr.Body.String(); !strings.Contains(got, `