1package tray
2
3import (
4 "errors"
5 "io"
6 "strings"
7 "testing"
8 "time"
9
10 "github.com/bnema/zerobus"
11)
12
13func TestParseCommand(t *testing.T) {
14 good := map[string]command{
15 "click left 0 1": {verb: "click", button: btnLeft, col: 0, token: 1},
16 "click left 0 4294967295": {verb: "click", button: btnLeft, token: 1<<32 - 1},
17 "click left 1048576 1": {verb: "click", button: btnLeft, col: 1 << 20, token: 1},
18 "menu-activate 4294967295 2147483647": {verb: "menu-activate", token: 1<<32 - 1, col: 1<<31 - 1},
19 "click middle 3 42": {verb: "click", button: btnMiddle, col: 3, token: 42},
20 "click right 12 7": {verb: "click", button: btnRight, col: 12, token: 7},
21 "scroll up 2 0": {verb: "scroll", vert: true, delta: -2},
22 "scroll down 1 4": {verb: "scroll", vert: true, delta: 1, col: 4},
23 "scroll left 3 1": {verb: "scroll", delta: -3, col: 1},
24 "scroll right 5 2": {verb: "scroll", delta: 5, col: 2},
25 "hover 4": {verb: "hover", col: 4},
26 "leave": {verb: "leave"},
27 "menu-activate 9 17": {verb: "menu-activate", token: 9, col: 17},
28 "menu-closed 9": {verb: "menu-closed", token: 9},
29 " click left 1 2 ": {verb: "click", button: btnLeft, col: 1, token: 2},
30 }
31 for line, want := range good {
32 got, ok := parseCommand(line)
33 if !ok || got != want {
34 t.Errorf("parseCommand(%q) = %+v, %v; want %+v", line, got, ok, want)
35 }
36 }
37 for _, line := range []string{
38 "", " ", "garbage", "click", "click left", "click left 0", "click left 0 1 2", "click up 0 1",
39 "click left -1 1", "click left x 1", "click left 0 -3", "click left +1 1", "click left 0 4294967296",
40 "scroll up", "scroll up 0 0", "scroll up -1 0", "scroll up 2", "scroll diagonal 1 0", "scroll up 1000000 0",
41 "hover", "hover x", "hover -1", "hover 1 2", "leave now", "menu-activate 1", "menu-activate 1 -2",
42 "menu-closed", "menu-closed x", "menu-closed 1 2", "CLICK left 0 1",
43 // Signs are refused, and so is anything past 31 bits for columns and steps.
44 "click left 0 +1", "click left +0 1", "click left 0 -0", "scroll up +2 0", "scroll up 2 +0", "hover +1",
45 "menu-activate +1 2", "menu-activate 1 +2", "menu-closed +1", "menu-closed -1",
46 "click left 2147483648 1", "scroll up 1 2147483648", "hover 2147483648", "hover 1048577",
47 "menu-activate 1 2147483648", "click left 0 0x1", "click left 0 1_0", "click left 0 1e3",
48 } {
49 if got, ok := parseCommand(line); ok {
50 t.Errorf("parseCommand(%q) = %+v, want it refused", line, got)
51 }
52 }
53}
54
55// barInput is the pipe the test writes the bar's lines to.
56type barInput struct{ w *io.PipeWriter }
57
58func (b barInput) send(t *testing.T, lines ...string) {
59 t.Helper()
60 for _, l := range lines {
61 if _, err := io.WriteString(b.w, l+"\n"); err != nil {
62 t.Fatal(err)
63 }
64 }
65}
66
67// setup runs a tray with input on a private bus, with two items: steam ("S",
68// column 0) and obsidian ("O", column 2).
69func setup(t *testing.T) (steam, obs *fakeItem, in barInput, out *lines) {
70 t.Helper()
71 addr := privateBus(t)
72 pr, pw := io.Pipe()
73 t.Cleanup(func() { _ = pw.Close() })
74 in = barInput{pw}
75 out, _, _ = startTrayWith(t, addr, pr, func() (*zerobus.Conn, error) { return zerobus.Dial(addr) })
76 waitWatcher(t, addr)
77 steam = newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
78 obs = newFakeItem(t, addr, "obsidian_status_icon_1", "", [4]byte{255, 120, 80, 220})
79 steam.register(t)
80 out.waitFor(t, "steam", func(s string) bool { return strings.Contains(s, "S") })
81 obs.register(t)
82 out.waitFor(t, "both items", func(s string) bool { return strings.Contains(s, "S") && strings.Contains(s, "O") })
83 return steam, obs, in, out
84}
85
86func expectCall(t *testing.T, f *fakeItem, want string) {
87 t.Helper()
88 select {
89 case got := <-f.calls:
90 if got != want {
91 t.Fatalf("item got %s, want %s", got, want)
92 }
93 case <-time.After(5 * time.Second):
94 t.Fatalf("item never got %s", want)
95 }
96}
97
98func expectNoCall(t *testing.T, f *fakeItem) {
99 t.Helper()
100 select {
101 case got := <-f.calls:
102 t.Fatalf("unexpected call %s", got)
103 case <-time.After(150 * time.Millisecond):
104 }
105}
106
107func TestClicksAndScroll(t *testing.T) {
108 steam, obs, in, _ := setup(t)
109
110 in.send(t, "click left 0 1")
111 expectCall(t, steam, "Activate(0,0)")
112 in.send(t, "click middle 0 2")
113 expectCall(t, steam, "SecondaryActivate(0,0)")
114 in.send(t, "click right 0 3")
115 expectCall(t, steam, "ContextMenu(0,0)")
116
117 // The second icon starts at column 2, after one space.
118 in.send(t, "click left 2 4")
119 expectCall(t, obs, "Activate(0,0)")
120 expectNoCall(t, steam)
121
122 in.send(t, "scroll up 2 0")
123 expectCall(t, steam, "Scroll(-2,vertical)")
124 in.send(t, "scroll down 3 0")
125 expectCall(t, steam, "Scroll(3,vertical)")
126 in.send(t, "scroll right 1 2")
127 expectCall(t, obs, "Scroll(1,horizontal)")
128 in.send(t, "scroll left 4 2")
129 expectCall(t, obs, "Scroll(-4,horizontal)")
130}
131
132func TestClickOutsideIconsAndGarbageAreIgnored(t *testing.T) {
133 steam, obs, in, _ := setup(t)
134 in.send(t,
135 "click left 1 1", // the space between the icons
136 "click left 9 2", // past the last icon
137 "garbage",
138 "click left x 3",
139 "click left 0",
140 "click left 0 4 extra",
141 "click left -1 5",
142 "scroll up 0 0",
143 "\x1b[31m not a command",
144 strings.Repeat("click left 0 1 ", 40), // longer than the line limit
145 "hover 0", "leave", // accepted by the parser, nothing to do yet
146 )
147 expectNoCall(t, steam)
148 expectNoCall(t, obs)
149 // The loop still works afterwards.
150 in.send(t, "click left 0 9")
151 expectCall(t, steam, "Activate(0,0)")
152}
153
154func TestActivateUnknownMethodFallsBackToContextMenu(t *testing.T) {
155 steam, _, in, _ := setup(t)
156 steam.run(t, func() { steam.unknown = "Activate" })
157 in.send(t, "click left 0 1")
158 expectCall(t, steam, "Activate(0,0)")
159 expectCall(t, steam, "ContextMenu(0,0)")
160}
161
162func TestItemIsMenuLeftClickOpensTheMenu(t *testing.T) {
163 var buf strings.Builder
164 tr := &Tray{opt: Options{Out: &buf}}
165 tr.items = []*item{{loaded: true, icon: "X", dest: ":1.1", path: "/p", isMenu: true, menu: "/m"}}
166 if err := tr.render(true); err != nil {
167 t.Fatal(err)
168 }
169 tg, ok := tr.target(0)
170 if !ok || !tg.IsMenu || tg.Menu != "/m" {
171 t.Fatalf("target = %+v, %v", tg, ok)
172 }
173}
174
175// A frozen application must not block the tray: the call is abandoned after
176// two seconds and the next click, on another item, still goes through.
177func TestFrozenApplicationDoesNotBlockNextClick(t *testing.T) {
178 steam, obs, in, _ := setup(t)
179 steam.run(t, func() { steam.slow, steam.slowFor = "Activate", 4*time.Second })
180 start := time.Now()
181 in.send(t, "click left 0 1", "click left 2 2")
182 expectCall(t, steam, "Activate(0,0)")
183 expectCall(t, obs, "Activate(0,0)")
184 if d := time.Since(start); d < 1500*time.Millisecond || d > 3500*time.Millisecond {
185 t.Fatalf("the second click arrived after %v, want about 2s (the watchdog)", d)
186 }
187 // And again on the dropped item: the connection was redialed.
188 in.send(t, "click middle 2 3")
189 expectCall(t, obs, "SecondaryActivate(0,0)")
190}
191
192// If shutdown begins while the control connection is being dialed, the new
193// connection is closed and not kept.
194func TestConn2ClosesADialThatRacesShutdown(t *testing.T) {
195 addr := privateBus(t)
196 dialing, release := make(chan struct{}), make(chan struct{})
197 var dialed *zerobus.Conn
198 c := &control{dial: func() (*zerobus.Conn, error) {
199 close(dialing)
200 <-release
201 conn, err := zerobus.Dial(addr)
202 dialed = conn
203 return conn, err
204 }}
205 errc := make(chan error, 1)
206 go func() { _, err := c.conn2(); errc <- err }()
207 <-dialing
208 closed := make(chan struct{})
209 go func() { c.close(); close(closed) }()
210 select {
211 case <-closed: // close must not wait for the dial
212 case <-time.After(time.Second):
213 t.Fatal("close waited for the dial")
214 }
215 close(release)
216 if err := <-errc; !errors.Is(err, zerobus.ErrClosed) {
217 t.Fatalf("conn2 = %v, want ErrClosed", err)
218 }
219 if c.conn != nil {
220 t.Fatal("the connection was kept after shutdown")
221 }
222 dialed.NewCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "GetId", "")
223 if _, err := dialed.Call(); err == nil {
224 t.Fatal("the connection dialed during shutdown is still open")
225 }
226}