1package ui
2
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "image"
8 "os"
9 "path/filepath"
10 "testing"
11
12 "github.com/bnema/nefergui/internal/css"
13 "github.com/bnema/nefergui/internal/edit"
14 "github.com/bnema/nefergui/internal/text"
15)
16
17// The golden is the serialized display list, not a rendered image; each case
18// still checks the intended pseudo-state/event before comparing exact JSON.
19func TestControlDisplaySnapshots(t *testing.T) {
20 catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
21 if err != nil {
22 t.Fatal(err)
23 }
24 controls := []string{"text", "heading", "image", "icon", "separator", "spacer", "button", "checkbox", "radio", "slider", "input", "textarea"}
25 states := []string{"normal", "hover", "focus-visible", "disabled", "checked"}
26 for _, kind := range controls {
27 for _, state := range states {
28 if (state == "checked" && kind != "checkbox" && kind != "radio") || ((state == "focus-visible" || state == "disabled") && kind != "button" && kind != "checkbox" && kind != "radio" && kind != "slider" && kind != "input" && kind != "textarea") {
29 continue
30 }
31 t.Run(kind+"/"+state, func(t *testing.T) {
32 r := newRuntime()
33 r.setTextEngine(text.NewEngine(catalog))
34 r.styles = css.Compile(css.UA(), css.Parse(`text,heading,image,icon,separator,spacer,button,checkbox,radio,slider,input,textarea {width:120px;height:28px;font-size:16px;background:#ddeeff} button:hover,checkbox:hover,radio:hover,slider:hover,input:hover,textarea:hover {background:#aaccee} button:focus-visible,checkbox:focus-visible,radio:focus-visible,slider:focus-visible,input:focus-visible,textarea:focus-visible {outline:2px solid red} button:disabled,checkbox:disabled,radio:disabled,slider:disabled,input:disabled,textarea:disabled {opacity:0.5}`))
35 checked := state == "checked"
36 choice := ""
37 if checked {
38 choice = "yes"
39 }
40 v := 3.0
41 value := "abc"
42 disabled := state == "disabled"
43 view := func(f *Frame) {
44 root := f.Root()
45 switch kind {
46 case "text":
47 root.Text("abc")
48 case "heading":
49 root.Heading("abc")
50 case "image":
51 root.Image(image.NewRGBA(image.Rect(0, 0, 12, 12)))
52 case "icon":
53 root.Icon("star")
54 case "separator":
55 root.Separator()
56 case "spacer":
57 root.Spacer()
58 case "button":
59 root.Button("abc", Disabled(disabled))
60 case "checkbox":
61 root.Checkbox("abc", &checked, Disabled(disabled))
62 case "radio":
63 root.Radio("abc", "yes", &choice, Disabled(disabled))
64 case "slider":
65 root.Slider("abc", &v, 0, 10, 1, Disabled(disabled))
66 case "input":
67 root.Input("abc", &value, Disabled(disabled))
68 case "textarea":
69 root.Textarea("abc", &value, Disabled(disabled))
70 }
71 }
72 r.Build(view)
73 switch state {
74 case "hover":
75 r.route(platformInput{Kind: "motion", X: 2, Y: 2})
76 r.Build(view)
77 if r.state.hover == nil {
78 t.Fatal("no hover")
79 }
80 case "focus-visible":
81 r.route(key("Tab"))
82 r.Build(view)
83 if !r.state.visible {
84 t.Fatal("no keyboard focus")
85 }
86 }
87 data, err := json.MarshalIndent(r.output.Display, "", " ")
88 if err != nil {
89 t.Fatal(err)
90 }
91 assertSnapshot(t, kind+"_"+state, data)
92 })
93 }
94 }
95 for _, name := range []string{"selection-ltr", "selection-arabic", "preedit"} {
96 t.Run("input/"+name, func(t *testing.T) {
97 r := newRuntime()
98 r.setTextEngine(text.NewEngine(catalog))
99 r.styles = css.Compile(css.UA(), css.Parse(`input {width:120px;height:28px;font-size:16px;background:#ddeeff}`))
100 value := "abc"
101 if name == "selection-arabic" {
102 value = "مرحبا"
103 }
104 view := func(f *Frame) { f.Root().Input("", &value) }
105 r.Build(view)
106 r.route(key("Tab"))
107 r.Build(view)
108 s := r.edits[idKey(r.Target(0))]
109 if name == "preedit" {
110 r.route(platformInput{Kind: "ime-done", IME: edit.IMEBatch{Preedit: "é", Begin: len("é")}})
111 r.Build(view)
112 } else {
113 s.Select(0, len(value))
114 r.Redraw()
115 r.Build(view)
116 }
117 data, err := json.MarshalIndent(r.output.Display, "", " ")
118 if err != nil {
119 t.Fatal(err)
120 }
121 assertSnapshot(t, "input_"+name, data)
122 })
123 }
124}
125func assertSnapshot(t *testing.T, name string, data []byte) {
126 t.Helper()
127 path := filepath.Join("testdata", "control-display", name+".json")
128 if os.Getenv("NEFERGUI_WRITE_SNAPSHOTS") == "1" {
129 if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
130 t.Fatal(err)
131 }
132 if err := os.WriteFile(path, append(data, '\n'), 0644); err != nil {
133 t.Fatal(err)
134 }
135 }
136 want, err := os.ReadFile(path)
137 if err != nil {
138 t.Fatalf("missing snapshot %s: %v", name, err)
139 }
140 if !bytes.Equal(bytes.TrimSpace(want), bytes.TrimSpace(data)) {
141 t.Fatalf("snapshot %s changed (got %d bytes, want %d): %s", name, len(data), len(want), fmt.Sprintf("first 120: %s", data[:min(len(data), 120)]))
142 }
143}