1package tray
2
3import (
4 "os"
5 "path/filepath"
6 "testing"
7 "time"
8)
9
10// fakeFont knows a few glyph names; each maps to a letter so results read
11// easily.
12var fakeFont = map[string]rune{
13 "fa-steam": 'S',
14 "custom-obsidian": 'O',
15 "md-volume_off": 'M',
16 "md-gamepad_variant": 'G',
17 "md-web": 'W',
18 "md-lan": 'N',
19 "md-alpha_z_circle": 'Z',
20 "md-google_chrome": 'C',
21 "md-wifi": 'F',
22 "md-sync": 'Y',
23 "md-chat": 'H',
24 "md-cog": 'K',
25}
26
27func lookup(name string) (rune, bool) {
28 r, ok := fakeFont[name]
29 return r, ok
30}
31
32func writeDesktop(t *testing.T, dir, name, body string) {
33 t.Helper()
34 apps := filepath.Join(dir, "applications")
35 if err := os.MkdirAll(apps, 0o755); err != nil {
36 t.Fatal(err)
37 }
38 if err := os.WriteFile(filepath.Join(apps, name+".desktop"), []byte(body), 0o644); err != nil {
39 t.Fatal(err)
40 }
41}
42
43func TestResolve(t *testing.T) {
44 dir := t.TempDir()
45 writeDesktop(t, dir, "com.example.Racer", "[Desktop Entry]\nName=Racer\nIcon=racer-game\nCategories=Game;\n")
46 writeDesktop(t, dir, "chromium", "[Desktop Entry]\nName=Chromium\nIcon=chromium\nCategories=Network;WebBrowser;\n")
47 writeDesktop(t, dir, "zed", "[Desktop Entry]\nName=Zed\nStartupWMClass=dev.zed.Zed\n\n[Desktop Action new]\nCategories=Game;\n")
48 writeDesktop(t, dir, "dev.lizardbyte.app.Sunrise", "[Desktop Entry]\nName=Sunrise\nCategories=RemoteAccess;Network;\n")
49 writeDesktop(t, dir, "org.gnome.Settings", "[Desktop Entry]\nName=Settings\nCategories=Settings;\n")
50 writeDesktop(t, dir, "chatter", "[Desktop Entry]\nName=Chatter\nCategories=Chat;\n")
51 writeDesktop(t, dir, "ghost", "[Desktop Entry]\nName=Ghost\nHidden=true\n")
52 later := t.TempDir()
53 writeDesktop(t, later, "chatter", "[Desktop Entry]\nName=Chatter\nCategories=Game;\n")
54 writeDesktop(t, later, "ghost", "[Desktop Entry]\nName=Ghost\nCategories=Game;\n")
55 r := NewResolver(lookup, map[string]string{"Sunshine": "☀", "Mail": "md-lan"}, []string{dir, later})
56
57 for _, c := range []struct {
58 name, id, icon, title, want string
59 }{
60 {"brand from the id", "steam", "", "", "S"},
61 {"brand from a word of the id", "obsidian_status_icon_1", "", "", "O"},
62 {"brand from the whole name", "google-chrome", "", "", "C"},
63 {"status icon name", "nm-applet", "audio-volume-muted", "", "M"},
64 {"status icon name before a brand", "steam", "audio-volume-muted", "", "M"},
65 {"status word inside an app name is not a status", "signal-desktop", "signal-desktop", "", "S"},
66 {"wireless status", "nm-applet", "nm-signal-75", "", "F"},
67 {"title words are not brands", "", "", "Sync complete", "S"},
68 {"whole title as a brand", "", "", "Google Chrome", "C"},
69 {"title words find no desktop file", "", "", "Open settings", "O"},
70 {"last part of a reverse-DNS name", "settings", "", "", "K"},
71 {"earlier data dir wins", "chatter", "", "", "H"},
72 {"hidden desktop file counts as deleted", "ghost", "", "", "G"},
73 {"category from the desktop file", "racer", "racer-game", "", "G"},
74 {"most specific category wins", "chromium", "", "", "W"},
75 {"desktop file found by a word of its name", "Sunrise", "", "Sunrise", "N"},
76 {"literal override, any case", "sunshine", "", "", "☀"},
77 {"glyph-name override", "", "", "mail", "N"},
78 {"letter of the desktop name; actions are ignored", "dev.zed.Zed", "", "", "Z"},
79 {"letter without the font glyph", "Quux", "", "", "Q"},
80 {"a file path is not a name", "", "/run/user/1/steam.png", "", "•"},
81 {"nothing to go on", "", "", "", "•"},
82 } {
83 if got := r.Resolve(c.id, c.icon, c.title); got != c.want {
84 t.Errorf("%s: Resolve(%q, %q, %q) = %q, want %q", c.name, c.id, c.icon, c.title, got, c.want)
85 }
86 }
87}
88
89func TestResolveFindsAppsInstalledLater(t *testing.T) {
90 dir := t.TempDir()
91 r := NewResolver(lookup, nil, []string{dir})
92 if got := r.Resolve("racer", "", ""); got != "R" {
93 t.Fatalf("before install: %q", got)
94 }
95 writeDesktop(t, dir, "racer", "[Desktop Entry]\nName=Racer\nCategories=Game;\n")
96 // Make sure the directory's mtime differs from the one at the load,
97 // even on a coarse clock.
98 later := time.Now().Add(time.Hour)
99 if err := os.Chtimes(filepath.Join(dir, "applications"), later, later); err != nil {
100 t.Fatal(err)
101 }
102 if got := r.Resolve("racer", "", ""); got != "G" {
103 t.Fatalf("after install: %q, want G", got)
104 }
105}
106
107func TestResolveDoesNotRescanUnchangedDirs(t *testing.T) {
108 // A directory dated in the future does not make every miss rescan.
109 dir := t.TempDir()
110 writeDesktop(t, dir, "racer", "[Desktop Entry]\nName=Racer\nCategories=Game;\n")
111 future := time.Now().Add(24 * time.Hour)
112 if err := os.Chtimes(filepath.Join(dir, "applications"), future, future); err != nil {
113 t.Fatal(err)
114 }
115 r := NewResolver(lookup, nil, []string{dir})
116 r.Resolve("unknown", "", "")
117 if r.changed() {
118 t.Fatal("an unchanged directory counts as changed")
119 }
120}
121
122func TestEmptyOverrideHides(t *testing.T) {
123 r := NewResolver(lookup, map[string]string{"steam": ""}, []string{t.TempDir()})
124 if got := r.Resolve("steam", "", ""); got != "" {
125 t.Fatalf("Resolve = %q, want empty", got)
126 }
127}
128
129func TestWords(t *testing.T) {
130 got := words("org.telegram.desktop_2-Tray 42")
131 if len(got) != 1 || got[0] != "telegram" {
132 t.Fatalf("words = %q", got)
133 }
134}