.json`` in
+``resources/translations/`` and optionally add an entry to
+:attr:`Translator.BUILTIN_LANGUAGES` for a nicer display name.
+"""
+
+import json
+import logging
+import sys
+from pathlib import Path
+from typing import Dict, Optional
+
+logger = logging.getLogger(__name__)
+
+
+class Translator:
+ """Manages translations for the application.
+
+ Loads translations from UTF-8 JSON files that use dot-notation string keys.
+ Falls back to the English translation (and ultimately to the bare key) when
+ a translation is missing.
+
+ Attributes:
+ BUILTIN_LANGUAGES: Mapping of language code → display name for languages
+ that ship with the application. Add entries here when including new
+ translation files.
+ """
+
+ #: Human-readable display names for supported language codes.
+ #: Unknown codes fall back to their uppercase code string.
+ BUILTIN_LANGUAGES: Dict[str, str] = {
+ "en": "English",
+ "de": "Deutsch",
+ "fr": "Français",
+ "it": "Italiano",
+ "ru": "Русский",
+ "zh": "中文",
+ }
+
+ def __init__(self) -> None:
+ self._language: str = "en"
+ self._translations: Dict[str, str] = {}
+ self._fallback: Dict[str, str] = {}
+ self._translations_dir: Optional[Path] = None
+
+ def initialize(self, language: str, translations_dir: Path) -> None:
+ """Initialize the translator with a language and translations directory.
+
+ Args:
+ language: Language code (e.g. ``"en"``, ``"de"``, ``"fr"``) or
+ ``"auto"`` to detect from the system locale.
+ translations_dir: Directory containing the ``.json`` translation files.
+ """
+ self._translations_dir = translations_dir
+
+ # Resolve "auto" to system locale
+ if language == "auto":
+ language = self._detect_system_language()
+ logger.debug(f"Auto-detected language: {language}")
+
+ # Load English as fallback first
+ en_path = translations_dir / "en.json"
+ if en_path.exists():
+ self._fallback = self._load_file(en_path)
+ logger.debug(f"Loaded English fallback translations ({len(self._fallback)} keys)")
+ else:
+ logger.warning(f"English translation file not found at {en_path}")
+
+ # Load requested language
+ self._language = language
+ if language != "en":
+ lang_path = translations_dir / f"{language}.json"
+ if lang_path.exists():
+ self._translations = self._load_file(lang_path)
+ logger.debug(f"Loaded '{language}' translations ({len(self._translations)} keys)")
+ else:
+ logger.warning(
+ f"Translation file not found for language '{language}', "
+ "falling back to English"
+ )
+ self._translations = {}
+ else:
+ self._translations = self._fallback
+
+ def tr(self, key: str, **kwargs: str) -> str:
+ """Get translated string for the given key.
+
+ Args:
+ key: Translation key using dot-notation (e.g. ``"toolbar.home"``).
+ **kwargs: Named format arguments applied to the translated string.
+
+ Returns:
+ Translated and formatted string. Returns the *key* itself when no
+ translation is found, so missing keys are always visible.
+ """
+ text = self._translations.get(key) or self._fallback.get(key) or key
+ if kwargs:
+ try:
+ text = text.format(**kwargs)
+ except (KeyError, ValueError) as e:
+ logger.debug(f"Translation format error for key '{key}': {e}")
+ return text
+
+ def get_current_language(self) -> str:
+ """Get the currently active language code (e.g. ``"de"``)."""
+ return self._language
+
+ def get_available_languages(self) -> Dict[str, str]:
+ """Return available languages as ``{code: display_name}``.
+
+ Discovers language files at runtime so newly added JSON files are
+ automatically included without code changes.
+
+ Returns:
+ Ordered dict mapping language code → human-readable display name.
+ """
+ if self._translations_dir is None:
+ return {"en": "English"}
+
+ languages: Dict[str, str] = {}
+ for lang_file in sorted(self._translations_dir.glob("*.json")):
+ code = lang_file.stem
+ name = self.BUILTIN_LANGUAGES.get(code, code.upper())
+ languages[code] = name
+ return languages
+
+ # ------------------------------------------------------------------
+ # Private helpers
+ # ------------------------------------------------------------------
+
+ def _load_file(self, path: Path) -> Dict[str, str]:
+ """Load a JSON translation file.
+
+ Args:
+ path: Path to the UTF-8 encoded JSON translation file.
+
+ Returns:
+ Dictionary of translation keys to translated strings, or an empty
+ dict when the file cannot be read or parsed.
+ """
+ try:
+ with open(path, "r", encoding="utf-8") as f:
+ return json.load(f)
+ except (json.JSONDecodeError, IOError) as e:
+ logger.error(f"Failed to load translation file {path}: {e}")
+ return {}
+
+ def _detect_system_language(self) -> str:
+ """Detect system language from locale or platform settings.
+
+ On Windows, attempts to read the UI language via the WinAPI before
+ falling back to the ``locale`` module.
+
+ Returns:
+ Best-matching supported language code, or ``"en"`` as fallback.
+ """
+ import locale
+
+ try:
+ lang_code: Optional[str] = None
+
+ if sys.platform.startswith("win"):
+ # Windows: use GetUserDefaultUILanguage for accuracy
+ try:
+ import ctypes
+
+ lcid = ctypes.windll.kernel32.GetUserDefaultUILanguage() # type: ignore[attr-defined]
+ # Subset of LCID → ISO 639-1 mappings
+ lcid_map: Dict[int, str] = {
+ 0x0407: "de", # German (Germany)
+ 0x0C07: "de", # German (Austria)
+ 0x0807: "de", # German (Switzerland)
+ 0x040C: "fr", # French (France)
+ 0x080C: "fr", # French (Belgium)
+ 0x0C0C: "fr", # French (Canada)
+ 0x100C: "fr", # French (Switzerland)
+ 0x0410: "it", # Italian (Italy)
+ 0x0810: "it", # Italian (Switzerland)
+ 0x0419: "ru", # Russian
+ 0x0804: "zh", # Chinese Simplified
+ 0x0404: "zh", # Chinese Traditional
+ 0x0409: "en", # English (US)
+ 0x0809: "en", # English (UK)
+ }
+ lang_code = lcid_map.get(lcid)
+ except Exception:
+ pass
+
+ if not lang_code:
+ raw = locale.getdefaultlocale()[0] or ""
+ lang_code = raw.split("_")[0].lower() if raw else None
+
+ if lang_code and lang_code in self.BUILTIN_LANGUAGES:
+ return lang_code
+
+ except Exception as e:
+ logger.debug(f"Language auto-detection failed: {e}")
+
+ return "en"
+
+
+# ---------------------------------------------------------------------------
+# Module-level singleton and public API
+# ---------------------------------------------------------------------------
+
+_translator = Translator()
+
+
+def _ensure_initialized() -> None:
+ """Initialize translator lazily with default settings if needed."""
+ if _translator._translations_dir is not None: # type: ignore[attr-defined]
+ return
+ _translator.initialize("en", get_translations_dir())
+
+
+def initialize(language: str, translations_dir: Path) -> None:
+ """Initialize the global translator.
+
+ Should be called **once at application startup**, before any UI is shown.
+
+ Args:
+ language: Language code (e.g. ``"de"``) or ``"auto"`` for system
+ locale detection.
+ translations_dir: Directory containing the ``.json`` translation files.
+ """
+ _translator.initialize(language, translations_dir)
+
+
+def tr(key: str, **kwargs: str) -> str:
+ """Translate a string by key.
+
+ Args:
+ key: Translation key (e.g. ``"toolbar.home"``).
+ **kwargs: Named format arguments (e.g. ``name="file.pdf"``).
+
+ Returns:
+ Translated string with any format substitutions applied.
+ """
+ _ensure_initialized()
+ text = _translator.tr(key, **kwargs)
+
+ # If lookup failed and translator points to a non-default directory (e.g. tests
+ # overriding translator state), retry from default bundled translations.
+ if text == key:
+ default_dir = get_translations_dir()
+ current_dir = _translator._translations_dir # type: ignore[attr-defined]
+ if current_dir != default_dir:
+ _translator.initialize("en", default_dir)
+ text = _translator.tr(key, **kwargs)
+
+ return text
+
+
+def get_current_language() -> str:
+ """Return the currently active language code (e.g. ``"de"``)."""
+ return _translator.get_current_language()
+
+
+def get_available_languages() -> Dict[str, str]:
+ """Return all available languages as ``{code: display_name}``."""
+ _ensure_initialized()
+ return _translator.get_available_languages()
+
+
+def get_translations_dir() -> Path:
+ """Resolve the translations directory for the current runtime context.
+
+ Handles development mode, PyInstaller bundles, and MSI installations
+ by searching the known candidate paths in order.
+
+ Returns:
+ Path to the ``resources/translations`` directory.
+ """
+ if hasattr(sys, "_MEIPASS"):
+ # PyInstaller bundle
+ return Path(sys._MEIPASS) / "resources" / "translations" # type: ignore[attr-defined]
+ # Development mode or installed Python package
+ return Path(__file__).parent.parent.parent.parent / "resources" / "translations"
diff --git a/tests/unit/test_brand_config.py b/tests/unit/test_brand_config.py
new file mode 100644
index 0000000..9016fef
--- /dev/null
+++ b/tests/unit/test_brand_config.py
@@ -0,0 +1,147 @@
+"""Tests for brand-aware build configuration helpers."""
+
+import json
+import sys
+from pathlib import Path
+
+BUILD_SCRIPTS_DIR = Path(__file__).resolve().parents[2] / "build" / "scripts"
+if str(BUILD_SCRIPTS_DIR) not in sys.path:
+ sys.path.insert(0, str(BUILD_SCRIPTS_DIR))
+
+from brand_config import (
+ DEFAULT_BRAND_ID,
+ collect_local_release_data,
+ generate_release_manifest,
+ load_brand_config,
+ merge_release_manifests,
+)
+
+
+def test_load_agravity_brand_config():
+ """Test loading the Agravity brand manifest."""
+ brand = load_brand_config("agravity")
+
+ assert brand.brand_id == "agravity"
+ assert brand.display_name == "Agravity Bridge"
+ assert brand.asset_prefix == "AgravityBridge"
+ assert brand.exe_name == "AgravityBridge"
+ assert brand.toolbar_icon_home == "resources/icons/home.ico"
+ assert brand.toolbar_icon_reload == "resources/icons/reload.ico"
+ assert brand.toolbar_icon_open == "resources/icons/open.ico"
+ assert brand.toolbar_icon_openwith == "resources/icons/openwith.ico"
+ assert brand.windows_installer_name("0.8.4") == "AgravityBridge-0.8.4-win-x64.msi"
+
+
+def test_generate_release_manifest_for_agravity(tmp_path):
+ """Test generating a shared release manifest from local artifacts."""
+ project_root = tmp_path
+ (project_root / "build" / "brands").mkdir(parents=True)
+ (project_root / "build" / "dist" / "windows" / "agravity").mkdir(parents=True)
+ (project_root / "build" / "dist" / "macos" / "agravity").mkdir(parents=True)
+
+ source_manifest = Path(__file__).resolve().parents[2] / "build" / "brands" / "agravity.json"
+ (project_root / "build" / "brands" / "agravity.json").write_text(
+ source_manifest.read_text(encoding="utf-8"),
+ encoding="utf-8",
+ )
+
+ win_installer = (
+ project_root
+ / "build"
+ / "dist"
+ / "windows"
+ / "agravity"
+ / "AgravityBridge-0.8.4-win-x64.msi"
+ )
+ win_installer.write_bytes(b"msi")
+ (win_installer.parent / f"{win_installer.name}.sha256").write_text("abc", encoding="utf-8")
+
+ mac_installer = (
+ project_root
+ / "build"
+ / "dist"
+ / "macos"
+ / "agravity"
+ / "AgravityBridge-0.8.4-macos-universal.dmg"
+ )
+ mac_installer.write_bytes(b"dmg")
+ (mac_installer.parent / f"{mac_installer.name}.sha256").write_text("def", encoding="utf-8")
+
+ output_path = project_root / "build" / "dist" / "release-manifest.json"
+ generate_release_manifest(
+ "0.8.4",
+ ["agravity"],
+ output_path=output_path,
+ root=project_root,
+ )
+
+ manifest = json.loads(output_path.read_text(encoding="utf-8"))
+ assert manifest["version"] == "0.8.4"
+ assert (
+ manifest["brands"]["agravity"]["windows-x64"]["installer"]
+ == "AgravityBridge-0.8.4-win-x64.msi"
+ )
+ assert (
+ manifest["brands"]["agravity"]["macos-universal"]["installer"]
+ == "AgravityBridge-0.8.4-macos-universal.dmg"
+ )
+
+
+def test_collect_local_release_data_includes_default_brand(tmp_path):
+ """Test discovering local artifacts for the default Windows build."""
+ project_root = tmp_path
+ installer_dir = project_root / "build" / "dist" / "windows" / DEFAULT_BRAND_ID
+ installer_dir.mkdir(parents=True)
+
+ installer = installer_dir / "WebDropBridge-0.8.4-win-x64.msi"
+ installer.write_bytes(b"msi")
+ checksum = installer_dir / f"{installer.name}.sha256"
+ checksum.write_text("abc", encoding="utf-8")
+
+ data = collect_local_release_data("0.8.4", platform="windows", root=project_root)
+
+ assert data["brands"] == [DEFAULT_BRAND_ID]
+ assert str(installer) in data["artifacts"]
+ assert str(checksum) in data["artifacts"]
+ assert (
+ data["manifest"]["brands"][DEFAULT_BRAND_ID]["windows-x64"]["installer"] == installer.name
+ )
+
+
+def test_merge_release_manifests_preserves_existing_platforms():
+ """Test merging platform-specific manifest entries from separate upload runs."""
+ base_manifest = {
+ "version": "0.8.4",
+ "channel": "stable",
+ "brands": {
+ "agravity": {
+ "windows-x64": {
+ "installer": "AgravityBridge-0.8.4-win-x64.msi",
+ "checksum": "AgravityBridge-0.8.4-win-x64.msi.sha256",
+ }
+ }
+ },
+ }
+ overlay_manifest = {
+ "version": "0.8.4",
+ "channel": "stable",
+ "brands": {
+ "agravity": {
+ "macos-universal": {
+ "installer": "AgravityBridge-0.8.4-macos-universal.dmg",
+ "checksum": "AgravityBridge-0.8.4-macos-universal.dmg.sha256",
+ }
+ }
+ },
+ }
+
+ merged = merge_release_manifests(base_manifest, overlay_manifest)
+
+ assert (
+ merged["brands"]["agravity"]["windows-x64"]["installer"]
+ == "AgravityBridge-0.8.4-win-x64.msi"
+ )
+ assert (
+ merged["brands"]["agravity"]["macos-universal"]["installer"]
+ == "AgravityBridge-0.8.4-macos-universal.dmg"
+ )
diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py
index fdeda3d..09e4d6d 100644
--- a/tests/unit/test_config.py
+++ b/tests/unit/test_config.py
@@ -1,6 +1,7 @@
"""Unit tests for configuration system."""
import os
+import sys
import pytest
@@ -12,14 +13,26 @@ def clear_env():
"""Clear environment variables before each test to avoid persistence."""
# Save current env
saved_env = os.environ.copy()
-
+
# Clear relevant variables
for key in list(os.environ.keys()):
- if key.startswith(('APP_', 'LOG_', 'ALLOWED_', 'WEBAPP_', 'WINDOW_', 'ENABLE_')):
+ if key.startswith(
+ (
+ "APP_",
+ "LOG_",
+ "ALLOWED_",
+ "WEBAPP_",
+ "WINDOW_",
+ "ENABLE_",
+ "BRAND_",
+ "UPDATE_",
+ "LANGUAGE",
+ )
+ ):
del os.environ[key]
-
+
yield
-
+
# Restore env (cleanup)
os.environ.clear()
os.environ.update(saved_env)
@@ -64,6 +77,28 @@ class TestConfigFromEnv:
assert config.window_width == 1200
assert config.window_height == 800
+ def test_from_env_with_branding_values(self, tmp_path):
+ """Test loading branding and update metadata from environment."""
+ env_file = tmp_path / ".env"
+ root1 = tmp_path / "root1"
+ root1.mkdir()
+ env_file.write_text(
+ f"BRAND_ID=agravity\n"
+ f"APP_CONFIG_DIR_NAME=agravity_bridge\n"
+ f"UPDATE_REPO=HIM-public/webdrop-bridge\n"
+ f"UPDATE_CHANNEL=stable\n"
+ f"UPDATE_MANIFEST_NAME=release-manifest.json\n"
+ f"ALLOWED_ROOTS={root1}\n"
+ )
+
+ config = Config.from_env(str(env_file))
+
+ assert config.brand_id == "agravity"
+ assert config.config_dir_name == "agravity_bridge"
+ assert config.update_repo == "HIM-public/webdrop-bridge"
+ assert config.update_channel == "stable"
+ assert config.update_manifest_name == "release-manifest.json"
+
def test_from_env_with_defaults(self, tmp_path):
"""Test loading config uses defaults when env vars not set."""
# Create empty .env file
@@ -73,8 +108,11 @@ class TestConfigFromEnv:
config = Config.from_env(str(env_file))
assert config.app_name == "WebDrop Bridge"
+ assert config.brand_id == "webdrop_bridge"
+ assert config.config_dir_name == "webdrop_bridge"
# Version should come from __init__.py (dynamic, not hardcoded)
from webdrop_bridge import __version__
+
assert config.app_version == __version__
assert config.log_level == "INFO"
assert config.window_width == 1024
@@ -187,3 +225,30 @@ class TestConfigValidation:
config = Config.from_env(str(env_file))
assert config.allowed_urls == ["example.com", "test.org"]
+
+ def test_brand_specific_default_paths(self):
+ """Test brand-specific config and log directories."""
+ config_path = Config.get_default_config_path("agravity_bridge")
+ log_path = Config.get_default_log_path("agravity_bridge")
+
+ assert config_path.parts[-2:] == ("agravity_bridge", "config.json")
+ assert log_path.parts[-2:] == ("logs", "agravity_bridge.log")
+
+
+class TestBootstrapEnvLoading:
+ """Test bootstrap .env loading behavior for packaged builds."""
+
+ def test_load_bootstrap_env_reads_meipass_dotenv(self, tmp_path, monkeypatch):
+ """Packaged app should load .env from PyInstaller runtime directory."""
+ meipass_dir = tmp_path / "runtime"
+ meipass_dir.mkdir(parents=True)
+ env_path = meipass_dir / ".env"
+ env_path.write_text("APP_NAME=Agravity Bridge\n", encoding="utf-8")
+
+ monkeypatch.setattr(sys, "frozen", True, raising=False)
+ monkeypatch.setattr(sys, "_MEIPASS", str(meipass_dir), raising=False)
+
+ loaded_path = Config.load_bootstrap_env()
+
+ assert loaded_path == env_path
+ assert os.getenv("APP_NAME") == "Agravity Bridge"
diff --git a/tests/unit/test_i18n.py b/tests/unit/test_i18n.py
new file mode 100644
index 0000000..b52da9d
--- /dev/null
+++ b/tests/unit/test_i18n.py
@@ -0,0 +1,60 @@
+"""Unit tests for i18n translation helper."""
+
+import json
+from pathlib import Path
+
+from webdrop_bridge.utils import i18n
+
+
+class TestI18n:
+ """Tests for translation lookup and fallback behavior."""
+
+ def test_tr_lazy_initialization_uses_english_defaults(self):
+ """Translator should lazily initialize and resolve known keys."""
+ # Force a fresh singleton state for this test.
+ i18n._translator = i18n.Translator() # type: ignore[attr-defined]
+
+ assert i18n.tr("settings.title") == "Settings"
+
+ def test_initialize_with_language_falls_back_to_english(self, tmp_path: Path):
+ """Missing keys in selected language should fall back to English."""
+ translations = tmp_path / "translations"
+ translations.mkdir(parents=True, exist_ok=True)
+
+ (translations / "en.json").write_text(
+ json.dumps(
+ {
+ "greeting": "Hello {name}",
+ "settings.title": "Settings",
+ }
+ ),
+ encoding="utf-8",
+ )
+ (translations / "de.json").write_text(
+ json.dumps(
+ {
+ "settings.title": "Einstellungen",
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ i18n._translator = i18n.Translator() # type: ignore[attr-defined]
+ i18n.initialize("de", translations)
+
+ assert i18n.tr("settings.title") == "Einstellungen"
+ assert i18n.tr("greeting", name="Alex") == "Hello Alex"
+
+ def test_get_available_languages_reads_translation_files(self, tmp_path: Path):
+ """Available languages should be discovered from JSON files."""
+ translations = tmp_path / "translations"
+ translations.mkdir(parents=True, exist_ok=True)
+ (translations / "en.json").write_text("{}", encoding="utf-8")
+ (translations / "fr.json").write_text("{}", encoding="utf-8")
+
+ i18n._translator = i18n.Translator() # type: ignore[attr-defined]
+ i18n.initialize("en", translations)
+
+ available = i18n.get_available_languages()
+ assert "en" in available
+ assert "fr" in available
diff --git a/tests/unit/test_main_window.py b/tests/unit/test_main_window.py
index 01eaa5a..161be8a 100644
--- a/tests/unit/test_main_window.py
+++ b/tests/unit/test_main_window.py
@@ -149,3 +149,74 @@ class TestMainWindowURLWhitelist:
# web_view should have allowed_urls configured
assert window.web_view.allowed_urls == sample_config.allowed_urls
+
+
+class TestMainWindowOpenWith:
+ """Test Open With chooser behavior."""
+
+ def test_open_with_app_chooser_windows(self, qtbot, sample_config):
+ """Windows should use ShellExecuteW with the openas verb."""
+ window = MainWindow(sample_config)
+ qtbot.addWidget(window)
+
+ test_file = sample_config.allowed_roots[0] / "open_with_test.txt"
+ test_file.write_text("test")
+
+ with patch("webdrop_bridge.ui.main_window.sys.platform", "win32"):
+ with patch("ctypes.windll.shell32.ShellExecuteW", return_value=33) as mock_shell:
+ assert window._open_with_app_chooser(str(test_file)) is True
+ mock_shell.assert_called_once_with(
+ None,
+ "openas",
+ str(test_file),
+ None,
+ None,
+ 1,
+ )
+
+ def test_open_with_app_chooser_windows_shellexecute_failure(self, qtbot, sample_config):
+ """Windows should fall back to OpenAs_RunDLL when ShellExecuteW fails."""
+ window = MainWindow(sample_config)
+ qtbot.addWidget(window)
+
+ test_file = sample_config.allowed_roots[0] / "open_with_fallback.txt"
+ test_file.write_text("test")
+
+ with patch("webdrop_bridge.ui.main_window.sys.platform", "win32"):
+ with patch("ctypes.windll.shell32.ShellExecuteW", return_value=31):
+ with patch("webdrop_bridge.ui.main_window.subprocess.Popen") as mock_popen:
+ assert window._open_with_app_chooser(str(test_file)) is True
+ mock_popen.assert_called_once_with(
+ ["rundll32.exe", "shell32.dll,OpenAs_RunDLL", str(test_file)]
+ )
+
+ def test_open_with_app_chooser_missing_file(self, qtbot, sample_config):
+ """Missing files should fail before platform-specific invocation."""
+ window = MainWindow(sample_config)
+ qtbot.addWidget(window)
+
+ with patch("webdrop_bridge.ui.main_window.sys.platform", "win32"):
+ assert window._open_with_app_chooser("C:/tmp/does_not_exist.txt") is False
+
+ def test_open_with_app_chooser_macos_success(self, qtbot, sample_config):
+ """macOS should return True when osascript exits successfully."""
+ window = MainWindow(sample_config)
+ qtbot.addWidget(window)
+
+ test_file = sample_config.allowed_roots[0] / "open_with_macos.txt"
+ test_file.write_text("test")
+
+ class _Result:
+ returncode = 0
+
+ with patch("webdrop_bridge.ui.main_window.sys.platform", "darwin"):
+ with patch("webdrop_bridge.ui.main_window.subprocess.run", return_value=_Result()):
+ assert window._open_with_app_chooser(str(test_file)) is True
+
+ def test_open_with_app_chooser_unsupported_platform(self, qtbot, sample_config):
+ """Unsupported platforms should return False."""
+ window = MainWindow(sample_config)
+ qtbot.addWidget(window)
+
+ with patch("webdrop_bridge.ui.main_window.sys.platform", "linux"):
+ assert window._open_with_app_chooser("/tmp/test.txt") is False
diff --git a/tests/unit/test_settings_dialog.py b/tests/unit/test_settings_dialog.py
index 51fd02d..06d78d0 100644
--- a/tests/unit/test_settings_dialog.py
+++ b/tests/unit/test_settings_dialog.py
@@ -34,7 +34,7 @@ class TestSettingsDialogInitialization:
"""Test dialog can be created."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
-
+
assert dialog is not None
assert dialog.windowTitle() == "Settings"
@@ -42,51 +42,58 @@ class TestSettingsDialogInitialization:
"""Test dialog has all required tabs."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
-
+
assert dialog.tabs is not None
- assert dialog.tabs.count() == 6 # Web Source, Paths, URLs, Logging, Window, Profiles
+ assert dialog.tabs.count() == 7 # General + previous 6 tabs
+
+ def test_dialog_has_general_tab(self, qtbot, sample_config):
+ """Test General tab exists."""
+ dialog = SettingsDialog(sample_config)
+ qtbot.addWidget(dialog)
+
+ assert dialog.tabs.tabText(0) == "General"
def test_dialog_has_web_source_tab(self, qtbot, sample_config):
"""Test Web Source tab exists."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
-
- assert dialog.tabs.tabText(0) == "Web Source"
+
+ assert dialog.tabs.tabText(1) == "Web Source"
def test_dialog_has_paths_tab(self, qtbot, sample_config):
"""Test Paths tab exists."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
-
- assert dialog.tabs.tabText(1) == "Paths"
+
+ assert dialog.tabs.tabText(2) == "Paths"
def test_dialog_has_urls_tab(self, qtbot, sample_config):
"""Test URLs tab exists."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
-
- assert dialog.tabs.tabText(2) == "URLs"
+
+ assert dialog.tabs.tabText(3) == "URLs"
def test_dialog_has_logging_tab(self, qtbot, sample_config):
"""Test Logging tab exists."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
-
- assert dialog.tabs.tabText(3) == "Logging"
+
+ assert dialog.tabs.tabText(4) == "Logging"
def test_dialog_has_window_tab(self, qtbot, sample_config):
"""Test Window tab exists."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
-
- assert dialog.tabs.tabText(4) == "Window"
+
+ assert dialog.tabs.tabText(5) == "Window"
def test_dialog_has_profiles_tab(self, qtbot, sample_config):
"""Test Profiles tab exists."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
-
- assert dialog.tabs.tabText(5) == "Profiles"
+
+ assert dialog.tabs.tabText(6) == "Profiles"
class TestPathsTab:
@@ -96,7 +103,7 @@ class TestPathsTab:
"""Test paths are loaded from configuration."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
-
+
items = [dialog.paths_list.item(i).text() for i in range(dialog.paths_list.count())]
assert len(items) == 2
# Paths are normalized (backslashes on Windows)
@@ -107,7 +114,7 @@ class TestPathsTab:
"""Test Add Path button exists."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
-
+
assert dialog.tabs.currentWidget() is not None
@@ -118,7 +125,7 @@ class TestURLsTab:
"""Test URLs are loaded from configuration."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
-
+
items = [dialog.urls_list.item(i).text() for i in range(dialog.urls_list.count())]
assert len(items) == 2
assert "http://example.com" in items
@@ -132,14 +139,14 @@ class TestLoggingTab:
"""Test log level is set from configuration."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
-
+
assert dialog.log_level_combo.currentText() == "INFO"
def test_log_levels_available(self, qtbot, sample_config):
"""Test all log levels are available."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
-
+
levels = [dialog.log_level_combo.itemText(i) for i in range(dialog.log_level_combo.count())]
assert "DEBUG" in levels
assert "INFO" in levels
@@ -155,21 +162,21 @@ class TestWindowTab:
"""Test window width is set from configuration."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
-
+
assert dialog.width_spin.value() == 800
def test_window_height_set_from_config(self, qtbot, sample_config):
"""Test window height is set from configuration."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
-
+
assert dialog.height_spin.value() == 600
def test_window_width_has_min_max(self, qtbot, sample_config):
"""Test window width spinbox has min/max."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
-
+
assert dialog.width_spin.minimum() == 400
assert dialog.width_spin.maximum() == 5000
@@ -177,7 +184,7 @@ class TestWindowTab:
"""Test window height spinbox has min/max."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
-
+
assert dialog.height_spin.minimum() == 300
assert dialog.height_spin.maximum() == 5000
@@ -189,7 +196,7 @@ class TestProfilesTab:
"""Test profiles list is initialized."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
-
+
assert dialog.profiles_list is not None
@@ -200,9 +207,9 @@ class TestConfigDataRetrieval:
"""Test retrieving configuration data from dialog."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
-
+
config_data = dialog.get_config_data()
-
+
assert config_data["app_name"] == "WebDrop Bridge"
assert config_data["log_level"] == "INFO"
assert config_data["window_width"] == 800
@@ -212,7 +219,7 @@ class TestConfigDataRetrieval:
"""Test get_config_data returns valid configuration data."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
-
+
# All default values are valid
config_data = dialog.get_config_data()
assert config_data is not None
@@ -222,14 +229,14 @@ class TestConfigDataRetrieval:
"""Test get_config_data returns modified values."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
-
+
# Modify values
dialog.width_spin.setValue(1024)
dialog.height_spin.setValue(768)
dialog.log_level_combo.setCurrentText("DEBUG")
-
+
config_data = dialog.get_config_data()
-
+
assert config_data["window_width"] == 1024
assert config_data["window_height"] == 768
assert config_data["log_level"] == "DEBUG"
@@ -242,7 +249,7 @@ class TestApplyConfigData:
"""Test applying config data updates paths."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
-
+
new_config = {
"app_name": "Test",
"app_version": "1.0.0",
@@ -255,9 +262,9 @@ class TestApplyConfigData:
"window_height": 600,
"enable_logging": True,
}
-
+
dialog._apply_config_data(new_config)
-
+
items = [dialog.paths_list.item(i).text() for i in range(dialog.paths_list.count())]
assert "/new/path" in items
assert "/another/path" in items
@@ -266,7 +273,7 @@ class TestApplyConfigData:
"""Test applying config data updates URLs."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
-
+
new_config = {
"app_name": "Test",
"app_version": "1.0.0",
@@ -279,9 +286,9 @@ class TestApplyConfigData:
"window_height": 600,
"enable_logging": True,
}
-
+
dialog._apply_config_data(new_config)
-
+
items = [dialog.urls_list.item(i).text() for i in range(dialog.urls_list.count())]
assert "http://new.com" in items
assert "http://test.org" in items
@@ -290,7 +297,7 @@ class TestApplyConfigData:
"""Test applying config data updates window size."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
-
+
new_config = {
"app_name": "Test",
"app_version": "1.0.0",
@@ -303,8 +310,8 @@ class TestApplyConfigData:
"window_height": 1024,
"enable_logging": True,
}
-
+
dialog._apply_config_data(new_config)
-
+
assert dialog.width_spin.value() == 1280
assert dialog.height_spin.value() == 1024
diff --git a/tests/unit/test_updater.py b/tests/unit/test_updater.py
index 1685f20..db57ebc 100644
--- a/tests/unit/test_updater.py
+++ b/tests/unit/test_updater.py
@@ -16,6 +16,17 @@ def update_manager(tmp_path):
return UpdateManager(current_version="0.0.1", config_dir=tmp_path)
+@pytest.fixture
+def agravity_update_manager(tmp_path):
+ """Create a brand-aware UpdateManager instance for Agravity Bridge."""
+ return UpdateManager(
+ current_version="0.0.1",
+ config_dir=tmp_path,
+ brand_id="agravity",
+ update_channel="stable",
+ )
+
+
@pytest.fixture
def sample_release():
"""Sample release data from API."""
@@ -252,6 +263,143 @@ class TestDownloading:
assert result is None
+ @pytest.mark.asyncio
+ async def test_download_update_uses_release_manifest(self, agravity_update_manager, tmp_path):
+ """Test branded download selection from a shared release manifest."""
+ release = Release(
+ tag_name="v0.0.2",
+ name="WebDropBridge v0.0.2",
+ version="0.0.2",
+ body="Release notes",
+ assets=[
+ {
+ "name": "AgravityBridge-0.0.2-win-x64.msi",
+ "browser_download_url": "https://example.com/AgravityBridge-0.0.2-win-x64.msi",
+ },
+ {
+ "name": "AgravityBridge-0.0.2-win-x64.msi.sha256",
+ "browser_download_url": "https://example.com/AgravityBridge-0.0.2-win-x64.msi.sha256",
+ },
+ {
+ "name": "OtherBridge-0.0.2-win-x64.msi",
+ "browser_download_url": "https://example.com/OtherBridge-0.0.2-win-x64.msi",
+ },
+ {
+ "name": "release-manifest.json",
+ "browser_download_url": "https://example.com/release-manifest.json",
+ },
+ ],
+ published_at="2026-01-29T10:00:00Z",
+ )
+
+ manifest = {
+ "version": "0.0.2",
+ "channel": "stable",
+ "brands": {
+ "agravity": {
+ "windows-x64": {
+ "installer": "AgravityBridge-0.0.2-win-x64.msi",
+ "checksum": "AgravityBridge-0.0.2-win-x64.msi.sha256",
+ }
+ }
+ },
+ }
+
+ with (
+ patch.object(UpdateManager, "_download_json_asset", return_value=manifest),
+ patch.object(UpdateManager, "_download_file", return_value=True) as mock_download,
+ ):
+ result = await agravity_update_manager.download_update(release, tmp_path)
+
+ assert result is not None
+ assert result.name == "AgravityBridge-0.0.2-win-x64.msi"
+ mock_download.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_download_update_falls_back_to_brand_prefix_without_manifest(
+ self, agravity_update_manager, tmp_path
+ ):
+ """Test branded download selection still works when the manifest is unavailable."""
+ release = Release(
+ tag_name="v0.0.2",
+ name="WebDropBridge v0.0.2",
+ version="0.0.2",
+ body="Release notes",
+ assets=[
+ {
+ "name": "WebDropBridge-0.0.2-win-x64.msi",
+ "browser_download_url": "https://example.com/WebDropBridge-0.0.2-win-x64.msi",
+ },
+ {
+ "name": "AgravityBridge-0.0.2-win-x64.msi",
+ "browser_download_url": "https://example.com/AgravityBridge-0.0.2-win-x64.msi",
+ },
+ {
+ "name": "AgravityBridge-0.0.2-win-x64.msi.sha256",
+ "browser_download_url": "https://example.com/AgravityBridge-0.0.2-win-x64.msi.sha256",
+ },
+ ],
+ published_at="2026-01-29T10:00:00Z",
+ )
+
+ with patch.object(UpdateManager, "_download_file", return_value=True) as mock_download:
+ result = await agravity_update_manager.download_update(release, tmp_path)
+
+ assert result is not None
+ assert result.name == "AgravityBridge-0.0.2-win-x64.msi"
+ mock_download.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_verify_checksum_uses_release_manifest(self, agravity_update_manager, tmp_path):
+ """Test branded checksum selection from a shared release manifest."""
+ test_file = tmp_path / "AgravityBridge-0.0.2-win-x64.msi"
+ test_file.write_bytes(b"test content")
+
+ import hashlib
+
+ checksum = hashlib.sha256(b"test content").hexdigest()
+ release = Release(
+ tag_name="v0.0.2",
+ name="WebDropBridge v0.0.2",
+ version="0.0.2",
+ body="Release notes",
+ assets=[
+ {
+ "name": "AgravityBridge-0.0.2-win-x64.msi",
+ "browser_download_url": "https://example.com/AgravityBridge-0.0.2-win-x64.msi",
+ },
+ {
+ "name": "AgravityBridge-0.0.2-win-x64.msi.sha256",
+ "browser_download_url": "https://example.com/AgravityBridge-0.0.2-win-x64.msi.sha256",
+ },
+ {
+ "name": "release-manifest.json",
+ "browser_download_url": "https://example.com/release-manifest.json",
+ },
+ ],
+ published_at="2026-01-29T10:00:00Z",
+ )
+ manifest = {
+ "version": "0.0.2",
+ "channel": "stable",
+ "brands": {
+ "agravity": {
+ "windows-x64": {
+ "installer": "AgravityBridge-0.0.2-win-x64.msi",
+ "checksum": "AgravityBridge-0.0.2-win-x64.msi.sha256",
+ }
+ }
+ },
+ }
+
+ with (
+ patch.object(UpdateManager, "_download_json_asset", return_value=manifest),
+ patch.object(UpdateManager, "_download_checksum", return_value=checksum),
+ ):
+ result = await agravity_update_manager.verify_checksum(test_file, release)
+
+ assert result is True
+
class TestChecksumVerification:
"""Test checksum verification."""