package layout import ( "image" "reflect" "slices" "testing" "github.com/bnema/nefergui/internal/css" ) // Layout output is detached: neither the shared paint storage nor a later // Layout call, nor a mutation of the input style, may change earlier results. func TestPaintOutputDetached(t *testing.T) { root, opts := paintFixture(t, 6) first, err := Layout(root, opts) if err != nil { t.Fatal(err) } want := deepCopyDisplay(first.Display) shadows := 0 for _, cmd := range first.Display { if cmd.Shadow != nil { shadows++ } } if shadows != 12 { t.Fatalf("fixture shadows=%d, want 12", shadows) } // Mutating the input styles must not reach painted shadows. for _, card := range root.Children { card.Style.BoxShadow[0].Blur = px(99) } second, err := Layout(root, opts) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(first.Display, want) { t.Fatal("first output changed after style mutation and second Layout") } if reflect.DeepEqual(second.Display, want) { t.Fatal("second output should reflect the mutated style") } // Writing through one command's storage must not change any other. for i := range second.Display { if second.Display[i].Shadow != nil { second.Display[i].Shadow.X = px(-7) } } if !reflect.DeepEqual(first.Display, want) { t.Fatal("outputs share shadow storage") } } func deepCopyDisplay(in []Command) []Command { out := make([]Command, len(in)) for i, c := range in { if c.Shadow != nil { s := *c.Shadow c.Shadow = &s } c.Runs = append([]Run(nil), c.Runs...) for j := range c.Runs { c.Runs[j].Glyphs = append([]Glyph(nil), c.Runs[j].Glyphs...) } out[i] = c } return out } // Runs and glyphs come from shared blocks; a caller appending to one must // reallocate rather than overwrite the next run or command. func TestPaintRunAndGlyphCapacityIsolated(t *testing.T) { root, opts := paintFixture(t, 3) out, err := Layout(root, opts) if err != nil { t.Fatal(err) } want := deepCopyDisplay(out.Display) texts := 0 for i := range out.Display { cmd := &out.Display[i] if cmd.Op != "text" { continue } texts++ if len(cmd.Runs) == 0 || cap(cmd.Runs) != len(cmd.Runs) { t.Fatalf("runs len=%d cap=%d", len(cmd.Runs), cap(cmd.Runs)) } for j := range cmd.Runs { if g := cmd.Runs[j].Glyphs; len(g) == 0 || cap(g) != len(g) { t.Fatalf("glyphs len=%d cap=%d", len(g), cap(g)) } } _ = append(cmd.Runs, Run{Start: -1}) for j := range cmd.Runs { _ = append(cmd.Runs[j].Glyphs, Glyph{ID: 0xdead}) } } if texts != 3 || !reflect.DeepEqual(out.Display, want) { t.Fatal("append through a command overwrote neighbouring data") } } // countCommands must match paint exactly, otherwise the single-allocation // guarantee silently degrades. func TestPaintCountsMatch(t *testing.T) { root, opts := paintFixture(t, 10) root.Children = append(root.Children, nil, &Node{ID: "hidden", Kind: Text, Content: "hidden", Style: &css.Style{Display: css.KeywordNone}}, &Node{ID: "image", Kind: Image, Image: image.NewRGBA(image.Rect(0, 0, 2, 2)), Style: &css.Style{Display: css.KeywordBlock, Width: px(20), Height: px(20)}}, &Node{ID: "outline", Kind: Box, Style: &css.Style{Display: css.KeywordBlock, Width: px(20), Height: px(20), OutlineStyle: css.KeywordSolid, OutlineWidth: px(2)}}, &Node{ID: "radius", Kind: Box, Style: &css.Style{Display: css.KeywordBlock, Width: px(20), Height: px(20), BorderRadius: css.Radii{TopLeft: px(2)}}}, ) ctx := newContext(opts) tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0) if err != nil { t.Fatal(err) } n := countCommands(root, tree) out := ctx.display(root, tree) runs, glyphs, shadows := 0, 0, 0 for _, cmd := range out { runs += len(cmd.Runs) for _, r := range cmd.Runs { glyphs += len(r.Glyphs) } if cmd.Shadow != nil { shadows++ } } if n.commands != len(out) || n.runs != runs || n.glyphs != glyphs || n.shadows != shadows { t.Fatalf("counts %+v vs commands=%d runs=%d glyphs=%d shadows=%d", n, len(out), runs, glyphs, shadows) } // An empty arena sizes each block once from the counts: one allocation // each for commands, shadows, runs and glyphs, with no growth. if raceEnabled { return } if got := testing.AllocsPerRun(10, func() { *ctx.arena = Arena{} ctx.display(root, tree) }); got != 4 { t.Fatalf("fresh display allocs=%v, want 4", got) } } func TestPaintAllocations(t *testing.T) { root, opts := paintFixture(t, 50) ctx := newContext(opts) tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0) if err != nil { t.Fatal(err) } // The arena's blocks are reused, so a repeated paint allocates nothing. if n := testing.AllocsPerRun(20, func() { ctx.display(root, tree) }); n != 0 { t.Fatalf("paint allocs=%v, want 0", n) } } // TestLayoutArenaReuse checks that a steady layout into a reused arena // allocates nothing, and that alternating two arenas keeps the previous // Output intact while the next one is built. func TestLayoutArenaReuse(t *testing.T) { root, opts := paintFixture(t, 20) var arenas [2]Arena layout := func(i int) Output { o := opts o.Arena = &arenas[i] out, err := Layout(root, o) if err != nil { t.Fatal(err) } return out } prev := layout(0) layout(1) wantID, wantX := prev.Tree.Children[3].ID, prev.Display[5].Rect.X root.Children[3].ID = "changed" layout(1) if prev.Tree.Children[3].ID != wantID || prev.Display[5].Rect.X != wantX { t.Fatal("building into one arena changed the Output of the other") } if n := testing.AllocsPerRun(20, func() { layout(1) }); n != 0 { t.Fatalf("steady layout allocs=%v, want 0", n) } // A list the caller grew with many decorations and handed back through // Keep is recycled, not dropped as oversized for the layout alone. decorations := make([]Command, 1000) // far beyond retainSlack times the layout's own decorate := func() { out := layout(1) arenas[1].Keep(slices.Insert(out.Display, 0, decorations...)) } decorate() if n := testing.AllocsPerRun(20, decorate); n != 0 { t.Fatalf("decorated layout allocs=%v, want 0", n) } // The runtime may lay out twice before one Keep (caret scrolling). if n := testing.AllocsPerRun(20, func() { layout(1); decorate() }); n != 0 { t.Fatalf("relayout before keep allocs=%v, want 0", n) } // Once decorations go away, the large list is released. decorated := cap(arenas[1].commands) arenas[1].Keep(layout(1).Display) if layout(1); cap(arenas[1].commands) >= decorated { t.Fatalf("decorated list kept after decorations went away: cap %d", cap(arenas[1].commands)) } // Growth falls back to individual allocations once, then fits. for range 30 { root.Children = append(root.Children, root.Children[0]) } layout(1) if n := testing.AllocsPerRun(20, func() { layout(1) }); n != 0 { t.Fatalf("allocs after growth=%v, want 0", n) } } func TestPaintEmptyAndShadowOrder(t *testing.T) { out, err := Layout(&Node{ID: "n", Kind: Box, Style: &css.Style{Display: css.KeywordBlock}}, Options{Width: 10, Height: 10}) if err != nil || out.Display != nil { t.Fatalf("empty paint: %+v %v", out.Display, err) } s := css.Style{Display: css.KeywordBlock, Width: px(10), Height: px(10), BackgroundColor: css.Color{A: 1}, BoxShadow: []css.Shadow{ {Inset: true, Blur: px(1)}, {Blur: px(2)}, {Blur: px(3)}, {Inset: true, Blur: px(4)}, }} out, err = Layout(node("n", Box, s), Options{Width: 10, Height: 10}) if err != nil { t.Fatal(err) } var got []float64 var ops []string for _, c := range out.Display { ops = append(ops, c.Op) if c.Shadow != nil { got = append(got, c.Shadow.Blur.Value) } } if !reflect.DeepEqual(got, []float64{2, 3, 1, 4}) || !reflect.DeepEqual(ops, []string{"shadow", "shadow", "rect", "shadow", "shadow"}) { t.Fatalf("order ops=%v blur=%v", ops, got) } }