1//go:build linux
2
3package systemtest
4
5import (
6 "context"
7 "encoding/json"
8 "fmt"
9 "image"
10 "math"
11 "os"
12 "path/filepath"
13 "regexp"
14 "sort"
15 "testing"
16 "time"
17
18 "github.com/bnema/nefergui/internal/css"
19 "github.com/bnema/nefergui/internal/harness"
20 "github.com/bnema/nefergui/internal/layout"
21 "github.com/bnema/nefergui/internal/presentation/vkdevice"
22 "github.com/bnema/nefergui/internal/render"
23 "github.com/bnema/nefergui/internal/text"
24)
25
26const sampleText = "office سلام 日本語"
27
28func TestListText(t *testing.T) {
29 requireHarness(t)
30 bin := binary(t)
31 catalog, err := text.Load(text.DirectorySource("../../../testdata/fonts"))
32 if err != nil {
33 t.Fatal(err)
34 }
35 measured, err := text.NewEngine(catalog).Measure(sampleText, text.Request{Families: []string{"Noto Sans"}, Size: 32}, 0)
36 if err != nil {
37 t.Fatal(err)
38 }
39 cmd := layout.Command{Op: "text", Color: css.Color{R: 1, G: 1, B: 1, A: 1}, Opacity: 1}
40 for _, line := range measured.Lines {
41 for _, shaped := range line.Runs {
42 r := layout.Run{Face: shaped.Face, FaceID: shaped.Face.ID, Size: 32}
43 for _, g := range shaped.Glyphs {
44 r.Glyphs = append(r.Glyphs, layout.Glyph{ID: uint32(g.ID), X: 20 + g.X, Y: 40 + g.Y})
45 }
46 cmd.Runs = append(cmd.Runs, r)
47 }
48 }
49 for _, scale := range []int{1, 2} {
50 t.Run(fmt.Sprint(scale), func(t *testing.T) {
51 prep, err := render.NewPreparer(vkdevice.AtlasSize, vkdevice.AtlasPages)
52 if err != nil {
53 t.Fatal(err)
54 }
55 frame, err := prep.Prepare([]layout.Command{cmd}, float64(scale), 800, 600)
56 if err != nil {
57 t.Fatal(err)
58 }
59 if len(frame.Quads) == 0 || len(frame.Uploads) == 0 {
60 t.Fatal("no glyphs or uploads")
61 }
62 root := filepath.Join(t.TempDir(), "artifacts")
63 res, code := harness.Run(context.Background(), harness.Options{Size: "800x600", Scale: "1", Layout: "us", Background: background, Out: root, Timeout: 60 * time.Second, ReadyTimeout: 10 * time.Second, Client: []string{bin, "--static", "--text", sampleText, "--scale", fmt.Sprint(scale), "--frames", "120"}})
64 t.Logf("artifacts=%s code=%d status=%s", root, code, res.Status)
65 if code != harness.Pass {
66 data, _ := os.ReadFile(filepath.Join(root, "logs", "client.stderr"))
67 t.Fatalf("harness: %s: %s", res.Error, data)
68 }
69 files, _ := filepath.Glob(filepath.Join(res.Artifacts["debug"], "readback-*.png"))
70 sort.Strings(files)
71 if len(files) == 0 {
72 t.Fatal("no readback")
73 }
74 img, err := harness.LoadPNG(files[len(files)-1])
75 if err != nil {
76 t.Fatal(err)
77 }
78 var sum summary
79 data, err := os.ReadFile(filepath.Join(res.Artifacts["debug"], "resource-summary.json"))
80 if err != nil {
81 t.Fatal(err)
82 }
83 if err = json.Unmarshal(data, &sum); err != nil {
84 t.Fatal(err)
85 }
86 if sum.Frames != 120 || sum.ImagesAfter != 0 || sum.FDPeak-sum.FDStart > 2 || sum.FDAfter > sum.FDStart {
87 t.Fatalf("resource counts: %+v", sum)
88 }
89 timing := jsonLines[timing](t, filepath.Join(res.Artifacts["debug"], "frame-timings.jsonl"))
90 if len(timing) != 120 {
91 t.Fatalf("timings=%d", len(timing))
92 }
93 for _, row := range timing {
94 if row.Images < 1 || row.Images > 6 || row.FDCount > sum.FDStart+2 {
95 t.Fatalf("unstable frame: %+v", row)
96 }
97 }
98 if os.Getenv("NEFERGUI_VULKAN_VALIDATION") == "1" {
99 data, err := os.ReadFile(res.Artifacts["client_stderr"])
100 if err != nil {
101 t.Fatal(err)
102 }
103 if regexp.MustCompile(`(?i)(NEFERGUI_VULKAN_VALIDATION_(ERROR|WARNING)|validation (error|warning))`).Match(data) {
104 t.Fatalf("validation: %s", data)
105 }
106 }
107 // Reconstruct the CPU coverage directly from the same rasterized masks
108 // that Prepare packed into atlas upload rectangles.
109 pages := make([][]byte, vkdevice.AtlasPages)
110 for i := range pages {
111 pages[i] = make([]byte, vkdevice.AtlasSize*vkdevice.AtlasSize)
112 }
113 for _, u := range frame.Uploads {
114 for y := 0; y < u.Rect.Dy(); y++ {
115 copy(pages[u.Page][(u.Rect.Min.Y+y)*vkdevice.AtlasSize+u.Rect.Min.X:], u.Bytes[y*u.Rect.Dx():(y+1)*u.Rect.Dx()])
116 }
117 }
118 var box image.Rectangle
119 expected := make([]byte, 800*600)
120 for _, q := range frame.Quads {
121 if q.Op != "glyph" {
122 continue
123 }
124 r := image.Rect(int(q.Rect.X), int(q.Rect.Y), int(q.Rect.X+q.Rect.W), int(q.Rect.Y+q.Rect.H))
125 box = box.Union(r)
126 for y := 0; y < r.Dy(); y++ {
127 for x := 0; x < r.Dx(); x++ {
128 expected[(r.Min.Y+y)*800+r.Min.X+x] = pages[q.Glyph.Page][(q.Glyph.Rect.Min.Y+y)*vkdevice.AtlasSize+q.Glyph.Rect.Min.X+x]
129 }
130 }
131 }
132 var ink image.Rectangle
133 cpu, gpu := 0.0, 0.0
134 for y := 0; y < 600; y++ {
135 for x := 0; x < 800; x++ {
136 p := img.At(x, y)
137 r16, g16, b16, a16 := p.RGBA()
138 r, g, b, a := r16>>8, g16>>8, b16>>8, a16>>8
139 if expected[y*800+x] > 0 {
140 cpu += float64(expected[y*800+x]) / 255
141 }
142 if !image.Pt(x, y).In(box) && (r != 17 || g != 17 || b != 17 || a != 255) {
143 t.Fatalf("outside ink box at %d,%d: %v", x, y, p)
144 }
145 if r > 17 {
146 gpu += float64(r-17) / 238
147 ink = ink.Union(image.Rect(x, y, x+1, y+1))
148 }
149 }
150 }
151 if ink.Empty() || math.Abs(float64(ink.Min.X-box.Min.X)) > 2 || math.Abs(float64(ink.Min.Y-box.Min.Y)) > 2 || math.Abs(float64(ink.Max.X-box.Max.X)) > 2 || math.Abs(float64(ink.Max.Y-box.Max.Y)) > 2 || math.Abs(gpu-cpu) > cpu*.1 {
152 t.Fatalf("ink=%v measure/raster box=%v coverage gpu=%.2f cpu=%.2f", ink, box, gpu, cpu)
153 }
154 t.Logf("scale=%d ink=%v measure/raster box=%v gpu coverage=%.2f cpu coverage=%.2f resources=%+v", scale, ink, box, gpu, cpu, sum)
155 })
156 }
157}