aboutsummaryrefslogtreecommitdiff
path: root/tests/notify/test_notify.py
blob: c49af57c3610d10e765d24eded7f312e0b9db521 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
"""Tests for dotfiles/common/.local/bin/notify.

notify wraps notify-send (the visual popup) and paplay (the sound). The tests
run the real script with HOME pointed at a temp dir holding fake icon/sound
assets, and with fake `notify-send` and `paplay` executables on PATH that log
their arguments. Assertions are made on those logs:

  - the popup always fires (notify-send logged)
  - the sound fires only when not --silent, at NOTIFY_VOLUME
  - --persist and --silent compose in any order
  - bad type / too few args / unknown flag fail

No real audio plays and no real notification is sent.

Run from repo root:
    python3 -m unittest tests.notify.test_notify
"""

import os
import shutil
import subprocess
import tempfile
import time
import unittest


REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
SCRIPT = os.path.join(REPO_ROOT, "dotfiles/common/.local/bin/notify")

SOUND_TYPES = ["success", "fail", "alert", "question", "alarm", "info", "security", "bug"]


class NotifyHarness(unittest.TestCase):

    def setUp(self):
        self.tmp = tempfile.mkdtemp(prefix="notify-test-")
        self.home = os.path.join(self.tmp, "home")
        self.bin = os.path.join(self.tmp, "bin")
        self.sound_dir = os.path.join(self.home, ".local/share/sounds/notify")
        self.icon_dir = os.path.join(self.home, ".local/share/icons/notify")
        os.makedirs(self.bin)
        os.makedirs(self.sound_dir)
        os.makedirs(self.icon_dir)

        # Fake assets so the script's existence checks pass.
        for t in SOUND_TYPES:
            open(os.path.join(self.sound_dir, f"{t}.ogg"), "w").close()
            open(os.path.join(self.icon_dir, f"{t}.png"), "w").close()

        # Fake binaries that log their argv to a file under tmp.
        self.notify_log = os.path.join(self.tmp, "notify-send.log")
        self.paplay_log = os.path.join(self.tmp, "paplay.log")
        self._make_stub("notify-send", self.notify_log)
        self._make_stub("paplay", self.paplay_log)

    def tearDown(self):
        shutil.rmtree(self.tmp, ignore_errors=True)

    def _make_stub(self, name, logfile):
        path = os.path.join(self.bin, name)
        with open(path, "w") as f:
            f.write("#!/bin/bash\n")
            f.write('printf "%s\\n" "$*" >> "' + logfile + '"\n')
        os.chmod(path, 0o755)

    def run_notify(self, *args, env_extra=None):
        env = os.environ.copy()
        env["HOME"] = self.home
        env["PATH"] = self.bin + os.pathsep + env.get("PATH", "")
        if env_extra:
            env.update(env_extra)
        return subprocess.run(
            [SCRIPT, *args], env=env, capture_output=True, text=True, timeout=10,
        )

    def read_log(self, path, wait=False):
        """Return log contents. If wait, poll briefly (the sound is backgrounded)."""
        if wait:
            deadline = time.time() + 2.0
            while time.time() < deadline and not os.path.exists(path):
                time.sleep(0.02)
        if not os.path.exists(path):
            return ""
        with open(path) as f:
            return f.read()

    def paplay_called(self):
        # Silent path never spawns paplay, so a short settle is enough to be sure.
        time.sleep(0.3)
        return os.path.exists(self.paplay_log)


# -----------------------------------------------------------------------------
# Normal cases
# -----------------------------------------------------------------------------

class TestNotifyNormal(NotifyHarness):

    def test_default_shows_popup_and_plays_sound(self):
        result = self.run_notify("info", "Title", "Body")
        self.assertEqual(result.returncode, 0, msg=result.stderr)
        self.assertIn("Title", self.read_log(self.notify_log))
        self.assertIn("info.ogg", self.read_log(self.paplay_log, wait=True))

    def test_sound_played_at_default_volume(self):
        self.run_notify("success", "T", "B")
        self.assertIn("--volume=65536", self.read_log(self.paplay_log, wait=True))

    def test_each_type_selects_its_own_sound(self):
        for t in SOUND_TYPES:
            with self.subTest(type=t):
                # Fresh log per type.
                if os.path.exists(self.paplay_log):
                    os.remove(self.paplay_log)
                self.run_notify(t, "T", "B")
                self.assertIn(f"{t}.ogg", self.read_log(self.paplay_log, wait=True))


# -----------------------------------------------------------------------------
# --silent
# -----------------------------------------------------------------------------

class TestNotifySilent(NotifyHarness):

    def test_silent_shows_popup_but_no_sound(self):
        result = self.run_notify("info", "T", "B", "--silent")
        self.assertEqual(result.returncode, 0, msg=result.stderr)
        self.assertIn("T", self.read_log(self.notify_log))
        self.assertFalse(self.paplay_called(), "paplay should not run under --silent")

    def test_silent_then_persist_both_apply(self):
        result = self.run_notify("info", "T", "B", "--silent", "--persist")
        self.assertEqual(result.returncode, 0, msg=result.stderr)
        self.assertIn("--expire-time=0", self.read_log(self.notify_log))
        self.assertFalse(self.paplay_called())

    def test_persist_then_silent_order_independent(self):
        result = self.run_notify("info", "T", "B", "--persist", "--silent")
        self.assertEqual(result.returncode, 0, msg=result.stderr)
        self.assertIn("--expire-time=0", self.read_log(self.notify_log))
        self.assertFalse(self.paplay_called())


# -----------------------------------------------------------------------------
# Volume knob + persist
# -----------------------------------------------------------------------------

class TestNotifyVolumeAndPersist(NotifyHarness):

    def test_notify_volume_env_overrides_playback_volume(self):
        self.run_notify("info", "T", "B", env_extra={"NOTIFY_VOLUME": "30000"})
        self.assertIn("--volume=30000", self.read_log(self.paplay_log, wait=True))

    def test_persist_adds_expire_time_zero(self):
        self.run_notify("info", "T", "B", "--persist")
        self.assertIn("--expire-time=0", self.read_log(self.notify_log))

    def test_no_persist_has_no_expire_time(self):
        self.run_notify("info", "T", "B")
        self.assertNotIn("--expire-time", self.read_log(self.notify_log))


# -----------------------------------------------------------------------------
# Error cases
# -----------------------------------------------------------------------------

class TestNotifyErrors(NotifyHarness):

    def test_unknown_type_exits_nonzero(self):
        result = self.run_notify("bogus", "T", "B")
        self.assertNotEqual(result.returncode, 0)
        self.assertIn("Unknown type", result.stderr)

    def test_too_few_args_shows_usage(self):
        result = self.run_notify("info", "OnlyTitle")
        self.assertNotEqual(result.returncode, 0)
        self.assertIn("Usage", result.stderr + result.stdout)

    def test_unknown_flag_exits_nonzero(self):
        result = self.run_notify("info", "T", "B", "--bogus")
        self.assertNotEqual(result.returncode, 0)
        self.assertIn("Unknown option", result.stderr)


if __name__ == "__main__":
    unittest.main()