test: add benchmarks, Unicode edge cases, and stress tests
Performance benchmarks across all major APIs: - BenchmarkRender (depth 1/3/5/7 tree, full page) - BenchmarkImprint (small/large pipeline) - BenchmarkCompareVariants (2/3 variants) - BenchmarkLayout (C, HCF, HLCRF, nested, many children) - BenchmarkEach (10/100/1000 items) - BenchmarkResponsive, BenchmarkStripTags - Codegen: GenerateClass, TagToClassName, GenerateBundle, GenerateRegistration Edge case tests: - Unicode: emoji, RTL (Arabic/Hebrew), zero-width chars, mixed scripts - Deep nesting: 10/20 levels, mixed slot types - Large Each iterations: 1000/5000 items, nested Each - Layout variant validation: invalid chars, lowercase, duplicates, empty - Nil context handling for Render, Imprint, CompareVariants - Switch no-match, Entitled nil context, empty tag El Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
04067438d9
commit
7efd2ab93a
3 changed files with 821 additions and 0 deletions
290
bench_test.go
Normal file
290
bench_test.go
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
i18n "forge.lthn.ai/core/go-i18n"
|
||||
)
|
||||
|
||||
func init() {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
}
|
||||
|
||||
// --- BenchmarkRender ---
|
||||
|
||||
// buildTree creates an El tree of the given depth with branching factor 3.
|
||||
func buildTree(depth int) Node {
|
||||
if depth <= 0 {
|
||||
return Raw("leaf")
|
||||
}
|
||||
children := make([]Node, 3)
|
||||
for i := range children {
|
||||
children[i] = buildTree(depth - 1)
|
||||
}
|
||||
return El("div", children...)
|
||||
}
|
||||
|
||||
func BenchmarkRender_Depth1(b *testing.B) {
|
||||
node := buildTree(1)
|
||||
ctx := NewContext()
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
node.Render(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkRender_Depth3(b *testing.B) {
|
||||
node := buildTree(3)
|
||||
ctx := NewContext()
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
node.Render(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkRender_Depth5(b *testing.B) {
|
||||
node := buildTree(5)
|
||||
ctx := NewContext()
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
node.Render(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkRender_Depth7(b *testing.B) {
|
||||
node := buildTree(7)
|
||||
ctx := NewContext()
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
node.Render(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkRender_FullPage(b *testing.B) {
|
||||
page := NewLayout("HCF").
|
||||
H(El("h1", Text("Dashboard"))).
|
||||
C(
|
||||
El("div",
|
||||
El("p", Text("Welcome")),
|
||||
Each([]string{"Home", "Settings", "Profile"}, func(item string) Node {
|
||||
return El("a", Raw(item))
|
||||
}),
|
||||
),
|
||||
).
|
||||
F(El("small", Text("Footer")))
|
||||
ctx := NewContext()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
page.Render(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// --- BenchmarkImprint ---
|
||||
|
||||
func BenchmarkImprint_Small(b *testing.B) {
|
||||
page := NewLayout("HCF").
|
||||
H(El("h1", Text("Building project"))).
|
||||
C(El("p", Text("Files deleted successfully"))).
|
||||
F(El("small", Text("Completed")))
|
||||
ctx := NewContext()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
Imprint(page, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkImprint_Large(b *testing.B) {
|
||||
items := make([]string, 20)
|
||||
for i := range items {
|
||||
items[i] = fmt.Sprintf("Item %d was created successfully", i)
|
||||
}
|
||||
page := NewLayout("HLCRF").
|
||||
H(El("h1", Text("Building project"))).
|
||||
L(El("nav", Each(items[:5], func(s string) Node { return El("a", Text(s)) }))).
|
||||
C(El("div", Each(items, func(s string) Node { return El("p", Text(s)) }))).
|
||||
R(El("aside", Text("Completed rendering operation"))).
|
||||
F(El("small", Text("Finished processing all items")))
|
||||
ctx := NewContext()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
Imprint(page, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// --- BenchmarkCompareVariants ---
|
||||
|
||||
func BenchmarkCompareVariants_TwoVariants(b *testing.B) {
|
||||
r := NewResponsive().
|
||||
Variant("desktop", NewLayout("HLCRF").
|
||||
H(El("h1", Text("Building project"))).
|
||||
C(El("p", Text("Files deleted successfully"))).
|
||||
F(El("small", Text("Completed")))).
|
||||
Variant("mobile", NewLayout("HCF").
|
||||
H(El("h1", Text("Building project"))).
|
||||
C(El("p", Text("Files deleted successfully"))).
|
||||
F(El("small", Text("Completed"))))
|
||||
ctx := NewContext()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
CompareVariants(r, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCompareVariants_ThreeVariants(b *testing.B) {
|
||||
r := NewResponsive().
|
||||
Variant("desktop", NewLayout("HLCRF").
|
||||
H(El("h1", Text("Building project"))).
|
||||
L(El("nav", Text("Navigation links"))).
|
||||
C(El("p", Text("Files deleted successfully"))).
|
||||
R(El("aside", Text("Sidebar content"))).
|
||||
F(El("small", Text("Completed")))).
|
||||
Variant("tablet", NewLayout("HCF").
|
||||
H(El("h1", Text("Building project"))).
|
||||
C(El("p", Text("Files deleted successfully"))).
|
||||
F(El("small", Text("Completed")))).
|
||||
Variant("mobile", NewLayout("C").
|
||||
C(El("p", Text("Files deleted successfully"))))
|
||||
ctx := NewContext()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
CompareVariants(r, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// --- BenchmarkLayout ---
|
||||
|
||||
func BenchmarkLayout_ContentOnly(b *testing.B) {
|
||||
layout := NewLayout("C").C(Raw("content"))
|
||||
ctx := NewContext()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
layout.Render(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkLayout_HCF(b *testing.B) {
|
||||
layout := NewLayout("HCF").
|
||||
H(Raw("header")).C(Raw("main")).F(Raw("footer"))
|
||||
ctx := NewContext()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
layout.Render(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkLayout_HLCRF(b *testing.B) {
|
||||
layout := NewLayout("HLCRF").
|
||||
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
|
||||
ctx := NewContext()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
layout.Render(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkLayout_Nested(b *testing.B) {
|
||||
inner := NewLayout("HCF").H(Raw("ih")).C(Raw("ic")).F(Raw("if"))
|
||||
layout := NewLayout("HLCRF").
|
||||
H(Raw("header")).L(inner).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
|
||||
ctx := NewContext()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
layout.Render(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkLayout_ManySlotChildren(b *testing.B) {
|
||||
nodes := make([]Node, 50)
|
||||
for i := range nodes {
|
||||
nodes[i] = El("p", Raw(fmt.Sprintf("paragraph %d", i)))
|
||||
}
|
||||
layout := NewLayout("HLCRF").
|
||||
H(Raw("header")).
|
||||
C(nodes...).
|
||||
F(Raw("footer"))
|
||||
ctx := NewContext()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
layout.Render(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// --- BenchmarkEach ---
|
||||
|
||||
func BenchmarkEach_10(b *testing.B) {
|
||||
benchEach(b, 10)
|
||||
}
|
||||
|
||||
func BenchmarkEach_100(b *testing.B) {
|
||||
benchEach(b, 100)
|
||||
}
|
||||
|
||||
func BenchmarkEach_1000(b *testing.B) {
|
||||
benchEach(b, 1000)
|
||||
}
|
||||
|
||||
func benchEach(b *testing.B, n int) {
|
||||
b.Helper()
|
||||
items := make([]int, n)
|
||||
for i := range items {
|
||||
items[i] = i
|
||||
}
|
||||
node := Each(items, func(i int) Node {
|
||||
return El("li", Raw(fmt.Sprintf("item-%d", i)))
|
||||
})
|
||||
ctx := NewContext()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
node.Render(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// --- BenchmarkResponsive ---
|
||||
|
||||
func BenchmarkResponsive_ThreeVariants(b *testing.B) {
|
||||
r := NewResponsive().
|
||||
Variant("desktop", NewLayout("HLCRF").H(Raw("h")).L(Raw("l")).C(Raw("c")).R(Raw("r")).F(Raw("f"))).
|
||||
Variant("tablet", NewLayout("HCF").H(Raw("h")).C(Raw("c")).F(Raw("f"))).
|
||||
Variant("mobile", NewLayout("C").C(Raw("c")))
|
||||
ctx := NewContext()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
r.Render(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// --- BenchmarkStripTags ---
|
||||
|
||||
func BenchmarkStripTags_Short(b *testing.B) {
|
||||
input := `<div>hello</div>`
|
||||
for b.Loop() {
|
||||
StripTags(input)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkStripTags_Long(b *testing.B) {
|
||||
layout := NewLayout("HLCRF").
|
||||
H(Raw("header content")).L(Raw("left sidebar")).
|
||||
C(Raw("main body content with multiple words")).
|
||||
R(Raw("right sidebar")).F(Raw("footer content"))
|
||||
input := layout.Render(NewContext())
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
StripTags(input)
|
||||
}
|
||||
}
|
||||
46
codegen/bench_test.go
Normal file
46
codegen/bench_test.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package codegen
|
||||
|
||||
import "testing"
|
||||
|
||||
func BenchmarkGenerateClass(b *testing.B) {
|
||||
for b.Loop() {
|
||||
GenerateClass("photo-grid", "C")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkTagToClassName(b *testing.B) {
|
||||
for b.Loop() {
|
||||
TagToClassName("my-super-widget-component")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGenerateBundle_Small(b *testing.B) {
|
||||
slots := map[string]string{
|
||||
"H": "nav-bar",
|
||||
"C": "main-content",
|
||||
}
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
GenerateBundle(slots)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGenerateBundle_Full(b *testing.B) {
|
||||
slots := map[string]string{
|
||||
"H": "nav-bar",
|
||||
"L": "side-panel",
|
||||
"C": "main-content",
|
||||
"R": "aside-widget",
|
||||
"F": "page-footer",
|
||||
}
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
GenerateBundle(slots)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGenerateRegistration(b *testing.B) {
|
||||
for b.Loop() {
|
||||
GenerateRegistration("photo-grid", "PhotoGrid")
|
||||
}
|
||||
}
|
||||
485
edge_test.go
Normal file
485
edge_test.go
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
package html
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
i18n "forge.lthn.ai/core/go-i18n"
|
||||
)
|
||||
|
||||
// --- Unicode / RTL edge cases ---
|
||||
|
||||
func TestText_Emoji(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"simple emoji", "\U0001F680"},
|
||||
{"emoji sequence", "\U0001F468\u200D\U0001F4BB"},
|
||||
{"mixed text and emoji", "Hello \U0001F30D World"},
|
||||
{"flag emoji", "\U0001F1EC\U0001F1E7"},
|
||||
{"emoji in sentence", "Status: \u2705 Complete"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
node := Text(tt.input)
|
||||
got := node.Render(ctx)
|
||||
if got == "" {
|
||||
t.Error("Text with emoji should not produce empty output")
|
||||
}
|
||||
// Emoji should pass through (they are not HTML special chars)
|
||||
if !strings.Contains(got, tt.input) {
|
||||
// Some chars may get escaped, but emoji bytes should survive
|
||||
t.Logf("note: emoji text rendered as %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEl_Emoji(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := El("span", Raw("\U0001F680 Launch"))
|
||||
got := node.Render(ctx)
|
||||
want := "<span>\U0001F680 Launch</span>"
|
||||
if got != want {
|
||||
t.Errorf("El with emoji = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestText_RTL(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"Arabic", "\u0645\u0631\u062D\u0628\u0627"},
|
||||
{"Hebrew", "\u05E9\u05DC\u05D5\u05DD"},
|
||||
{"mixed LTR and RTL", "Hello \u0645\u0631\u062D\u0628\u0627 World"},
|
||||
{"Arabic with numbers", "\u0627\u0644\u0639\u062F\u062F 42"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
node := Text(tt.input)
|
||||
got := node.Render(ctx)
|
||||
if got == "" {
|
||||
t.Error("Text with RTL content should not produce empty output")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEl_RTL(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Attr(El("div", Raw("\u0645\u0631\u062D\u0628\u0627")), "dir", "rtl")
|
||||
got := node.Render(ctx)
|
||||
if !strings.Contains(got, `dir="rtl"`) {
|
||||
t.Errorf("RTL element missing dir attribute in: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "\u0645\u0631\u062D\u0628\u0627") {
|
||||
t.Errorf("RTL element missing Arabic text in: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestText_ZeroWidth(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"zero-width space", "hello\u200Bworld"},
|
||||
{"zero-width joiner", "hello\u200Dworld"},
|
||||
{"zero-width non-joiner", "hello\u200Cworld"},
|
||||
{"soft hyphen", "super\u00ADcalifragilistic"},
|
||||
{"BOM character", "\uFEFFhello"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
node := Text(tt.input)
|
||||
got := node.Render(ctx)
|
||||
if got == "" {
|
||||
t.Error("Text with zero-width characters should not produce empty output")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestText_MixedScripts(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
ctx := NewContext()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"Latin + CJK", "Hello \u4F60\u597D"},
|
||||
{"Latin + Cyrillic", "Hello \u041F\u0440\u0438\u0432\u0435\u0442"},
|
||||
{"CJK + Arabic", "\u4F60\u597D \u0645\u0631\u062D\u0628\u0627"},
|
||||
{"Latin + Devanagari", "Hello \u0928\u092E\u0938\u094D\u0924\u0947"},
|
||||
{"Latin + Thai", "Hello \u0E2A\u0E27\u0E31\u0E2A\u0E14\u0E35"},
|
||||
{"all scripts mixed", "EN \u4F60\u597D \u0645\u0631\u062D\u0628\u0627 \u041F\u0440\u0438\u0432\u0435\u0442 \U0001F30D"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
node := Text(tt.input)
|
||||
got := node.Render(ctx)
|
||||
if got == "" {
|
||||
t.Error("Text with mixed scripts should not produce empty output")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripTags_Unicode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"emoji in tags", "<span>\U0001F680</span>", "\U0001F680"},
|
||||
{"RTL in tags", "<div>\u0645\u0631\u062D\u0628\u0627</div>", "\u0645\u0631\u062D\u0628\u0627"},
|
||||
{"CJK in tags", "<p>\u4F60\u597D\u4E16\u754C</p>", "\u4F60\u597D\u4E16\u754C"},
|
||||
{"mixed unicode regions", "<header>\U0001F680</header><main>\u4F60\u597D</main>", "\U0001F680 \u4F60\u597D"},
|
||||
{"zero-width in tags", "<span>a\u200Bb</span>", "a\u200Bb"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := StripTags(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("StripTags(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttr_UnicodeValue(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Attr(El("div"), "title", "\U0001F680 Rocket Launch")
|
||||
got := node.Render(ctx)
|
||||
want := "title=\"\U0001F680 Rocket Launch\""
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("attribute with emoji should be preserved, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Deep nesting stress tests ---
|
||||
|
||||
func TestLayout_DeepNesting_10Levels(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
// Build 10 levels of nested layouts
|
||||
current := NewLayout("C").C(Raw("deepest"))
|
||||
for i := 0; i < 9; i++ {
|
||||
current = NewLayout("C").C(current)
|
||||
}
|
||||
|
||||
got := current.Render(ctx)
|
||||
|
||||
// Should contain the deepest content
|
||||
if !strings.Contains(got, "deepest") {
|
||||
t.Error("10 levels deep: missing leaf content")
|
||||
}
|
||||
|
||||
// Should have 10 levels of C-0 nesting
|
||||
expectedBlock := "C-0"
|
||||
for i := 1; i < 10; i++ {
|
||||
expectedBlock += "-C-0"
|
||||
}
|
||||
if !strings.Contains(got, fmt.Sprintf(`data-block="%s"`, expectedBlock)) {
|
||||
t.Errorf("10 levels deep: missing expected block ID %q in:\n%s", expectedBlock, got)
|
||||
}
|
||||
|
||||
// Must have exactly 10 <main> tags
|
||||
if count := strings.Count(got, "<main"); count != 10 {
|
||||
t.Errorf("10 levels deep: expected 10 <main> tags, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_DeepNesting_20Levels(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
current := NewLayout("C").C(Raw("bottom"))
|
||||
for i := 0; i < 19; i++ {
|
||||
current = NewLayout("C").C(current)
|
||||
}
|
||||
|
||||
got := current.Render(ctx)
|
||||
|
||||
if !strings.Contains(got, "bottom") {
|
||||
t.Error("20 levels deep: missing leaf content")
|
||||
}
|
||||
if count := strings.Count(got, "<main"); count != 20 {
|
||||
t.Errorf("20 levels deep: expected 20 <main> tags, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_DeepNesting_MixedSlots(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
// Alternate slot types at each level: C -> L -> C -> L -> ...
|
||||
current := NewLayout("C").C(Raw("leaf"))
|
||||
for i := 0; i < 5; i++ {
|
||||
if i%2 == 0 {
|
||||
current = NewLayout("HLCRF").L(current)
|
||||
} else {
|
||||
current = NewLayout("HCF").C(current)
|
||||
}
|
||||
}
|
||||
|
||||
got := current.Render(ctx)
|
||||
if !strings.Contains(got, "leaf") {
|
||||
t.Error("mixed deep nesting: missing leaf content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEach_LargeIteration_1000(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
items := make([]int, 1000)
|
||||
for i := range items {
|
||||
items[i] = i
|
||||
}
|
||||
|
||||
node := Each(items, func(i int) Node {
|
||||
return El("li", Raw(fmt.Sprintf("%d", i)))
|
||||
})
|
||||
|
||||
got := node.Render(ctx)
|
||||
|
||||
if count := strings.Count(got, "<li>"); count != 1000 {
|
||||
t.Errorf("Each with 1000 items: expected 1000 <li>, got %d", count)
|
||||
}
|
||||
if !strings.Contains(got, "<li>0</li>") {
|
||||
t.Error("Each with 1000 items: missing first item")
|
||||
}
|
||||
if !strings.Contains(got, "<li>999</li>") {
|
||||
t.Error("Each with 1000 items: missing last item")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEach_LargeIteration_5000(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
items := make([]int, 5000)
|
||||
for i := range items {
|
||||
items[i] = i
|
||||
}
|
||||
|
||||
node := Each(items, func(i int) Node {
|
||||
return El("span", Raw(fmt.Sprintf("%d", i)))
|
||||
})
|
||||
|
||||
got := node.Render(ctx)
|
||||
|
||||
if count := strings.Count(got, "<span>"); count != 5000 {
|
||||
t.Errorf("Each with 5000 items: expected 5000 <span>, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEach_NestedEach(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
rows := []int{0, 1, 2}
|
||||
cols := []string{"a", "b", "c"}
|
||||
|
||||
node := Each(rows, func(row int) Node {
|
||||
return El("tr", Each(cols, func(col string) Node {
|
||||
return El("td", Raw(fmt.Sprintf("%d-%s", row, col)))
|
||||
}))
|
||||
})
|
||||
|
||||
got := node.Render(ctx)
|
||||
|
||||
if count := strings.Count(got, "<tr>"); count != 3 {
|
||||
t.Errorf("nested Each: expected 3 <tr>, got %d", count)
|
||||
}
|
||||
if count := strings.Count(got, "<td>"); count != 9 {
|
||||
t.Errorf("nested Each: expected 9 <td>, got %d", count)
|
||||
}
|
||||
if !strings.Contains(got, "1-b") {
|
||||
t.Error("nested Each: missing cell content '1-b'")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Layout variant validation ---
|
||||
|
||||
func TestLayout_InvalidVariant_Chars(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
variant string
|
||||
}{
|
||||
{"all invalid", "XYZ"},
|
||||
{"lowercase valid", "hlcrf"},
|
||||
{"numbers", "123"},
|
||||
{"special chars", "!@#"},
|
||||
{"mixed valid and invalid", "HXC"},
|
||||
{"empty string", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
layout := NewLayout(tt.variant).
|
||||
H(Raw("header")).L(Raw("left")).C(Raw("main")).R(Raw("right")).F(Raw("footer"))
|
||||
got := layout.Render(ctx)
|
||||
|
||||
// Invalid variant chars should silently produce no output for those slots
|
||||
// This documents the current behaviour: no panic, no error.
|
||||
if tt.variant == "XYZ" || tt.variant == "hlcrf" || tt.variant == "123" ||
|
||||
tt.variant == "!@#" || tt.variant == "" {
|
||||
if got != "" {
|
||||
t.Errorf("NewLayout(%q) with all invalid chars should produce empty output, got %q", tt.variant, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_InvalidVariant_MixedValidInvalid(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
// "HXC" — H and C are valid, X is not. Only H and C should render.
|
||||
layout := NewLayout("HXC").
|
||||
H(Raw("header")).C(Raw("main"))
|
||||
got := layout.Render(ctx)
|
||||
|
||||
if !strings.Contains(got, "header") {
|
||||
t.Errorf("HXC variant should render H slot, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "main") {
|
||||
t.Errorf("HXC variant should render C slot, got:\n%s", got)
|
||||
}
|
||||
// Should only have 2 semantic elements
|
||||
if count := strings.Count(got, "data-block="); count != 2 {
|
||||
t.Errorf("HXC variant should produce 2 blocks, got %d in:\n%s", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_DuplicateVariantChars(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
// "CCC" — C appears three times. Should render C slot content three times.
|
||||
layout := NewLayout("CCC").C(Raw("content"))
|
||||
got := layout.Render(ctx)
|
||||
|
||||
count := strings.Count(got, "content")
|
||||
if count != 3 {
|
||||
t.Errorf("CCC variant should render C slot 3 times, got %d occurrences in:\n%s", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayout_EmptySlots(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
|
||||
// Variant includes all slots but none are populated — should produce empty output.
|
||||
layout := NewLayout("HLCRF")
|
||||
got := layout.Render(ctx)
|
||||
|
||||
if got != "" {
|
||||
t.Errorf("layout with no slot content should produce empty output, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Render convenience function edge cases ---
|
||||
|
||||
func TestRender_NilContext(t *testing.T) {
|
||||
node := Raw("test")
|
||||
got := Render(node, nil)
|
||||
if got != "test" {
|
||||
t.Errorf("Render with nil context = %q, want %q", got, "test")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImprint_NilContext(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
|
||||
node := NewLayout("C").C(El("p", Text("Building project")))
|
||||
imp := Imprint(node, nil)
|
||||
|
||||
if imp.TokenCount == 0 {
|
||||
t.Error("Imprint with nil context should still produce tokens")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareVariants_NilContext(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
|
||||
r := NewResponsive().
|
||||
Variant("a", NewLayout("C").C(Text("Building project"))).
|
||||
Variant("b", NewLayout("C").C(Text("Building project")))
|
||||
|
||||
scores := CompareVariants(r, nil)
|
||||
if _, ok := scores["a:b"]; !ok {
|
||||
t.Error("CompareVariants with nil context should still produce scores")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareVariants_SingleVariant(t *testing.T) {
|
||||
svc, _ := i18n.New()
|
||||
i18n.SetDefault(svc)
|
||||
|
||||
r := NewResponsive().
|
||||
Variant("only", NewLayout("C").C(Text("Building project")))
|
||||
|
||||
scores := CompareVariants(r, NewContext())
|
||||
if len(scores) != 0 {
|
||||
t.Errorf("CompareVariants with single variant should produce no pairs, got %d", len(scores))
|
||||
}
|
||||
}
|
||||
|
||||
// --- escapeHTML / escapeAttr edge cases ---
|
||||
|
||||
func TestEscapeAttr_AllSpecialChars(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := Attr(El("div"), "data-val", `&<>"'`)
|
||||
got := node.Render(ctx)
|
||||
|
||||
if strings.Contains(got, `"&<>"'"`) {
|
||||
t.Error("attribute value with special chars must be fully escaped")
|
||||
}
|
||||
if !strings.Contains(got, "&<>"'") {
|
||||
t.Errorf("expected all special chars escaped in attribute, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestElNode_EmptyTag(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
node := El("", Raw("content"))
|
||||
got := node.Render(ctx)
|
||||
|
||||
// Empty tag is weird but should not panic
|
||||
if !strings.Contains(got, "content") {
|
||||
t.Errorf("El with empty tag should still render children, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchNode_NoMatch(t *testing.T) {
|
||||
ctx := NewContext()
|
||||
cases := map[string]Node{
|
||||
"a": Raw("alpha"),
|
||||
"b": Raw("beta"),
|
||||
}
|
||||
node := Switch(func(*Context) string { return "c" }, cases)
|
||||
got := node.Render(ctx)
|
||||
if got != "" {
|
||||
t.Errorf("Switch with no matching case should produce empty string, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntitled_NilContext(t *testing.T) {
|
||||
node := Entitled("premium", Raw("content"))
|
||||
got := node.Render(nil)
|
||||
if got != "" {
|
||||
t.Errorf("Entitled with nil context should produce empty string, got %q", got)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue