feat: initial moonset implementation — Wayland session power menu v0.1.0
5 Power-Aktionen (lock, logout, hibernate, reboot, shutdown), GTK4 + Layer Shell UI mit Catppuccin Mocha Theme, Multi-Monitor-Support, Inline-Confirmation, DE/EN i18n, TOML-Config mit Wallpaper-Fallback. 54 Tests grün.
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
# ABOUTME: Tests for configuration loading and wallpaper path resolution.
|
||||
# ABOUTME: Verifies TOML parsing, fallback hierarchy, and default values.
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from moonset.config import Config, load_config, resolve_background_path
|
||||
|
||||
|
||||
class TestLoadConfig:
|
||||
"""Tests for TOML config loading."""
|
||||
|
||||
def test_returns_default_config_when_no_files_exist(self) -> None:
|
||||
config = load_config(config_paths=[Path("/nonexistent")])
|
||||
assert config.background_path is None
|
||||
|
||||
def test_reads_background_path_from_toml(self, tmp_path: Path) -> None:
|
||||
conf = tmp_path / "moonset.toml"
|
||||
conf.write_text('background_path = "/custom/wallpaper.jpg"\n')
|
||||
config = load_config(config_paths=[conf])
|
||||
assert config.background_path == "/custom/wallpaper.jpg"
|
||||
|
||||
def test_later_paths_override_earlier(self, tmp_path: Path) -> None:
|
||||
conf1 = tmp_path / "first.toml"
|
||||
conf1.write_text('background_path = "/first.jpg"\n')
|
||||
conf2 = tmp_path / "second.toml"
|
||||
conf2.write_text('background_path = "/second.jpg"\n')
|
||||
config = load_config(config_paths=[conf1, conf2])
|
||||
assert config.background_path == "/second.jpg"
|
||||
|
||||
def test_skips_missing_config_files(self, tmp_path: Path) -> None:
|
||||
conf = tmp_path / "exists.toml"
|
||||
conf.write_text('background_path = "/exists.jpg"\n')
|
||||
config = load_config(config_paths=[Path("/nonexistent"), conf])
|
||||
assert config.background_path == "/exists.jpg"
|
||||
|
||||
def test_default_config_has_none_background(self) -> None:
|
||||
config = Config()
|
||||
assert config.background_path is None
|
||||
|
||||
|
||||
class TestResolveBackgroundPath:
|
||||
"""Tests for wallpaper path resolution fallback hierarchy."""
|
||||
|
||||
def test_uses_config_path_when_file_exists(self, tmp_path: Path) -> None:
|
||||
wallpaper = tmp_path / "custom.jpg"
|
||||
wallpaper.touch()
|
||||
config = Config(background_path=str(wallpaper))
|
||||
assert resolve_background_path(config) == wallpaper
|
||||
|
||||
def test_ignores_config_path_when_file_missing(self, tmp_path: Path) -> None:
|
||||
config = Config(background_path="/nonexistent/wallpaper.jpg")
|
||||
# Falls through to system or package fallback
|
||||
result = resolve_background_path(config)
|
||||
assert result is not None
|
||||
|
||||
def test_uses_moonarch_wallpaper_as_second_fallback(self, tmp_path: Path) -> None:
|
||||
moonarch_wp = tmp_path / "wallpaper.jpg"
|
||||
moonarch_wp.touch()
|
||||
config = Config(background_path=None)
|
||||
with patch("moonset.config.MOONARCH_WALLPAPER", moonarch_wp):
|
||||
assert resolve_background_path(config) == moonarch_wp
|
||||
|
||||
def test_uses_package_fallback_as_last_resort(self) -> None:
|
||||
config = Config(background_path=None)
|
||||
with patch("moonset.config.MOONARCH_WALLPAPER", Path("/nonexistent")):
|
||||
result = resolve_background_path(config)
|
||||
# Package fallback should always exist
|
||||
assert result is not None
|
||||
@@ -0,0 +1,85 @@
|
||||
# ABOUTME: Tests for locale detection and string lookup.
|
||||
# ABOUTME: Verifies DE/EN string tables and locale fallback behavior.
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from moonset.i18n import Strings, detect_locale, load_strings
|
||||
|
||||
|
||||
class TestDetectLocale:
|
||||
"""Tests for locale detection from environment and config files."""
|
||||
|
||||
@patch.dict("os.environ", {"LANG": "de_DE.UTF-8"})
|
||||
def test_detects_german_from_env(self) -> None:
|
||||
assert detect_locale() == "de"
|
||||
|
||||
@patch.dict("os.environ", {"LANG": "en_US.UTF-8"})
|
||||
def test_detects_english_from_env(self) -> None:
|
||||
assert detect_locale() == "en"
|
||||
|
||||
@patch.dict("os.environ", {"LANG": ""})
|
||||
def test_reads_locale_conf_when_env_empty(self, tmp_path: Path) -> None:
|
||||
conf = tmp_path / "locale.conf"
|
||||
conf.write_text("LANG=de_DE.UTF-8\n")
|
||||
assert detect_locale(locale_conf_path=conf) == "de"
|
||||
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_reads_locale_conf_when_env_unset(self, tmp_path: Path) -> None:
|
||||
conf = tmp_path / "locale.conf"
|
||||
conf.write_text("LANG=en_GB.UTF-8\n")
|
||||
assert detect_locale(locale_conf_path=conf) == "en"
|
||||
|
||||
@patch.dict("os.environ", {"LANG": "C"})
|
||||
def test_c_locale_falls_back_to_english(self) -> None:
|
||||
assert detect_locale() == "en"
|
||||
|
||||
@patch.dict("os.environ", {"LANG": "POSIX"})
|
||||
def test_posix_locale_falls_back_to_english(self) -> None:
|
||||
assert detect_locale() == "en"
|
||||
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_missing_conf_falls_back_to_english(self) -> None:
|
||||
assert detect_locale(locale_conf_path=Path("/nonexistent")) == "en"
|
||||
|
||||
@patch.dict("os.environ", {"LANG": "fr_FR.UTF-8"})
|
||||
def test_detects_unsupported_locale(self) -> None:
|
||||
assert detect_locale() == "fr"
|
||||
|
||||
|
||||
class TestLoadStrings:
|
||||
"""Tests for string table loading."""
|
||||
|
||||
def test_loads_german_strings(self) -> None:
|
||||
strings = load_strings("de")
|
||||
assert isinstance(strings, Strings)
|
||||
assert strings.lock_label == "Sperren"
|
||||
|
||||
def test_loads_english_strings(self) -> None:
|
||||
strings = load_strings("en")
|
||||
assert isinstance(strings, Strings)
|
||||
assert strings.lock_label == "Lock"
|
||||
|
||||
def test_unknown_locale_falls_back_to_english(self) -> None:
|
||||
strings = load_strings("fr")
|
||||
assert strings.lock_label == "Lock"
|
||||
|
||||
def test_all_string_fields_are_nonempty(self) -> None:
|
||||
for locale in ("de", "en"):
|
||||
strings = load_strings(locale)
|
||||
for field_name in Strings.__dataclass_fields__:
|
||||
value = getattr(strings, field_name)
|
||||
assert value, f"{locale}: {field_name} is empty"
|
||||
|
||||
def test_confirm_yes_no_present(self) -> None:
|
||||
strings = load_strings("de")
|
||||
assert strings.confirm_yes == "Ja"
|
||||
assert strings.confirm_no == "Abbrechen"
|
||||
|
||||
def test_error_messages_present(self) -> None:
|
||||
strings = load_strings("en")
|
||||
assert "failed" in strings.lock_failed.lower()
|
||||
assert "failed" in strings.logout_failed.lower()
|
||||
assert "failed" in strings.hibernate_failed.lower()
|
||||
assert "failed" in strings.reboot_failed.lower()
|
||||
assert "failed" in strings.shutdown_failed.lower()
|
||||
@@ -0,0 +1,74 @@
|
||||
# ABOUTME: Integration tests for the moonset power menu.
|
||||
# ABOUTME: Verifies that all modules work together correctly.
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from moonset.config import Config, load_config, resolve_background_path
|
||||
from moonset.i18n import Strings, load_strings
|
||||
from moonset.panel import ACTION_DEFINITIONS, ActionDef
|
||||
from moonset.power import POWER_TIMEOUT
|
||||
|
||||
|
||||
class TestModuleIntegration:
|
||||
"""Tests that verify modules work together."""
|
||||
|
||||
def test_action_defs_reference_valid_power_functions(self) -> None:
|
||||
"""Each ActionDef references a function from power.py."""
|
||||
from moonset import power
|
||||
power_functions = {
|
||||
power.lock, power.logout, power.hibernate,
|
||||
power.reboot, power.shutdown,
|
||||
}
|
||||
for action_def in ACTION_DEFINITIONS:
|
||||
assert action_def.action_fn in power_functions, (
|
||||
f"{action_def.name} references unknown power function"
|
||||
)
|
||||
|
||||
def test_action_defs_match_i18n_fields_de(self) -> None:
|
||||
"""All label/error/confirm attrs in ActionDefs exist in DE strings."""
|
||||
strings = load_strings("de")
|
||||
for action_def in ACTION_DEFINITIONS:
|
||||
assert hasattr(strings, action_def.label_attr)
|
||||
assert hasattr(strings, action_def.error_attr)
|
||||
if action_def.confirm_attr:
|
||||
assert hasattr(strings, action_def.confirm_attr)
|
||||
|
||||
def test_action_defs_match_i18n_fields_en(self) -> None:
|
||||
"""All label/error/confirm attrs in ActionDefs exist in EN strings."""
|
||||
strings = load_strings("en")
|
||||
for action_def in ACTION_DEFINITIONS:
|
||||
assert hasattr(strings, action_def.label_attr)
|
||||
assert hasattr(strings, action_def.error_attr)
|
||||
if action_def.confirm_attr:
|
||||
assert hasattr(strings, action_def.confirm_attr)
|
||||
|
||||
def test_config_defaults_produce_valid_background_path(self) -> None:
|
||||
"""Default config resolves to an existing wallpaper file."""
|
||||
config = Config()
|
||||
path = resolve_background_path(config)
|
||||
assert path.suffix in (".jpg", ".png", ".webp")
|
||||
|
||||
def test_full_config_to_strings_flow(self, tmp_path: Path) -> None:
|
||||
"""Config loading and string loading work independently."""
|
||||
conf = tmp_path / "moonset.toml"
|
||||
conf.write_text('background_path = "/custom/path.jpg"\n')
|
||||
config = load_config(config_paths=[conf])
|
||||
assert config.background_path == "/custom/path.jpg"
|
||||
|
||||
strings = load_strings("de")
|
||||
assert strings.lock_label == "Sperren"
|
||||
|
||||
@patch.dict("os.environ", {"LANG": "de_DE.UTF-8"})
|
||||
def test_german_locale_produces_german_labels(self) -> None:
|
||||
"""Full flow: German locale → German button labels."""
|
||||
strings = load_strings()
|
||||
for action_def in ACTION_DEFINITIONS:
|
||||
label = action_def.get_label(strings)
|
||||
assert label
|
||||
# German labels should not be the English ones
|
||||
en_strings = load_strings("en")
|
||||
en_label = action_def.get_label(en_strings)
|
||||
assert label != en_label, (
|
||||
f"{action_def.name}: DE and EN labels are identical"
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
# ABOUTME: Tests for the power menu panel UI module.
|
||||
# ABOUTME: Verifies action button creation, confirmation flow, and dismiss behavior.
|
||||
|
||||
from unittest.mock import MagicMock, patch, PropertyMock
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from moonset.i18n import load_strings
|
||||
from moonset.panel import (
|
||||
ACTION_DEFINITIONS,
|
||||
ActionDef,
|
||||
)
|
||||
|
||||
|
||||
class TestActionDefinitions:
|
||||
"""Tests for action definition structure."""
|
||||
|
||||
def test_has_five_actions(self) -> None:
|
||||
assert len(ACTION_DEFINITIONS) == 5
|
||||
|
||||
def test_action_order_by_destructiveness(self) -> None:
|
||||
names = [a.name for a in ACTION_DEFINITIONS]
|
||||
assert names == ["lock", "logout", "hibernate", "reboot", "shutdown"]
|
||||
|
||||
def test_lock_has_no_confirmation(self) -> None:
|
||||
lock_def = ACTION_DEFINITIONS[0]
|
||||
assert lock_def.name == "lock"
|
||||
assert lock_def.needs_confirm is False
|
||||
|
||||
def test_destructive_actions_need_confirmation(self) -> None:
|
||||
for action_def in ACTION_DEFINITIONS[1:]:
|
||||
assert action_def.needs_confirm is True, (
|
||||
f"{action_def.name} should need confirmation"
|
||||
)
|
||||
|
||||
def test_all_actions_have_icon_names(self) -> None:
|
||||
for action_def in ACTION_DEFINITIONS:
|
||||
assert action_def.icon_name, f"{action_def.name} missing icon_name"
|
||||
assert action_def.icon_name.endswith("-symbolic")
|
||||
|
||||
def test_all_actions_have_callable_functions(self) -> None:
|
||||
for action_def in ACTION_DEFINITIONS:
|
||||
assert callable(action_def.action_fn)
|
||||
|
||||
def test_action_labels_from_strings(self) -> None:
|
||||
strings = load_strings("en")
|
||||
for action_def in ACTION_DEFINITIONS:
|
||||
label = action_def.get_label(strings)
|
||||
assert label, f"{action_def.name} has empty label"
|
||||
|
||||
def test_action_error_messages_from_strings(self) -> None:
|
||||
strings = load_strings("en")
|
||||
for action_def in ACTION_DEFINITIONS:
|
||||
error_msg = action_def.get_error_message(strings)
|
||||
assert error_msg, f"{action_def.name} has empty error message"
|
||||
|
||||
def test_confirmable_actions_have_confirm_prompts(self) -> None:
|
||||
strings = load_strings("en")
|
||||
for action_def in ACTION_DEFINITIONS:
|
||||
if action_def.needs_confirm:
|
||||
prompt = action_def.get_confirm_prompt(strings)
|
||||
assert prompt, f"{action_def.name} has empty confirm prompt"
|
||||
|
||||
def test_lock_confirm_prompt_is_none(self) -> None:
|
||||
strings = load_strings("en")
|
||||
lock_def = ACTION_DEFINITIONS[0]
|
||||
assert lock_def.get_confirm_prompt(strings) is None
|
||||
@@ -0,0 +1,139 @@
|
||||
# ABOUTME: Tests for power actions — lock, logout, hibernate, reboot, shutdown.
|
||||
# ABOUTME: Uses mocking to avoid actually calling system commands.
|
||||
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from moonset.power import lock, logout, hibernate, reboot, shutdown, POWER_TIMEOUT
|
||||
|
||||
|
||||
class TestLock:
|
||||
"""Tests for the lock power action."""
|
||||
|
||||
@patch("moonset.power.subprocess.run")
|
||||
def test_calls_loginctl_lock_session(self, mock_run) -> None:
|
||||
lock()
|
||||
|
||||
mock_run.assert_called_once_with(
|
||||
["loginctl", "lock-session"], check=True, timeout=POWER_TIMEOUT
|
||||
)
|
||||
|
||||
@patch("moonset.power.subprocess.run")
|
||||
def test_raises_on_failure(self, mock_run) -> None:
|
||||
mock_run.side_effect = subprocess.CalledProcessError(1, "loginctl")
|
||||
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
lock()
|
||||
|
||||
@patch("moonset.power.subprocess.run")
|
||||
def test_raises_on_timeout(self, mock_run) -> None:
|
||||
mock_run.side_effect = subprocess.TimeoutExpired("loginctl", POWER_TIMEOUT)
|
||||
|
||||
with pytest.raises(subprocess.TimeoutExpired):
|
||||
lock()
|
||||
|
||||
|
||||
class TestLogout:
|
||||
"""Tests for the logout power action."""
|
||||
|
||||
@patch("moonset.power.subprocess.run")
|
||||
def test_calls_niri_quit(self, mock_run) -> None:
|
||||
logout()
|
||||
|
||||
mock_run.assert_called_once_with(
|
||||
["niri", "msg", "action", "quit"], check=True, timeout=POWER_TIMEOUT
|
||||
)
|
||||
|
||||
@patch("moonset.power.subprocess.run")
|
||||
def test_raises_on_failure(self, mock_run) -> None:
|
||||
mock_run.side_effect = subprocess.CalledProcessError(1, "niri")
|
||||
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
logout()
|
||||
|
||||
@patch("moonset.power.subprocess.run")
|
||||
def test_raises_on_timeout(self, mock_run) -> None:
|
||||
mock_run.side_effect = subprocess.TimeoutExpired("niri", POWER_TIMEOUT)
|
||||
|
||||
with pytest.raises(subprocess.TimeoutExpired):
|
||||
logout()
|
||||
|
||||
|
||||
class TestHibernate:
|
||||
"""Tests for the hibernate power action."""
|
||||
|
||||
@patch("moonset.power.subprocess.run")
|
||||
def test_calls_systemctl_hibernate(self, mock_run) -> None:
|
||||
hibernate()
|
||||
|
||||
mock_run.assert_called_once_with(
|
||||
["systemctl", "hibernate"], check=True, timeout=POWER_TIMEOUT
|
||||
)
|
||||
|
||||
@patch("moonset.power.subprocess.run")
|
||||
def test_raises_on_failure(self, mock_run) -> None:
|
||||
mock_run.side_effect = subprocess.CalledProcessError(1, "systemctl")
|
||||
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
hibernate()
|
||||
|
||||
@patch("moonset.power.subprocess.run")
|
||||
def test_raises_on_timeout(self, mock_run) -> None:
|
||||
mock_run.side_effect = subprocess.TimeoutExpired("systemctl", POWER_TIMEOUT)
|
||||
|
||||
with pytest.raises(subprocess.TimeoutExpired):
|
||||
hibernate()
|
||||
|
||||
|
||||
class TestReboot:
|
||||
"""Tests for the reboot power action."""
|
||||
|
||||
@patch("moonset.power.subprocess.run")
|
||||
def test_calls_loginctl_reboot(self, mock_run) -> None:
|
||||
reboot()
|
||||
|
||||
mock_run.assert_called_once_with(
|
||||
["loginctl", "reboot"], check=True, timeout=POWER_TIMEOUT
|
||||
)
|
||||
|
||||
@patch("moonset.power.subprocess.run")
|
||||
def test_raises_on_failure(self, mock_run) -> None:
|
||||
mock_run.side_effect = subprocess.CalledProcessError(1, "loginctl")
|
||||
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
reboot()
|
||||
|
||||
@patch("moonset.power.subprocess.run")
|
||||
def test_raises_on_timeout(self, mock_run) -> None:
|
||||
mock_run.side_effect = subprocess.TimeoutExpired("loginctl", POWER_TIMEOUT)
|
||||
|
||||
with pytest.raises(subprocess.TimeoutExpired):
|
||||
reboot()
|
||||
|
||||
|
||||
class TestShutdown:
|
||||
"""Tests for the shutdown power action."""
|
||||
|
||||
@patch("moonset.power.subprocess.run")
|
||||
def test_calls_loginctl_poweroff(self, mock_run) -> None:
|
||||
shutdown()
|
||||
|
||||
mock_run.assert_called_once_with(
|
||||
["loginctl", "poweroff"], check=True, timeout=POWER_TIMEOUT
|
||||
)
|
||||
|
||||
@patch("moonset.power.subprocess.run")
|
||||
def test_raises_on_failure(self, mock_run) -> None:
|
||||
mock_run.side_effect = subprocess.CalledProcessError(1, "loginctl")
|
||||
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
shutdown()
|
||||
|
||||
@patch("moonset.power.subprocess.run")
|
||||
def test_raises_on_timeout(self, mock_run) -> None:
|
||||
mock_run.side_effect = subprocess.TimeoutExpired("loginctl", POWER_TIMEOUT)
|
||||
|
||||
with pytest.raises(subprocess.TimeoutExpired):
|
||||
shutdown()
|
||||
Reference in New Issue
Block a user