Skip to content
Merged
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
5 changes: 1 addition & 4 deletions htmx.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,10 +248,7 @@ func (t *htmxUnmatchedTarget) Is(component any) bool {
// pageNodeHasType reports whether pn's value has the receiver type info
// names.
func pageNodeHasType(pn *PageNode, info *methodInfo) bool {
if info.isBound {
return sameTypeName(pn.Value.Type(), info.receiverTypeName)
}
return pointerType(pn.Value.Type()) == pointerType(info.receiverType)
return info.receiverIs(pn.Value.Type())
}

func (t *htmxUnmatchedTarget) isFunction(info *methodInfo) bool {
Expand Down
137 changes: 137 additions & 0 deletions id_bound_cross_package_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package structpages

import (
"context"
"reflect"
"testing"

fnfmt "github.com/jackielii/structpages/internal/fn-fmt"
"github.com/jackielii/structpages/internal/idconflicta"
"github.com/jackielii/structpages/internal/idconflictb"
)

// widgetNodes returns the idConflictRoot tree and its two Widget nodes,
// one per package.
func widgetNodes(t *testing.T) (pc *parseContext, a, b *PageNode) {
t.Helper()
pc, err := parsePageTree("/", &idConflictRoot{})
if err != nil {
t.Fatalf("parsePageTree: %v", err)
}
for n := range pc.root.All() {
switch pointerType(n.Value.Type()) {
case reflect.TypeFor[*idconflicta.Widget]():
a = n
case reflect.TypeFor[*idconflictb.Widget]():
b = n
}
}
if a == nil || b == nil {
t.Fatal("Widget nodes not found")
}
return pc, a, b
}

// A bound method value (b.List) names its receiver's package as well as
// its type, so it resolves as exactly as the method expression: never to
// a same-named type from another package (#39).
func TestID_boundMethodValueCrossPackage(t *testing.T) {
pc, aNode, bNode := widgetNodes(t)
base := pcCtx.WithValue(context.Background(), pc)
var a idconflicta.Widget
var b idconflictb.Widget

for _, fn := range []struct {
name string
id func(context.Context, any) (string, error)
}{
{"ID", ID},
{"IDTarget", IDTarget},
} {
wantA, err := fn.id(base, idconflicta.Widget.List)
if err != nil {
t.Fatalf("%s(idconflicta.Widget.List): %v", fn.name, err)
}
wantB, err := fn.id(base, idconflictb.Widget.List)
if err != nil {
t.Fatalf("%s(idconflictb.Widget.List): %v", fn.name, err)
}
for _, cur := range []struct {
name string
page *PageNode
}{
{"no current page", nil},
{"current page idconflicta.Widget", aNode},
{"current page idconflictb.Widget", bNode},
} {
ctx := base
if cur.page != nil {
ctx = currentPageCtx.WithValue(base, cur.page)
}
if got, err := fn.id(ctx, b.List); err != nil || got != wantB {
t.Errorf("%s(b.List), %s: got %q, %v; want %q", fn.name, cur.name, got, err, wantB)
}
if got, err := fn.id(ctx, a.List); err != nil || got != wantA {
t.Errorf("%s(a.List), %s: got %q, %v; want %q", fn.name, cur.name, got, err, wantA)
}
}
}
}

// A request for a page's own component Is that component's bound value,
// and not the bound value of a same-named type from another package.
func TestMethodRenderTarget_boundMethodValueCrossPackage(t *testing.T) {
m, ok := reflect.TypeFor[idconflicta.Widget]().MethodByName("List")
if !ok {
t.Fatal("idconflicta.Widget.List not found")
}
target := newMethodRenderTarget("List", &m)
var a idconflicta.Widget
var b idconflictb.Widget
if !target.Is(a.List) {
t.Error("Is(a.List) = false, want true")
}
if target.Is(b.List) {
t.Error("Is(b.List) = true, want false: b is idconflictb.Widget")
}
}

// With htmx, a request served by one Widget page that targets the other
// package's Widget.List region Is that region's bound value, and not the
// served page's own.
func TestHTMXUnmatchedTarget_boundMethodValueCrossPackage(t *testing.T) {
pc, aNode, bNode := widgetNodes(t)
var a idconflicta.Widget
var b idconflictb.Widget
for _, tt := range []struct {
name string
served, want *PageNode
is, isNot any
}{
{"served a, target b", aNode, bNode, b.List, a.List},
{"served b, target a", bNode, aNode, a.List, b.List},
} {
t.Run(tt.name, func(t *testing.T) {
target := newHTMXUnmatchedTarget(pc.componentID(tt.want, "List", true), tt.served, pc)
if !target.Is(tt.is) {
t.Error("Is(other package's Widget.List) = false, want true")
}
if target.Is(tt.isNot) {
t.Error("Is(served page's own Widget.List) = true, want false")
}
})
}
}

// A bound method value's function name ends in "-fm"; a standalone
// function in a package whose path merely contains "-fm" is not one.
func TestID_functionInPackagePathWithFm(t *testing.T) {
pc, err := parsePageTree("/", &idConflictRoot{})
if err != nil {
t.Fatalf("parsePageTree: %v", err)
}
ctx := pcCtx.WithValue(context.Background(), pc)
if got, err := ID(ctx, fnfmt.Badge); err != nil || got != "fn-fmt-badge" {
t.Errorf("ID(fnfmt.Badge) = %q, %v; want %q", got, err, "fn-fmt-badge")
}
}
20 changes: 9 additions & 11 deletions id_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import (
"context"
"strings"
"testing"

"github.com/jackielii/structpages/internal/idconflicta"
)

// A singly-mounted tree for exercising bound-method-value resolution and
Expand Down Expand Up @@ -97,23 +95,23 @@ func TestID_ChainEdgeErrors(t *testing.T) {
}
}

// TestID_BoundMethodAmbiguous covers the isBound name-matching path when the
// same type name is mounted twice (the two cross-package Widget types from
// idConflictRoot in id_cross_package_test.go): a bound method value resolves
// by type name, matches both, and errors with both distinct ids listed.
// TestID_BoundMethodAmbiguous covers the isBound path when one type is
// mounted under two parents (ambBRoot in id_ambiguity_test.go): a bound
// method value matches both mounts, and errors with both distinct ids
// listed, as the method expression does.
func TestID_BoundMethodAmbiguous(t *testing.T) {
pc, err := parsePageTree("/", &idConflictRoot{})
pc, err := parsePageTree("/", &ambBRoot{})
if err != nil {
t.Fatalf("parsePageTree: %v", err)
}
ctx := pcCtx.WithValue(context.Background(), pc)

var inst idconflicta.Widget // bound value; List is a bound method value
_, err = ID(ctx, inst.List)
var inst ambSameName // bound value; Overlays is a bound method value
_, err = ID(ctx, inst.Overlays)
if err == nil {
t.Fatal("expected ambiguity error for bound method on a doubly-mounted type name")
t.Fatal("expected ambiguity error for bound method on a doubly-mounted type")
}
for _, want := range []string{"a-widget-list", "b-widget-list"} {
for _, want := range []string{"foundations-entry-detail-overlays", "components-entry-detail-overlays"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not contain %q", err.Error(), want)
}
Expand Down
25 changes: 7 additions & 18 deletions id_for.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,10 +268,7 @@ func resolvePageForMethod(pc *parseContext, currentPage *PageNode, info *methodI
matches := pc.collectPageNodesForMethod(info)
switch len(matches) {
case 0:
if info.isBound {
return nil, fmt.Errorf("no page node found with type name %q", info.receiverTypeName)
}
return nil, fmt.Errorf("no page node found for type %s", pointerType(info.receiverType).String())
return nil, fmt.Errorf("no page node found for type %s", info.receiverString())
case 1:
return matches[0], nil
}
Expand Down Expand Up @@ -326,38 +323,30 @@ func (p *parseContext) collectPageNodesForMethod(info *methodInfo) []*PageNode {
var out []*PageNode
for node := range p.root.All() {
nodeType := node.Value.Type()
if !info.receiverIs(nodeType) {
continue
}
if info.isBound {
if !sameTypeName(nodeType, info.receiverTypeName) {
continue
}
if _, found := nodeType.MethodByName(info.methodName); !found {
continue
}
} else if pointerType(nodeType) != pointerType(info.receiverType) {
continue
}
out = append(out, node)
}
return out
}

// pageNodeMatchesMethod reports whether pn's value type is the
// receiver type for info (either by reflect.Type or by type name
// for bound method values).
// receiver type for info and has its method.
func pageNodeMatchesMethod(pn *PageNode, info *methodInfo) bool {
if pn == nil {
return false
}
nodeType := pn.Value.Type()
if info.isBound {
if !sameTypeName(nodeType, info.receiverTypeName) {
return false
}
} else if pointerType(nodeType) != pointerType(info.receiverType) {
if !info.receiverIs(nodeType) {
return false
}
// Confirm the method exists on this type — guards against
// matching a same-named type that doesn't have the method.
// Confirm the method exists on this type.
_, found := pointerType(nodeType).MethodByName(info.methodName)
return found
}
Expand Down
19 changes: 19 additions & 0 deletions internal/fn-fmt/badge.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Package fnfmt provides a standalone function component in a package
// whose import path contains "-fm", the suffix the Go compiler gives bound
// method values, to test that its function is not taken for one.
package fnfmt

import (
"context"
"io"
)

type comp struct{ s string }

func (c comp) Render(_ context.Context, w io.Writer) error {
_, err := io.WriteString(w, c.s)
return err
}

// Badge is a standalone function component.
func Badge() comp { return comp{s: "fnfmt.Badge"} }
17 changes: 17 additions & 0 deletions internal/fn-fmt/badge_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package fnfmt

import (
"context"
"strings"
"testing"
)

func TestBadgeRenders(t *testing.T) {
var b strings.Builder
if err := Badge().Render(context.Background(), &b); err != nil {
t.Fatalf("Render: %v", err)
}
if got := b.String(); got != "fnfmt.Badge" {
t.Errorf("rendered %q, want %q", got, "fnfmt.Badge")
}
}
Loading
Loading