Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion lib/bind/setterfloat64.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package bind
import (
"errors"
"fmt"
"html/template"
"math"
"strconv"

Expand All @@ -22,10 +23,50 @@ type numeric interface {
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}

// setterFloat64 adapts a numeric [Setter] to a Setter[float64], sanitizing every
// write through sanitizeFloatForT.
//
// Embedding Setter[T] promotes only JawsGet and JawsSet, so the adapter forwards
// the optional event and attribute interfaces explicitly. Without that, wrapping
// would hide them from [jaws.Element.ApplyGetter], silently dropping the Clicked,
// ContextMenu and InitialHTMLAttr hooks a [Binder] exposes. Each forward reports
// "not handled" when the wrapped Setter does not implement the interface, which is
// what ApplyGetter and the event dispatch already expect for a plain setter.
type setterFloat64[T numeric] struct {
Setter[T]
}

func (s setterFloat64[T]) JawsClick(elem *jaws.Element, click jaws.Click) (err error) {
err = jaws.ErrEventUnhandled
if h, ok := s.Setter.(jaws.ClickHandler); ok {
err = h.JawsClick(elem, click)
}
return
}

func (s setterFloat64[T]) JawsContextMenu(elem *jaws.Element, click jaws.Click) (err error) {
err = jaws.ErrEventUnhandled
if h, ok := s.Setter.(jaws.ContextMenuHandler); ok {
err = h.JawsContextMenu(elem, click)
}
return
}

func (s setterFloat64[T]) JawsInput(elem *jaws.Element, value string) (err error) {
err = jaws.ErrEventUnhandled
if h, ok := s.Setter.(jaws.InputHandler); ok {
err = h.JawsInput(elem, value)
}
return
}

func (s setterFloat64[T]) JawsInitialHTMLAttr(elem *jaws.Element) (attr template.HTMLAttr) {
if h, ok := s.Setter.(jaws.InitialHTMLAttrHandler); ok {
attr = h.JawsInitialHTMLAttr(elem)
}
return
}

// sanitizeFloatForT validates value before it is converted to T and reports
// whether it may be the canonical float64 view of more than one T integer. It
// rejects non-finite values for every numeric T, and for integer T also rejects
Expand Down Expand Up @@ -193,6 +234,13 @@ func makeSetterFloat64for[T numeric](s *Setter[float64], value any) bool {
// bridge can lose precision: not every integer magnitude beyond 2^53 is exactly
// representable as float64.
//
// Writes are sanitized whatever the bound type. [Setter.JawsSet] returns
// [ErrFloatNotFinite] for a NaN or infinite value and [ErrFloatOutOfRange] for a
// finite value that does not fit the bound type, leaving the bound value
// unchanged in both cases. The float64 case is sanitized too, so an untrusted
// value cannot store a NaN that would defeat the equality comparison every
// change-detecting setter relies on.
//
// When an integer loses precision in that conversion, writing the canonical
// float64 returned by [Getter.JawsGet] back through [Setter.JawsSet] preserves
// the underlying integer and returns [jaws.ErrValueUnchanged]. This also applies
Expand All @@ -216,7 +264,13 @@ func makeSetterFloat64for[T numeric](s *Setter[float64], value any) bool {
func MakeSetterFloat64(value any) (s Setter[float64]) {
switch v := value.(type) {
case Setter[float64]:
return v
// Wrap rather than pass through: setterFloat64 is where the non-finite guard
// lives, and a float64 setter is the one settable case that needs no
// conversion, so passing it through unchanged would be the only binding that
// accepts NaN. The wrapper costs nothing else here: sanitizeFloatForT[float64]
// takes the finiteness-only default branch and reports mayAlias false, and
// T(value) is the identity, so no conversion or extra JawsGet is introduced.
return setterFloat64[float64]{Setter: v}
case Getter[float64]:
return setterReadOnly[float64]{v}
case float64:
Expand Down
183 changes: 181 additions & 2 deletions lib/bind/setterfloat64_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package bind

import (
"errors"
"html/template"
"math"
"reflect"
"strconv"
Expand All @@ -22,6 +23,7 @@ func (tg testGetter[T]) JawsGet(elem *jaws.Element) T {
}

func Test_makeSetterFloat64types(t *testing.T) {
tsfloat64 := newTestSetter(float64(0))
tsint := newTestSetter(int(0))
tsuintptr := newTestSetter(uintptr(0))
tests := []struct {
Expand All @@ -31,8 +33,8 @@ func Test_makeSetterFloat64types(t *testing.T) {
}{
{
name: "Setter[float64]",
v: setterFloat64[float64]{},
wantS: setterFloat64[float64]{},
v: tsfloat64,
wantS: setterFloat64[float64]{tsfloat64},
},
{
name: "Getter[float64]",
Expand Down Expand Up @@ -569,3 +571,180 @@ func Test_makeSetterFloat64_panicNamedNumeric(t *testing.T) {
assertPanics("value", Celsius(20))
assertPanics("setter", newTestSetter[Celsius](20))
}

// Test_setterFloat64_rejectsNonFiniteForEveryNumericType pins that
// [MakeSetterFloat64] applies the non-finite guard uniformly, whatever the bound
// numeric type. float64 is the case worth pinning: it is the only settable type
// that reaches JawsSet without a conversion step, so a missing guard there would
// store a NaN that permanently defeats the equality-based update dedup in
// binder.JawsSetLocked (NaN != NaN) and, for the float widgets in lib/ui, would
// terminate every Request that later renders it.
func Test_setterFloat64_rejectsNonFiniteForEveryNumericType(t *testing.T) {
nonFinite := []float64{math.NaN(), math.Inf(1), math.Inf(-1)}

assertRejects := func(t *testing.T, name string, s Setter[float64], unchanged func() bool) {
t.Helper()
for _, bad := range nonFinite {
if err := s.JawsSet(nil, bad); !errors.Is(err, ErrFloatNotFinite) {
t.Errorf("%s: JawsSet(%v) = %v, want ErrFloatNotFinite", name, bad, err)
}
if !unchanged() {
t.Fatalf("%s: JawsSet(%v) mutated the bound value", name, bad)
}
}
}

t.Run("float64", func(t *testing.T) {
var mu sync.Mutex
value := 1.5
assertRejects(t, "float64", MakeSetterFloat64(New(&mu, &value)), func() bool {
return value == 1.5
})
})

t.Run("float32", func(t *testing.T) {
var mu sync.Mutex
value := float32(1.5)
assertRejects(t, "float32", MakeSetterFloat64(New(&mu, &value)), func() bool {
return value == 1.5
})
})

t.Run("int", func(t *testing.T) {
var mu sync.Mutex
value := 3
assertRejects(t, "int", MakeSetterFloat64(New(&mu, &value)), func() bool {
return value == 3
})
})
}

// Test_setterFloat64_float64PassThroughBehavior pins the behavior the non-finite
// guard must not disturb for a plain float64 binding: finite values are stored,
// an unchanged value still reports jaws.ErrValueUnchanged, and the setter still
// resolves to the same tag key as the underlying Binder.
func Test_setterFloat64_float64PassThroughBehavior(t *testing.T) {
var mu sync.Mutex
value := 0.0
bind := New(&mu, &value)
s := MakeSetterFloat64(bind)

if err := s.JawsSet(nil, 2.5); err != nil {
t.Fatalf("JawsSet(2.5): %v", err)
}
if value != 2.5 {
t.Fatalf("stored value = %v, want 2.5", value)
}
if got := s.JawsGet(nil); got != 2.5 {
t.Fatalf("JawsGet() = %v, want 2.5", got)
}
if err := s.JawsSet(nil, 2.5); !errors.Is(err, jaws.ErrValueUnchanged) {
t.Fatalf("JawsSet(2.5) again = %v, want ErrValueUnchanged", err)
}

tags, err := tag.TagExpand(s)
if err != nil {
t.Fatalf("TagExpand: %v", err)
}
if len(tags) != 1 || tags[0] != any(&value) {
t.Fatalf("TagExpand() = %#v, want [%p]", tags, &value)
}
}

// inputSetterFloat64 is a Setter[float64] that also handles raw browser input, so
// the tests can exercise setterFloat64's InputHandler forwarding. bind.Binder
// covers the click, context-menu and initial-attribute interfaces but not this one.
type inputSetterFloat64 struct {
*testSetter[float64]
got string
}

func (is *inputSetterFloat64) JawsInput(elem *jaws.Element, value string) error {
is.got = value
return nil
}

// Test_setterFloat64_forwardsOptionalInterfaces pins that adapting a numeric
// Setter stays transparent to jaws.Element.ApplyGetter. The adapter embeds
// Setter[T], which promotes only JawsGet and JawsSet, so the optional event and
// attribute interfaces must be forwarded explicitly or a Binder's Clicked,
// ContextMenu and InitialHTMLAttr hooks would silently stop firing once the value
// is bound to a float widget.
func Test_setterFloat64_forwardsOptionalInterfaces(t *testing.T) {
t.Run("delegates to the wrapped setter", func(t *testing.T) {
var mu sync.Mutex
value := 1.5
var clicked, contextMenu int
b := New(&mu, &value).
Clicked(func(Binder[float64], *jaws.Element, jaws.Click) error {
clicked++
return nil
}).
ContextMenu(func(Binder[float64], *jaws.Element, jaws.Click) error {
contextMenu++
return nil
}).
InitialHTMLAttr(func(Binder[float64], *jaws.Element) template.HTMLAttr {
return `data-x="1"`
})
s := MakeSetterFloat64(b)

ch, ok := s.(jaws.ClickHandler)
if !ok {
t.Fatal("adapter does not implement jaws.ClickHandler")
}
if err := ch.JawsClick(nil, jaws.Click{}); err != nil || clicked != 1 {
t.Errorf("JawsClick() = %v, clicked = %d, want nil and 1", err, clicked)
}

cm, ok := s.(jaws.ContextMenuHandler)
if !ok {
t.Fatal("adapter does not implement jaws.ContextMenuHandler")
}
if err := cm.JawsContextMenu(nil, jaws.Click{}); err != nil || contextMenu != 1 {
t.Errorf("JawsContextMenu() = %v, contextMenu = %d, want nil and 1", err, contextMenu)
}

ah, ok := s.(jaws.InitialHTMLAttrHandler)
if !ok {
t.Fatal("adapter does not implement jaws.InitialHTMLAttrHandler")
}
if got := ah.JawsInitialHTMLAttr(nil); got != `data-x="1"` {
t.Errorf("JawsInitialHTMLAttr() = %q, want %q", got, `data-x="1"`)
}
})

t.Run("forwards input to a wrapped InputHandler", func(t *testing.T) {
is := &inputSetterFloat64{testSetter: newTestSetter(1.5)}
s := MakeSetterFloat64(is)
ih, ok := s.(jaws.InputHandler)
if !ok {
t.Fatal("adapter does not implement jaws.InputHandler")
}
if err := ih.JawsInput(nil, "2.5"); err != nil {
t.Fatalf("JawsInput(): %v", err)
}
if is.got != "2.5" {
t.Errorf("wrapped JawsInput got %q, want %q", is.got, "2.5")
}
})

t.Run("reports unhandled for a plain setter", func(t *testing.T) {
// A Setter that implements none of the optional interfaces must leave the
// event unhandled so dispatch falls through to the widget, and must
// contribute no initial attribute.
s := MakeSetterFloat64(newTestSetter(1.5))
if err := s.(jaws.ClickHandler).JawsClick(nil, jaws.Click{}); !errors.Is(err, jaws.ErrEventUnhandled) {
t.Errorf("JawsClick() = %v, want ErrEventUnhandled", err)
}
if err := s.(jaws.ContextMenuHandler).JawsContextMenu(nil, jaws.Click{}); !errors.Is(err, jaws.ErrEventUnhandled) {
t.Errorf("JawsContextMenu() = %v, want ErrEventUnhandled", err)
}
if err := s.(jaws.InputHandler).JawsInput(nil, "1"); !errors.Is(err, jaws.ErrEventUnhandled) {
t.Errorf("JawsInput() = %v, want ErrEventUnhandled", err)
}
if got := s.(jaws.InitialHTMLAttrHandler).JawsInitialHTMLAttr(nil); got != "" {
t.Errorf("JawsInitialHTMLAttr() = %q, want empty", got)
}
})
}
69 changes: 69 additions & 0 deletions lib/ui/input_widgets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -631,3 +631,72 @@ func TestInputTextWidget_InitialHTMLAttrFromBinder(t *testing.T) {
_, got := renderUI(t, rq, NewText(b))
mustMatch(t, `^<input id="Jid\.[0-9]+" type="text" value="foo" data-binder="yes">$`, got)
}

// TestInputFloat_BinderAdapterStaysTransparent guards every binding that reaches a
// float widget through bind.MakeSetterFloat64, which wraps the Binder in an adapter
// so non-finite browser values are rejected. The adapter must not shadow what
// jaws.Element.ApplyGetter looks for: the Binder's Clicked and InitialHTMLAttr hooks
// and its pointer-derived dirty tag all have to survive the wrapping. An integer
// binding is covered as well as float64, because it takes the converting path.
func TestInputFloat_BinderAdapterStaysTransparent(t *testing.T) {
t.Run("float64", func(t *testing.T) {
_, rq := newCoreRequest(t)
var mu deadlock.Mutex
value := 1.5
var clicked int
b := bind.New(&mu, &value).
Clicked(func(bind.Binder[float64], *jaws.Element, jaws.Click) error {
clicked++
return nil
}).
InitialHTMLAttr(func(bind.Binder[float64], *jaws.Element) template.HTMLAttr {
return `data-unit="cm"`
})
number := NewNumber(bind.MakeSetterFloat64(b))

elem, got := renderUI(t, rq, number)
mustMatch(t, `^<input id="Jid\.[0-9]+" type="number" value="1.5" data-unit="cm">$`, got)

// The dirty target must still be the bound pointer, so a browser edit
// reconciles through the same tag the application dirties.
if !elem.HasTag(&value) {
t.Fatalf("element not registered under the bound pointer; tags: %v", rq.TagsOf(elem))
}
if err := jaws.CallEventHandlers(elem.UI(), elem, what.Click, "0 0 0 x"); err != nil {
t.Fatalf("click: %v", err)
}
if clicked != 1 {
t.Fatalf("Clicked hook fired %d times, want 1", clicked)
}

if err := number.JawsInput(elem, "2.5"); err != nil {
t.Fatalf("JawsInput(2.5): %v", err)
}
if value != 2.5 {
t.Fatalf("stored value = %v, want 2.5", value)
}

// A non-finite value is refused, leaving the bound value intact rather than
// storing a NaN that would break change detection.
if err := bind.MakeSetterFloat64(b).JawsSet(elem, math.NaN()); !errors.Is(err, bind.ErrFloatNotFinite) {
t.Fatalf("JawsSet(NaN) = %v, want bind.ErrFloatNotFinite", err)
}
if value != 2.5 {
t.Fatalf("stored value = %v after NaN, want 2.5", value)
}
})

t.Run("int", func(t *testing.T) {
_, rq := newCoreRequest(t)
var mu deadlock.Mutex
value := 3
b := bind.New(&mu, &value).InitialHTMLAttr(
func(bind.Binder[int], *jaws.Element) template.HTMLAttr { return `data-unit="px"` })

elem, got := renderUI(t, rq, NewNumber(bind.MakeSetterFloat64(b)))
mustMatch(t, `^<input id="Jid\.[0-9]+" type="number" value="3" data-unit="px">$`, got)
if !elem.HasTag(&value) {
t.Fatalf("element not registered under the bound pointer; tags: %v", rq.TagsOf(elem))
}
})
}
Loading