v0.1.0 47aaa0f

demo_test.go RawHistoryFolder

1//go:build linux
2
3package systemtest
4
5import (
6	"context"
7	"encoding/json"
8	"fmt"
9	"image"
10	"image/color"
11	"os"
12	"os/exec"
13	"path/filepath"
14	"strings"
15	"testing"
16	"time"
17
18	"github.com/bnema/nefergui/internal/harness"
19)
20
21func demoBinary(t *testing.T) string {
22	t.Helper()
23	bin := filepath.Join(t.TempDir(), "demo")
24	cmd := exec.Command("go", "build", "-o", bin, "./examples/demo")
25	cmd.Dir = "../../.."
26	cmd.Env = append(os.Environ(), "GOWORK=off", "CGO_ENABLED=0")
27	if out, err := cmd.CombinedOutput(); err != nil {
28		t.Fatalf("build demo: %v %s", err, out)
29	}
30	return bin
31}
32func runDemo(t *testing.T, bin string, scale, frames int, input string, minDuration time.Duration) harness.Result {
33	t.Helper()
34	root := filepath.Join(t.TempDir(), "artifacts")
35	client := []string{bin, "--static", "--frames", fmt.Sprint(frames), "--min-duration", minDuration.String()}
36	res, code := harness.Run(context.Background(), harness.Options{Size: fmt.Sprintf("%dx%d", 960*scale, 640*scale), Scale: fmt.Sprint(scale), Layout: "us", Background: background, Out: root, Input: input, Timeout: 60 * time.Second, ReadyTimeout: 10 * time.Second, Client: client})
37	t.Logf("scale=%d frames=%d code=%d artifacts=%s", scale, frames, code, root)
38	if code != harness.Pass {
39		b, _ := os.ReadFile(filepath.Join(root, "logs", "client.stderr"))
40		t.Fatalf("harness: %s stderr=%s", res.Error, b)
41	}
42	return res
43}
44func TestDemoStatic(t *testing.T) {
45	requireHarness(t)
46	bin := demoBinary(t)
47	for _, scale := range []int{1, 2} {
48		t.Run(fmt.Sprint(scale), func(t *testing.T) {
49			res := runDemo(t, bin, scale, 24, "", 0)
50			files, err := filepath.Glob(filepath.Join(res.Artifacts["debug"], "readback-*.png"))
51			if err != nil || len(files) == 0 {
52				t.Fatalf("readback files: %v %v", files, err)
53			}
54			img, err := harness.LoadPNG(files[len(files)-1])
55			if err != nil {
56				t.Fatal(err)
57			}
58			if img.Bounds().Dx() != 960*scale || img.Bounds().Dy() != 640*scale {
59				t.Fatalf("size %v", img.Bounds())
60			}
61			for _, p := range []struct {
62				name string
63				x, y int
64				want color.NRGBA
65			}{
66				{"background", 5, 5, color.NRGBA{255, 255, 255, 255}},
67				{"button", 740, 185, color.NRGBA{212, 212, 216, 255}},
68			} {
69				x, y := p.x*scale, p.y*scale
70				t.Logf("%s (%d,%d)=%v", p.name, x, y, color.NRGBAModel.Convert(img.At(x, y)))
71				if err := harness.PixelProbe(img, x, y, p.want, 8); err != nil {
72					t.Error(err)
73				}
74			}
75			// Labels must reach the screen. Each region lies inside one control
76			// fill: most pixels must match it (the control is where expected) and
77			// enough must differ (glyph ink). Background probes alone once passed
78			// with every control label missing.
79			for _, l := range []struct {
80				name           string
81				x0, y0, x1, y1 int
82				fill           color.NRGBA
83			}{
84				{"Apply", 810, 177, 866, 198, color.NRGBA{212, 212, 216, 255}},
85				{"Untitled.txt", 24, 113, 115, 136, color.NRGBA{228, 228, 231, 255}},
86				{"Dark mode", 850, 14, 935, 35, color.NRGBA{255, 255, 255, 255}},
87				{"Name placeholder", 749, 130, 840, 152, color.NRGBA{250, 250, 250, 255}},
88			} {
89				n, total := ink(img, l.x0*scale, l.y0*scale, l.x1*scale, l.y1*scale, l.fill)
90				if n < 40*scale*scale || n > total/2 {
91					t.Errorf("%s: %d/%d ink pixels, label not painted in its control", l.name, n, total)
92				}
93			}
94		})
95	}
96}
97
98// ink counts pixels in [x0,x1)×[y0,y1) far from fill, and the region size.
99func ink(img image.Image, x0, y0, x1, y1 int, fill color.NRGBA) (n, total int) {
100	for y := y0; y < y1; y++ {
101		for x := x0; x < x1; x++ {
102			c := color.NRGBAModel.Convert(img.At(x, y)).(color.NRGBA)
103			if absDiff(c.R, fill.R)+absDiff(c.G, fill.G)+absDiff(c.B, fill.B) > 30 {
104				n++
105			}
106			total++
107		}
108	}
109	return n, total
110}
111func absDiff(a, b uint8) int {
112	if a > b {
113		return int(a - b)
114	}
115	return int(b - a)
116}
117
118// TestDemoCursorShapes hovers a menu button, the input and the background,
119// then reads the cursor shapes NeferWL applied from its run log (the harness
120// enables wayland debug logging). The shapes come from the demo's CSS: UA
121// `cursor: pointer` on buttons and `cursor: text` on inputs.
122func TestDemoCursorShapes(t *testing.T) {
123	requireHarness(t)
124	bin := demoBinary(t)
125	script := filepath.Join(t.TempDir(), "input.txt")
126	// Same cold-start allowance as TestDemoInputAndResources: the script
127	// starts at compositor launch, not when the demo maps.
128	if err := os.WriteFile(script, []byte("sleep 8s\nmove 80 123\nsleep 1s\nmove 800 140\nsleep 1s\nmove 100 580\nsleep 1s\n"), 0600); err != nil {
129		t.Fatal(err)
130	}
131	res := runDemo(t, bin, 1, 400, script, 12*time.Second)
132	data, err := os.ReadFile(res.Artifacts["compositor_run_log"])
133	if err != nil {
134		t.Fatal(err)
135	}
136	var shapes []string
137	for _, line := range strings.Split(string(data), "\n") {
138		var entry struct{ Message, Shape string }
139		if json.Unmarshal([]byte(line), &entry) == nil && entry.Message == "cursor" && entry.Shape != "" {
140			if len(shapes) == 0 || shapes[len(shapes)-1] != entry.Shape {
141				shapes = append(shapes, entry.Shape)
142			}
143		}
144	}
145	if got := strings.Join(shapes, ","); got != "pointer,text,default" {
146		t.Fatalf("cursor shapes %q, want pointer,text,default", got)
147	}
148}
149
150func TestDemoInputAndResources(t *testing.T) {
151	requireHarness(t)
152	bin := demoBinary(t)
153	script := filepath.Join(t.TempDir(), "input.txt")
154	// The compositor starts the script at launch, not when the client maps;
155	// wait past cold start (font discovery) before injecting input. The demo
156	// keeps presenting for 12 s so the run outlasts the ~10 s script however
157	// fast frames render.
158	if err := os.WriteFile(script, []byte("sleep 8s\nmove 800 140\nsleep 200ms\nclick left\nsleep 200ms\ntype Example\nsleep 200ms\nmove 820 186\nsleep 200ms\nclick left\nsleep 1s\n"), 0600); err != nil {
159		t.Fatal(err)
160	}
161	res := runDemo(t, bin, 1, 400, script, 12*time.Second)
162	data, err := os.ReadFile(filepath.Join(res.Artifacts["debug"], "demo-state.json"))
163	if err != nil {
164		t.Fatal(err)
165	}
166	var state struct{ Name, Status string }
167	if err = json.Unmarshal(data, &state); err != nil {
168		t.Fatal(err)
169	}
170	if state.Name != "Example" || state.Status != "Author set to Example · session only" {
171		layout, _ := os.ReadFile(filepath.Join(res.Artifacts["debug"], "layout.json"))
172		t.Fatalf("state %+v layout=%s", state, layout)
173	}
174	var s summary
175	data, err = os.ReadFile(filepath.Join(res.Artifacts["debug"], "resource-summary.json"))
176	if err != nil {
177		t.Fatal(err)
178	}
179	if err = json.Unmarshal(data, &s); err != nil {
180		t.Fatal(err)
181	}
182	t.Logf("frames=%d fd_start=%d fd_peak=%d fd_after=%d images_after=%d", s.Frames, s.FDStart, s.FDPeak, s.FDAfter, s.ImagesAfter)
183	// FDStart > 0 proves the summary fields were actually decoded.
184	if s.Frames < 400 || s.FDStart == 0 || s.ImagesAfter != 0 || s.FDPeak-s.FDStart > 2 || s.FDAfter > s.FDStart {
185		t.Fatalf("resources %+v", s)
186	}
187}
188
189// TestDemoIndicators returns to the editor, selects the second radio and
190// clicks the slider track, then checks the model and the painted indicators
191// in the captured frame: the selected radio and the slider fill up to the
192// thumb use the accent color, the rest of the track does not.
193func TestDemoIndicators(t *testing.T) {
194	requireHarness(t)
195	bin := demoBinary(t)
196	script := filepath.Join(t.TempDir(), "input.txt")
197	// Same cold-start allowance as TestDemoInputAndResources.
198	if err := os.WriteFile(script, []byte("sleep 8s\nmove 80 166\nsleep 200ms\nclick left\nsleep 800ms\nmove 80 123\nsleep 200ms\nclick left\nsleep 500ms\nmove 800 312\nsleep 200ms\nclick left\nsleep 300ms\nmove 883 377\nclick left\nsleep 300ms\nmove 700 560\nsleep 1s\n"), 0600); err != nil {
199		t.Fatal(err)
200	}
201	res := runDemo(t, bin, 1, 400, script, 13*time.Second)
202	data, err := os.ReadFile(filepath.Join(res.Artifacts["debug"], "demo-state.json"))
203	if err != nil {
204		t.Fatal(err)
205	}
206	var state struct {
207		Page, Density string
208		Volume        float64
209	}
210	if err = json.Unmarshal(data, &state); err != nil {
211		t.Fatal(err)
212	}
213	if state.Page != "home" || state.Density != "compact" || state.Volume != 70 {
214		t.Fatalf("state %+v", state)
215	}
216	img := lastMappedFrame(t, res.Artifacts["frames"])
217	accent := color.NRGBA{82, 82, 91, 255} // light theme accent
218	// Muted paint is the demo text color at 45% alpha over the inspector. Each probe
219	// expects a specific color, so missing paint (plain white) fails too.
220	muted := color.NRGBA{151, 151, 152, 255}
221	for _, p := range []struct {
222		name string
223		x, y int
224		want color.NRGBA
225	}{
226		{"selected radio", 743, 304, accent},
227		{"unselected radio outline", 741, 273, muted},
228		{"slider fill", 790, 377, accent},
229		{"slider rail", 930, 377, muted},
230	} {
231		t.Logf("%s (%d,%d)=%v", p.name, p.x, p.y, color.NRGBAModel.Convert(img.At(p.x, p.y)))
232		if err := harness.PixelProbe(img, p.x, p.y, p.want, 12); err != nil {
233			t.Errorf("%s: %v", p.name, err)
234		}
235	}
236}
237
238// lastMappedFrame is the newest compositor frame still showing the demo: the
239// capture request fires before scripted input, and the final frames follow
240// client exit.
241func lastMappedFrame(t *testing.T, dir string) image.Image {
242	t.Helper()
243	files, err := filepath.Glob(filepath.Join(dir, "frame-*.png"))
244	if err != nil {
245		t.Fatal(err)
246	}
247	for i := len(files) - 1; i >= 0; i-- {
248		img, err := harness.LoadPNG(files[i])
249		if err != nil {
250			continue // possibly still being written
251		}
252		if harness.PixelProbe(img, 5, 5, color.NRGBA{255, 255, 255, 255}, 8) == nil || harness.PixelProbe(img, 5, 5, color.NRGBA{32, 32, 35, 255}, 8) == nil {
253			return img
254		}
255	}
256	t.Fatalf("no mapped demo frame in %s", dir)
257	return nil
258}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 12

↑↓ moveenter openesc close