test_display_control.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. """Tests for daemon.display_control — DisplayControl brightness and blanking."""
  2. import os
  3. import time
  4. import pytest
  5. class TestDisplayControlNoBacklight:
  6. """DisplayControl behavior when no backlight is present."""
  7. def test_no_backlight_detected(self, monkeypatch, tmp_path):
  8. # Point BACKLIGHT_BASE to an empty directory (no backlight entries)
  9. import daemon.display_control as dc_mod
  10. empty_dir = tmp_path / "backlight"
  11. empty_dir.mkdir()
  12. monkeypatch.setattr(dc_mod, "BACKLIGHT_BASE", empty_dir)
  13. dc = dc_mod.DisplayControl()
  14. assert dc.has_backlight is False
  15. def test_no_backlight_dir_missing(self, monkeypatch, tmp_path):
  16. import daemon.display_control as dc_mod
  17. monkeypatch.setattr(dc_mod, "BACKLIGHT_BASE", tmp_path / "nonexistent")
  18. dc = dc_mod.DisplayControl()
  19. assert dc.has_backlight is False
  20. def test_set_brightness_noop_without_backlight(self, monkeypatch, tmp_path):
  21. import daemon.display_control as dc_mod
  22. empty_dir = tmp_path / "backlight"
  23. empty_dir.mkdir()
  24. monkeypatch.setattr(dc_mod, "BACKLIGHT_BASE", empty_dir)
  25. dc = dc_mod.DisplayControl()
  26. # Should not raise
  27. dc.set_brightness(50)
  28. dc.set_brightness(0)
  29. dc.set_brightness(100)
  30. class TestDisplayControlWithBacklight:
  31. """DisplayControl behavior with a mock sysfs backlight."""
  32. @pytest.fixture
  33. def display(self, monkeypatch, tmp_path):
  34. import daemon.display_control as dc_mod
  35. bl_dir = tmp_path / "backlight" / "rpi_backlight"
  36. bl_dir.mkdir(parents=True)
  37. (bl_dir / "brightness").write_text("200")
  38. (bl_dir / "max_brightness").write_text("255")
  39. monkeypatch.setattr(dc_mod, "BACKLIGHT_BASE", tmp_path / "backlight")
  40. return dc_mod.DisplayControl(), bl_dir
  41. def test_has_backlight_true(self, display):
  42. dc, _ = display
  43. assert dc.has_backlight is True
  44. def test_set_brightness_100(self, display):
  45. dc, bl_dir = display
  46. dc.set_brightness(100)
  47. assert (bl_dir / "brightness").read_text() == "255"
  48. def test_set_brightness_0(self, display):
  49. dc, bl_dir = display
  50. dc.set_brightness(0)
  51. assert (bl_dir / "brightness").read_text() == "0"
  52. def test_set_brightness_50(self, display):
  53. dc, bl_dir = display
  54. dc.set_brightness(50)
  55. value = int((bl_dir / "brightness").read_text())
  56. # 50% of 255 = 127 or 128 depending on rounding
  57. assert value == round(255 * 50 / 100)
  58. def test_set_brightness_clamped_above_100(self, display):
  59. dc, bl_dir = display
  60. dc.set_brightness(200)
  61. assert (bl_dir / "brightness").read_text() == "255"
  62. def test_set_brightness_clamped_below_0(self, display):
  63. dc, bl_dir = display
  64. dc.set_brightness(-50)
  65. assert (bl_dir / "brightness").read_text() == "0"
  66. def test_max_brightness_fallback_on_missing_file(self, monkeypatch, tmp_path):
  67. """If max_brightness file doesn't exist, defaults to 255."""
  68. import daemon.display_control as dc_mod
  69. bl_dir = tmp_path / "backlight" / "rpi_backlight"
  70. bl_dir.mkdir(parents=True)
  71. (bl_dir / "brightness").write_text("100")
  72. # No max_brightness file
  73. monkeypatch.setattr(dc_mod, "BACKLIGHT_BASE", tmp_path / "backlight")
  74. dc = dc_mod.DisplayControl()
  75. assert dc._max_brightness == 255
  76. class TestDisplayControlBlanking:
  77. """Blanking logic: timeout, wake, tick."""
  78. @pytest.fixture
  79. def display(self, monkeypatch, tmp_path):
  80. import daemon.display_control as dc_mod
  81. empty_dir = tmp_path / "backlight"
  82. empty_dir.mkdir()
  83. monkeypatch.setattr(dc_mod, "BACKLIGHT_BASE", empty_dir)
  84. return dc_mod.DisplayControl()
  85. def test_blank_timeout_default_disabled(self, display):
  86. assert display._blank_timeout == 0
  87. def test_set_blank_timeout(self, display):
  88. display.set_blank_timeout(30)
  89. assert display._blank_timeout == 30
  90. def test_set_blank_timeout_negative_clamped(self, display):
  91. display.set_blank_timeout(-10)
  92. assert display._blank_timeout == 0
  93. def test_tick_does_not_blank_when_disabled(self, display):
  94. display.set_blank_timeout(0)
  95. display.tick()
  96. assert display._blanked is False
  97. def test_tick_blanks_after_timeout(self, display, monkeypatch):
  98. display.set_blank_timeout(5)
  99. # Simulate idle for 10 seconds by backdating last_activity
  100. display._last_activity = time.monotonic() - 10
  101. display.tick()
  102. assert display._blanked is True
  103. def test_tick_does_not_blank_before_timeout(self, display):
  104. display.set_blank_timeout(60)
  105. display.wake() # Reset activity
  106. display.tick()
  107. assert display._blanked is False
  108. def test_wake_unblanks(self, display):
  109. display.set_blank_timeout(5)
  110. display._last_activity = time.monotonic() - 10
  111. display.tick()
  112. assert display._blanked is True
  113. display.wake()
  114. assert display._blanked is False
  115. def test_tick_unblanks_when_timeout_disabled_while_blanked(self, display):
  116. """If timeout is disabled while screen is blanked, tick should unblank."""
  117. display.set_blank_timeout(5)
  118. display._last_activity = time.monotonic() - 10
  119. display.tick()
  120. assert display._blanked is True
  121. display.set_blank_timeout(0)
  122. display.tick()
  123. assert display._blanked is False
  124. def test_wake_resets_activity_timer(self, display):
  125. display.set_blank_timeout(5)
  126. old_time = display._last_activity
  127. time.sleep(0.01)
  128. display.wake()
  129. assert display._last_activity > old_time
  130. class TestDisplayControlFifoMessages:
  131. """The wake FIFO carries two messages: `wake` and `reload-timeout N`.
  132. These tests pin both — they're the only way the daemon can talk to
  133. the idle watchdog (spoolbuddy-idle.sh) running in the Wayland session.
  134. Regression target: a one-shot swayidle started with a stale timeout
  135. value would never pick up UI changes without these signals.
  136. """
  137. @pytest.fixture
  138. def display_with_fifo(self, monkeypatch, tmp_path):
  139. import daemon.display_control as dc_mod
  140. empty_dir = tmp_path / "backlight"
  141. empty_dir.mkdir()
  142. monkeypatch.setattr(dc_mod, "BACKLIGHT_BASE", empty_dir)
  143. fifo_path = tmp_path / "spoolbuddy-wake"
  144. os.mkfifo(str(fifo_path), 0o622)
  145. monkeypatch.setattr(dc_mod, "WAKE_FIFO", fifo_path)
  146. # Hold a non-blocking reader open so the daemon's writes don't hit ENXIO.
  147. reader_fd = os.open(str(fifo_path), os.O_RDONLY | os.O_NONBLOCK)
  148. try:
  149. yield dc_mod.DisplayControl(), reader_fd
  150. finally:
  151. os.close(reader_fd)
  152. @staticmethod
  153. def _drain(fd: int) -> bytes:
  154. """Read whatever is queued on the FIFO without blocking."""
  155. try:
  156. return os.read(fd, 4096)
  157. except BlockingIOError:
  158. return b""
  159. def test_wake_writes_wake_line(self, display_with_fifo):
  160. dc, reader_fd = display_with_fifo
  161. dc.wake()
  162. assert self._drain(reader_fd) == b"wake\n"
  163. def test_first_set_blank_timeout_does_not_signal(self, display_with_fifo):
  164. """The watchdog already fetched this value at its own startup —
  165. signalling here would just thrash swayidle for nothing."""
  166. dc, reader_fd = display_with_fifo
  167. dc.set_blank_timeout(300)
  168. assert self._drain(reader_fd) == b""
  169. assert dc._blank_timeout == 300
  170. def test_subsequent_change_signals_reload(self, display_with_fifo):
  171. dc, reader_fd = display_with_fifo
  172. dc.set_blank_timeout(300) # init — no signal
  173. dc.set_blank_timeout(60)
  174. assert self._drain(reader_fd) == b"reload-timeout 60\n"
  175. def test_same_value_does_not_signal(self, display_with_fifo):
  176. dc, reader_fd = display_with_fifo
  177. dc.set_blank_timeout(300)
  178. dc.set_blank_timeout(300)
  179. assert self._drain(reader_fd) == b""
  180. def test_disable_after_enable_signals_zero(self, display_with_fifo):
  181. """Going from "blanking on" to "blanking off" must reach the watchdog
  182. so it can stop swayidle — otherwise the screen keeps blanking even
  183. after the user picks 'Off'."""
  184. dc, reader_fd = display_with_fifo
  185. dc.set_blank_timeout(300) # init
  186. dc.set_blank_timeout(0)
  187. assert self._drain(reader_fd) == b"reload-timeout 0\n"
  188. def test_negative_clamped_to_zero_in_signal(self, display_with_fifo):
  189. dc, reader_fd = display_with_fifo
  190. dc.set_blank_timeout(300) # init
  191. dc.set_blank_timeout(-5)
  192. assert self._drain(reader_fd) == b"reload-timeout 0\n"
  193. def test_signal_no_op_when_fifo_missing(self, monkeypatch, tmp_path):
  194. """No watchdog running = no FIFO. Writes must not raise."""
  195. import daemon.display_control as dc_mod
  196. empty_dir = tmp_path / "backlight"
  197. empty_dir.mkdir()
  198. monkeypatch.setattr(dc_mod, "BACKLIGHT_BASE", empty_dir)
  199. monkeypatch.setattr(dc_mod, "WAKE_FIFO", tmp_path / "no-such-fifo")
  200. dc = dc_mod.DisplayControl()
  201. dc.set_blank_timeout(300)
  202. dc.set_blank_timeout(60) # would signal if FIFO existed
  203. dc.wake()
  204. # No assertion needed — surviving without raising is the contract.