snapshot of https://github.com/ActivityWatch/aw-watcher-window.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build / test on ubuntu-latest (py-3.9) (push) Failing after 2m36s
Build / typecheck (push) Successful in 1m38s
Build / test on macos-latest (py-3.9) (push) Has been cancelled
Build / test on windows-latest (py-3.9) (push) Has been cancelled
CodeQL / Analyze (python) (push) Failing after 1s
Some checks failed
Build / test on ubuntu-latest (py-3.9) (push) Failing after 2m36s
Build / typecheck (push) Successful in 1m38s
Build / test on macos-latest (py-3.9) (push) Has been cancelled
Build / test on windows-latest (py-3.9) (push) Has been cancelled
CodeQL / Analyze (python) (push) Failing after 1s
This commit is contained in:
176
tests/test_config.py
Normal file
176
tests/test_config.py
Normal file
@@ -0,0 +1,176 @@
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
import aw_core.dirs
|
||||
import pytest
|
||||
import tomlkit
|
||||
|
||||
from aw_watcher_window import config as config_module
|
||||
|
||||
# The Research Edition release build (release.yml) patches config.py with
|
||||
# scripts/patch_research_edition_config.py before running `make test`, which
|
||||
# flips research_enabled's default to true. The guards below assert the
|
||||
# pristine (non-research) defaults and must not fire on that build.
|
||||
RESEARCH_BUILD = os.environ.get("AW_RESEARCH_EDITION") == "true"
|
||||
|
||||
|
||||
def _patch_config_dir(monkeypatch, tmp_path):
|
||||
"""Point aw_core's config dir at tmp_path on all platforms.
|
||||
|
||||
XDG_CONFIG_HOME is Linux-only; appdirs ignores it on macOS/Windows.
|
||||
Patching get_config_dir directly is cross-platform.
|
||||
"""
|
||||
def _mock(module_name=None):
|
||||
base = tmp_path / "activitywatch"
|
||||
result = base / module_name if module_name else base
|
||||
result.mkdir(parents=True, exist_ok=True)
|
||||
return str(result)
|
||||
monkeypatch.setattr(aw_core.dirs, "get_config_dir", _mock)
|
||||
|
||||
|
||||
def test_first_run_config_has_no_research_keys(tmp_path, monkeypatch):
|
||||
"""Research/dev options must never be authored into a fresh user's config.
|
||||
|
||||
load_config_toml() writes the default_config template to disk on first run.
|
||||
It comments out keys but leaves table headers uncommented, so a research
|
||||
table in the template lands verbatim in every new user's config file.
|
||||
Verified by symptom: run against an empty config dir and read what was written.
|
||||
"""
|
||||
_patch_config_dir(monkeypatch, tmp_path)
|
||||
|
||||
config_module.load_config()
|
||||
|
||||
written = next(tmp_path.rglob("aw-watcher-window.toml")).read_text()
|
||||
assert "research" not in written
|
||||
|
||||
# The file must still be valid TOML, and parse to nothing but comments.
|
||||
assert dict(tomlkit.parse(written)["aw-watcher-window"]) == {}
|
||||
|
||||
|
||||
def test_research_options_are_read_when_user_sets_them(tmp_path, monkeypatch):
|
||||
"""Absent from the template, but honoured when a Research Edition user opts in."""
|
||||
_patch_config_dir(monkeypatch, tmp_path)
|
||||
config_path = tmp_path / "activitywatch" / "aw-watcher-window"
|
||||
config_path.mkdir(parents=True)
|
||||
(config_path / "aw-watcher-window.toml").write_text(
|
||||
"[aw-watcher-window]\n"
|
||||
"research_enabled = true\n"
|
||||
"\n"
|
||||
"[aw-watcher-window.research_category_map]\n"
|
||||
'youtube = "Entertainment"\n'
|
||||
"\n"
|
||||
"[aw-watcher-window.research_app_category_map]\n"
|
||||
'"Microsoft Outlook" = "Communication"\n'
|
||||
)
|
||||
monkeypatch.setattr(sys, "argv", ["aw-watcher-window"])
|
||||
|
||||
args = config_module.parse_args()
|
||||
|
||||
assert args.research_enabled is True
|
||||
assert args.research_category_map == {"youtube": "Entertainment"}
|
||||
assert args.research_app_category_map == {"Microsoft Outlook": "Communication"}
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
RESEARCH_BUILD,
|
||||
reason="research edition build patches this default on purpose (release.yml)",
|
||||
)
|
||||
def test_research_edition_sed_target_is_intact():
|
||||
"""The Research Edition release build patches this file with sed.
|
||||
|
||||
ActivityWatch/activitywatch .github/workflows/release.yml runs
|
||||
``sed -i 's/^research_enabled = false$/research_enabled = true/'`` against
|
||||
this module. That target lives in another repo, so pin it here — otherwise
|
||||
reformatting research_defaults silently breaks the Research Edition build.
|
||||
"""
|
||||
# Check the source FILE, not the evaluated string. sed anchors on ^...$ in
|
||||
# the file, so an indented ` research_enabled = false` inside the literal
|
||||
# still strips to the same value — the string-level check would stay green
|
||||
# while the release build broke.
|
||||
source = pathlib.Path(config_module.__file__).read_text()
|
||||
assert re.search(
|
||||
r"^research_enabled = false$", source, re.MULTILINE
|
||||
), "Research Edition sed target moved or got indented; see release.yml"
|
||||
|
||||
# Simulate what release.yml sed actually does: apply the replacement to the
|
||||
# source FILE and confirm the patched line appears at column 0. Checking the
|
||||
# runtime string (research_defaults) alone is insufficient — .strip() would
|
||||
# return the same value even if the source line is indented, keeping this
|
||||
# assertion green while the real sed-on-source would silently fail to match.
|
||||
patched_source = re.sub(
|
||||
r"^research_enabled = false$",
|
||||
"research_enabled = true",
|
||||
source,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
assert re.search(r"^research_enabled = true$", patched_source, re.MULTILINE), (
|
||||
"sed patch did not produce 'research_enabled = true' in source"
|
||||
)
|
||||
# Also verify the patched value is structurally valid TOML with the flag set.
|
||||
# The triple-quoted literal's runtime value is equivalent to the source content
|
||||
# (both are stripped of surrounding whitespace), so we can reuse it here.
|
||||
patched_defaults = re.sub(
|
||||
r"^research_enabled = false$",
|
||||
"research_enabled = true",
|
||||
config_module.research_defaults,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
assert tomlkit.parse(patched_defaults)["research_enabled"] is True
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
RESEARCH_BUILD,
|
||||
reason="research edition build patches this default on purpose (release.yml)",
|
||||
)
|
||||
def test_parse_args_defaults_research_off_without_config(tmp_path, monkeypatch):
|
||||
"""No research keys anywhere => disabled, with empty maps."""
|
||||
_patch_config_dir(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(sys, "argv", ["aw-watcher-window"])
|
||||
|
||||
args = config_module.parse_args()
|
||||
|
||||
assert args.research_enabled is False
|
||||
assert args.research_category_map == {}
|
||||
assert args.research_app_category_map == {}
|
||||
|
||||
|
||||
def test_parse_args_attaches_research_category_map(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
config_module,
|
||||
"load_config",
|
||||
lambda: {
|
||||
"exclude_title": False,
|
||||
"exclude_titles": [],
|
||||
"poll_time": 1.0,
|
||||
"strategy_macos": "swift",
|
||||
"research_enabled": True,
|
||||
"research_category_map": {"youtube": "Entertainment"},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(sys, "argv", ["aw-watcher-window"])
|
||||
|
||||
args = config_module.parse_args()
|
||||
|
||||
assert args.research_category_map == {"youtube": "Entertainment"}
|
||||
|
||||
|
||||
def test_no_research_overrides_config_enabled(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
config_module,
|
||||
"load_config",
|
||||
lambda: {
|
||||
"exclude_title": False,
|
||||
"exclude_titles": [],
|
||||
"poll_time": 1.0,
|
||||
"strategy_macos": "swift",
|
||||
"research_enabled": True,
|
||||
"research_category_map": {},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(sys, "argv", ["aw-watcher-window", "--no-research"])
|
||||
|
||||
args = config_module.parse_args()
|
||||
|
||||
assert args.research_enabled is False
|
||||
215
tests/test_main.py
Normal file
215
tests/test_main.py
Normal file
@@ -0,0 +1,215 @@
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import aw_watcher_window.main as main_module
|
||||
from aw_watcher_window.macos_cli import build_swift_command
|
||||
|
||||
|
||||
def test_research_mode_passes_map_to_macos_swift_strategy(monkeypatch):
|
||||
commands = []
|
||||
|
||||
class FakeProcess:
|
||||
pid = 123
|
||||
|
||||
def wait(self):
|
||||
return None
|
||||
|
||||
class FakeClient:
|
||||
client_name = "aw-watcher-window"
|
||||
client_hostname = "host.localdomain"
|
||||
server_address = "http://localhost:5600"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def create_bucket(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def wait_for_start(self):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(main_module.sys, "platform", "darwin")
|
||||
monkeypatch.setattr(main_module, "background_ensure_permissions", lambda: None)
|
||||
monkeypatch.setattr(main_module, "setup_logging", lambda **kwargs: None)
|
||||
monkeypatch.setattr(main_module, "ActivityWatchClient", FakeClient)
|
||||
monkeypatch.setattr(main_module.signal, "signal", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
main_module.subprocess,
|
||||
"Popen",
|
||||
lambda command: commands.append(command) or FakeProcess(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
main_module,
|
||||
"parse_args",
|
||||
lambda: SimpleNamespace(
|
||||
testing=True,
|
||||
verbose=False,
|
||||
host=None,
|
||||
port=None,
|
||||
strategy="swift",
|
||||
exclude_title=False,
|
||||
exclude_titles=[],
|
||||
research_enabled=True,
|
||||
research_category_map={"youtube": "Youtube"},
|
||||
research_app_category_map={},
|
||||
),
|
||||
)
|
||||
|
||||
main_module.main()
|
||||
|
||||
assert commands[0][-4:] == ["--research", "--research-category", "youtube", "Youtube"]
|
||||
|
||||
|
||||
def test_build_swift_command_omits_optional_filters():
|
||||
command = build_swift_command(
|
||||
"/tmp/aw-watcher-window-macos",
|
||||
"http://localhost:5600",
|
||||
"bucket",
|
||||
"host.localdomain",
|
||||
"aw-watcher-window",
|
||||
)
|
||||
|
||||
assert command == [
|
||||
"/tmp/aw-watcher-window-macos",
|
||||
"http://localhost:5600",
|
||||
"bucket",
|
||||
"host.localdomain",
|
||||
"aw-watcher-window",
|
||||
]
|
||||
|
||||
|
||||
def test_build_swift_command_passes_title_filters():
|
||||
command = build_swift_command(
|
||||
"/tmp/aw-watcher-window-macos",
|
||||
"http://localhost:5600",
|
||||
"bucket",
|
||||
"host.localdomain",
|
||||
"aw-watcher-window",
|
||||
exclude_title=True,
|
||||
exclude_titles=["Zoom", "Slack.*huddle"],
|
||||
)
|
||||
|
||||
assert command == [
|
||||
"/tmp/aw-watcher-window-macos",
|
||||
"http://localhost:5600",
|
||||
"bucket",
|
||||
"host.localdomain",
|
||||
"aw-watcher-window",
|
||||
"--exclude-title",
|
||||
"--exclude-titles",
|
||||
"Zoom",
|
||||
"--exclude-titles",
|
||||
"Slack.*huddle",
|
||||
]
|
||||
|
||||
|
||||
def test_build_swift_command_passes_empty_research_map():
|
||||
command = build_swift_command(
|
||||
"/tmp/aw-watcher-window-macos",
|
||||
"http://localhost:5600",
|
||||
"bucket",
|
||||
"host.localdomain",
|
||||
"aw-watcher-window",
|
||||
research_category_map={},
|
||||
)
|
||||
|
||||
assert command == [
|
||||
"/tmp/aw-watcher-window-macos",
|
||||
"http://localhost:5600",
|
||||
"bucket",
|
||||
"host.localdomain",
|
||||
"aw-watcher-window",
|
||||
"--research",
|
||||
]
|
||||
|
||||
|
||||
def test_build_swift_command_passes_research_categories():
|
||||
command = build_swift_command(
|
||||
"/tmp/aw-watcher-window-macos",
|
||||
"http://localhost:5600",
|
||||
"bucket",
|
||||
"host.localdomain",
|
||||
"aw-watcher-window",
|
||||
research_category_map={"youtube": "Youtube", "gmail": "Email"},
|
||||
)
|
||||
|
||||
assert command == [
|
||||
"/tmp/aw-watcher-window-macos",
|
||||
"http://localhost:5600",
|
||||
"bucket",
|
||||
"host.localdomain",
|
||||
"aw-watcher-window",
|
||||
"--research",
|
||||
"--research-category",
|
||||
"youtube",
|
||||
"Youtube",
|
||||
"--research-category",
|
||||
"gmail",
|
||||
"Email",
|
||||
]
|
||||
|
||||
|
||||
def test_research_transform_takes_precedence_over_exclude_titles():
|
||||
window = {
|
||||
"app": "Chrome",
|
||||
"title": "YouTube - Google Chrome",
|
||||
"url": "https://youtube.com/watch?v=abc",
|
||||
}
|
||||
|
||||
transformed = main_module.transform_window(
|
||||
window,
|
||||
exclude_titles=[re.compile("youtube", re.IGNORECASE)],
|
||||
research_category_map={"youtube": "Youtube"},
|
||||
)
|
||||
|
||||
assert transformed == {"app": "Chrome", "title": "Youtube"}
|
||||
|
||||
|
||||
def test_research_transform_takes_precedence_over_exclude_title():
|
||||
window = {
|
||||
"app": "Chrome",
|
||||
"title": "YouTube - Google Chrome",
|
||||
"url": "https://youtube.com/watch?v=abc",
|
||||
}
|
||||
|
||||
transformed = main_module.transform_window(
|
||||
window,
|
||||
exclude_title=True,
|
||||
research_category_map={"youtube": "Youtube"},
|
||||
)
|
||||
|
||||
assert transformed == {"app": "Chrome", "title": "Youtube"}
|
||||
|
||||
|
||||
def test_legacy_exclude_titles_still_apply_without_research_mode():
|
||||
window = {"app": "Chrome", "title": "YouTube - Google Chrome"}
|
||||
|
||||
transformed = main_module.transform_window(
|
||||
window,
|
||||
exclude_titles=[re.compile("youtube", re.IGNORECASE)],
|
||||
)
|
||||
|
||||
assert transformed == {"app": "Chrome", "title": "excluded"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"poll_time,expected_pulsetime",
|
||||
[
|
||||
(1.0, 2.0),
|
||||
(2.0, 3.0),
|
||||
(5.0, 7.5),
|
||||
(10.0, 15.0),
|
||||
],
|
||||
)
|
||||
def test_pulsetime_scales_with_poll_time(
|
||||
poll_time: float, expected_pulsetime: float
|
||||
):
|
||||
assert main_module.compute_pulsetime(poll_time) == expected_pulsetime
|
||||
423
tests/test_research_filter.py
Normal file
423
tests/test_research_filter.py
Normal file
@@ -0,0 +1,423 @@
|
||||
"""Tests for aw-watcher-window Research Edition filter."""
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from aw_watcher_window.research_filter import (
|
||||
BROWSER_APPS,
|
||||
classify_app,
|
||||
classify_title,
|
||||
is_browser,
|
||||
transform,
|
||||
)
|
||||
|
||||
|
||||
class TestIsBrowser(unittest.TestCase):
|
||||
def test_known_browsers(self):
|
||||
for app in BROWSER_APPS:
|
||||
self.assertTrue(is_browser(app), f"{app!r} should be a browser")
|
||||
|
||||
def test_case_insensitive(self):
|
||||
for app in (
|
||||
"Chrome",
|
||||
"GOOGLE CHROME",
|
||||
"Google-chrome",
|
||||
"Firefox",
|
||||
"Safari",
|
||||
"Edge",
|
||||
"Microsoft Edge",
|
||||
):
|
||||
self.assertTrue(is_browser(app), f"{app!r} should be a browser")
|
||||
|
||||
def test_linux_wm_class_browser_names(self):
|
||||
for app in (
|
||||
"Google-chrome",
|
||||
"Brave-browser",
|
||||
"Chromium-browser",
|
||||
"Microsoft-edge",
|
||||
):
|
||||
self.assertTrue(is_browser(app), f"{app!r} should be a browser")
|
||||
|
||||
def test_non_browsers(self):
|
||||
for app in ("Slack", "Terminal", "iTerm2", "Code", "zoom.us", ""):
|
||||
self.assertFalse(is_browser(app), f"{app!r} should not be a browser")
|
||||
|
||||
def test_python_and_swift_browser_aliases_match(self):
|
||||
swift = (Path(__file__).parents[1] / "aw_watcher_window" / "macos.swift").read_text()
|
||||
match = re.search(
|
||||
r"let\s+researchBrowserApps\s*=\s*Set\s*\(\s*\[(?P<entries>.*?)\]\s*\)",
|
||||
swift,
|
||||
re.DOTALL,
|
||||
)
|
||||
self.assertIsNotNone(match)
|
||||
swift_browser_apps = set(re.findall(r'"([^"]+)"', match.group("entries")))
|
||||
self.assertEqual(swift_browser_apps, BROWSER_APPS)
|
||||
|
||||
|
||||
class TestClassifyTitle(unittest.TestCase):
|
||||
CATEGORY_MAP = {
|
||||
"youtube": "Youtube",
|
||||
"facebook": "Facebook",
|
||||
"twitter": "Twitter",
|
||||
"reddit": "Forums & Blogs",
|
||||
"gmail": "Email",
|
||||
"outlook": "Email",
|
||||
}
|
||||
|
||||
def test_exact_substring_match(self):
|
||||
self.assertEqual(
|
||||
classify_title("YouTube - Google Chrome", self.CATEGORY_MAP),
|
||||
"Youtube",
|
||||
)
|
||||
|
||||
def test_case_insensitive_match(self):
|
||||
self.assertEqual(
|
||||
classify_title("YOUTUBE - Mozilla Firefox", self.CATEGORY_MAP),
|
||||
"Youtube",
|
||||
)
|
||||
|
||||
def test_title_with_notification_prefix(self):
|
||||
self.assertEqual(
|
||||
classify_title("Facebook (1) Notification - Brave Browser", self.CATEGORY_MAP),
|
||||
"Facebook",
|
||||
)
|
||||
|
||||
def test_no_match_returns_excluded(self):
|
||||
self.assertEqual(
|
||||
classify_title("systemd (SYSTEM) - man", self.CATEGORY_MAP),
|
||||
"excluded",
|
||||
)
|
||||
|
||||
def test_longest_pattern_wins(self):
|
||||
# Both "facebook" (8) and "twitter" (7) present — the longer pattern wins
|
||||
result = classify_title("Facebook Twitter integration - Chrome", self.CATEGORY_MAP)
|
||||
self.assertEqual(result, "Facebook")
|
||||
|
||||
def test_equal_length_patterns_resolve_to_map_order(self):
|
||||
# "gmail" and "inbox" are both 5 chars — the earlier map entry wins
|
||||
category_map = {"gmail": "Email", "inbox": "Other"}
|
||||
self.assertEqual(classify_title("gmail inbox", category_map), "Email")
|
||||
|
||||
def test_empty_title(self):
|
||||
self.assertEqual(classify_title("", self.CATEGORY_MAP), "excluded")
|
||||
|
||||
def test_url_matches_when_title_does_not(self):
|
||||
# URL is used for classification when title gives no match
|
||||
result = classify_title("New Tab", self.CATEGORY_MAP, url="https://youtube.com/watch?v=abc")
|
||||
self.assertEqual(result, "Youtube")
|
||||
|
||||
def test_url_takes_priority_over_title(self):
|
||||
# URL match wins even when title would match a different category
|
||||
result = classify_title("Gmail - Google", self.CATEGORY_MAP, url="https://youtube.com/")
|
||||
self.assertEqual(result, "Youtube")
|
||||
|
||||
def test_url_fallback_to_title(self):
|
||||
# If URL doesn't match any pattern, title is used
|
||||
result = classify_title("Facebook - Social", self.CATEGORY_MAP, url="https://example.com/unrelated")
|
||||
self.assertEqual(result, "Facebook")
|
||||
|
||||
def test_url_case_insensitive(self):
|
||||
result = classify_title("", self.CATEGORY_MAP, url="HTTPS://YOUTUBE.COM/WATCH")
|
||||
self.assertEqual(result, "Youtube")
|
||||
|
||||
|
||||
class TestTransform(unittest.TestCase):
|
||||
CATEGORY_MAP = {
|
||||
"youtube": "Youtube",
|
||||
"facebook": "Facebook",
|
||||
}
|
||||
|
||||
def test_no_category_map_returns_unchanged(self):
|
||||
window = {"app": "Chrome", "title": "YouTube - Chrome"}
|
||||
self.assertEqual(transform(window, None), window)
|
||||
|
||||
def test_empty_category_map_excludes_browser_title(self):
|
||||
window = {"app": "Chrome", "title": "YouTube - Chrome"}
|
||||
self.assertEqual(transform(window, {}), {"app": "Chrome", "title": "excluded"})
|
||||
|
||||
def test_empty_category_map_still_drops_non_browser_title(self):
|
||||
window = {"app": "Terminal", "title": "bash"}
|
||||
self.assertEqual(transform(window, {}), {"app": "Terminal"})
|
||||
|
||||
def test_browser_title_classified(self):
|
||||
window = {"app": "Chrome", "title": "YouTube - Chrome"}
|
||||
result = transform(window, self.CATEGORY_MAP)
|
||||
self.assertEqual(result["app"], "Chrome")
|
||||
self.assertEqual(result["title"], "Youtube")
|
||||
|
||||
def test_browser_title_excluded_when_no_match(self):
|
||||
window = {"app": "Firefox", "title": "Vim documentation"}
|
||||
result = transform(window, self.CATEGORY_MAP)
|
||||
self.assertEqual(result["app"], "Firefox")
|
||||
self.assertEqual(result["title"], "excluded")
|
||||
|
||||
def test_non_browser_drops_title(self):
|
||||
window = {"app": "iTerm2", "title": "~/projects/foo"}
|
||||
result = transform(window, self.CATEGORY_MAP)
|
||||
self.assertNotIn("title", result)
|
||||
self.assertEqual(result["app"], "iTerm2")
|
||||
|
||||
def test_non_browser_strips_url_but_preserves_incognito(self):
|
||||
# Privacy fix: non-browser URLs must be stripped to prevent leaking sensitive
|
||||
# file paths or email URLs (file://, mailbox://); incognito flag is non-sensitive
|
||||
window = {
|
||||
"app": "Finder",
|
||||
"title": "Documents",
|
||||
"url": "file:///Users/jdoe/sensitive-folder/",
|
||||
"incognito": False,
|
||||
}
|
||||
result = transform(window, self.CATEGORY_MAP)
|
||||
self.assertNotIn("title", result)
|
||||
self.assertEqual(result["app"], "Finder")
|
||||
self.assertNotIn("url", result, "Non-browser URLs must be stripped for privacy")
|
||||
self.assertEqual(result["incognito"], False)
|
||||
|
||||
def test_browser_strips_url_but_preserves_incognito(self):
|
||||
# Privacy fix: browser URLs must be stripped to prevent leaking search queries
|
||||
# and auth tokens; incognito flag is non-sensitive metadata and can be preserved
|
||||
window = {
|
||||
"app": "Chrome",
|
||||
"title": "YouTube - Google Chrome",
|
||||
"url": "https://youtube.com/search?q=secret",
|
||||
"incognito": False,
|
||||
}
|
||||
result = transform(window, self.CATEGORY_MAP)
|
||||
self.assertEqual(result["title"], "Youtube")
|
||||
self.assertNotIn("url", result, "Browser URLs must be stripped for privacy")
|
||||
self.assertEqual(result["incognito"], False)
|
||||
|
||||
def test_non_browser_minimal(self):
|
||||
window = {"app": "Terminal", "title": "bash"}
|
||||
self.assertEqual(transform(window, self.CATEGORY_MAP), {"app": "Terminal"})
|
||||
|
||||
def test_browser_with_leading_trailing_spaces_in_app(self):
|
||||
window = {"app": " Brave Browser ", "title": "Facebook"}
|
||||
result = transform(window, self.CATEGORY_MAP)
|
||||
self.assertEqual(result["title"], "Facebook")
|
||||
|
||||
def test_linux_browser_wm_class_title_classified(self):
|
||||
for app in ("Google-chrome", "Brave-browser"):
|
||||
with self.subTest(app=app):
|
||||
window = {"app": app, "title": "YouTube"}
|
||||
result = transform(window, self.CATEGORY_MAP)
|
||||
self.assertEqual(result["app"], app)
|
||||
self.assertEqual(result["title"], "Youtube")
|
||||
|
||||
def test_original_classifier_browser_aliases_are_classified(self):
|
||||
for app in ("Arc", "Arc Browser", "Vivaldi", "Vivaldi.exe", "Chromium.exe"):
|
||||
with self.subTest(app=app):
|
||||
window = {
|
||||
"app": app,
|
||||
"title": "New Tab",
|
||||
"url": "https://youtube.com/watch?v=private",
|
||||
}
|
||||
result = transform(window, self.CATEGORY_MAP)
|
||||
self.assertEqual(result, {"app": app, "title": "Youtube"})
|
||||
|
||||
def test_input_not_mutated(self):
|
||||
window = {"app": "Chrome", "title": "YouTube - Chrome"}
|
||||
original = dict(window)
|
||||
transform(window, self.CATEGORY_MAP)
|
||||
self.assertEqual(window, original)
|
||||
|
||||
def test_browser_url_used_for_classification(self):
|
||||
# URL is used for classification when title doesn't match (Swift advantage)
|
||||
window = {
|
||||
"app": "Safari",
|
||||
"title": "New Tab", # generic title — won't match
|
||||
"url": "https://youtube.com/watch?v=abc123",
|
||||
}
|
||||
result = transform(window, self.CATEGORY_MAP)
|
||||
self.assertEqual(result["title"], "Youtube")
|
||||
self.assertNotIn("url", result)
|
||||
|
||||
def test_browser_url_takes_priority_over_title(self):
|
||||
# URL match takes precedence over title match
|
||||
window = {
|
||||
"app": "Chrome",
|
||||
"title": "Facebook - Home",
|
||||
"url": "https://youtube.com/",
|
||||
}
|
||||
result = transform(window, self.CATEGORY_MAP)
|
||||
self.assertEqual(result["title"], "Youtube")
|
||||
self.assertNotIn("url", result)
|
||||
|
||||
|
||||
class TestClassifyApp(unittest.TestCase):
|
||||
APP_CATEGORY_MAP = {
|
||||
"microsoft outlook": "Email",
|
||||
"outlook.exe": "Email",
|
||||
"microsoft teams": "Work & Productivity",
|
||||
"teams": "Work & Productivity",
|
||||
"spotify": "Music & Audio",
|
||||
"finder": "Excluded",
|
||||
"steam": "Games",
|
||||
}
|
||||
|
||||
def test_exact_match(self):
|
||||
self.assertEqual(classify_app("Microsoft Outlook", self.APP_CATEGORY_MAP), "Email")
|
||||
|
||||
def test_case_insensitive(self):
|
||||
self.assertEqual(classify_app("SPOTIFY", self.APP_CATEGORY_MAP), "Music & Audio")
|
||||
self.assertEqual(classify_app("spotify", self.APP_CATEGORY_MAP), "Music & Audio")
|
||||
self.assertEqual(classify_app("Spotify", self.APP_CATEGORY_MAP), "Music & Audio")
|
||||
|
||||
def test_exe_suffix(self):
|
||||
self.assertEqual(classify_app("outlook.exe", self.APP_CATEGORY_MAP), "Email")
|
||||
|
||||
def test_unmapped_returns_excluded(self):
|
||||
self.assertEqual(classify_app("iTerm2", self.APP_CATEGORY_MAP), "Excluded")
|
||||
self.assertEqual(classify_app("Terminal", self.APP_CATEGORY_MAP), "Excluded")
|
||||
self.assertEqual(classify_app("", self.APP_CATEGORY_MAP), "Excluded")
|
||||
|
||||
def test_leading_trailing_spaces(self):
|
||||
self.assertEqual(classify_app(" Spotify ", self.APP_CATEGORY_MAP), "Music & Audio")
|
||||
|
||||
def test_explicit_excluded_entry(self):
|
||||
# "Finder" is explicitly mapped to "Excluded" in Matthias's map
|
||||
self.assertEqual(classify_app("Finder", self.APP_CATEGORY_MAP), "Excluded")
|
||||
|
||||
def test_empty_map_returns_excluded(self):
|
||||
self.assertEqual(classify_app("Spotify", {}), "Excluded")
|
||||
|
||||
def test_mixed_case_map_keys(self):
|
||||
# Configured keys may be mixed-case (as typed in config TOML); lookup
|
||||
# must still match case-insensitively on both sides.
|
||||
mixed_case_map = {
|
||||
"Microsoft Outlook": "Email",
|
||||
"Spotify": "Music & Audio",
|
||||
}
|
||||
self.assertEqual(classify_app("microsoft outlook", mixed_case_map), "Email")
|
||||
self.assertEqual(classify_app("SPOTIFY", mixed_case_map), "Music & Audio")
|
||||
|
||||
|
||||
class TestTransformWithAppMap(unittest.TestCase):
|
||||
CATEGORY_MAP = {
|
||||
"youtube": "Video Streaming",
|
||||
"facebook": "Social Networking",
|
||||
}
|
||||
APP_CATEGORY_MAP = {
|
||||
"microsoft outlook": "Email",
|
||||
"microsoft teams": "Work & Productivity",
|
||||
"spotify": "Music & Audio",
|
||||
"finder": "Excluded",
|
||||
}
|
||||
|
||||
def test_non_browser_mapped_to_category(self):
|
||||
# Raw app identity is REPLACED by its category; title is dropped.
|
||||
window = {"app": "Microsoft Outlook", "title": "Inbox — johndoe@example.com"}
|
||||
result = transform(window, self.CATEGORY_MAP, self.APP_CATEGORY_MAP)
|
||||
self.assertEqual(result["app"], "Email")
|
||||
self.assertNotIn("title", result)
|
||||
|
||||
def test_non_browser_unmapped_returns_excluded(self):
|
||||
# Unmapped non-browser app is anonymized to "Excluded", title dropped.
|
||||
window = {"app": "iTerm2", "title": "~/projects/secret"}
|
||||
result = transform(window, self.CATEGORY_MAP, self.APP_CATEGORY_MAP)
|
||||
self.assertEqual(result["app"], "Excluded")
|
||||
self.assertNotIn("title", result)
|
||||
|
||||
def test_non_browser_title_and_url_stripped(self):
|
||||
# App replaced by category; sensitive title AND URL must not leak
|
||||
window = {
|
||||
"app": "Microsoft Outlook",
|
||||
"title": "Private inbox",
|
||||
"url": "mailbox://private@corp.example.com/INBOX",
|
||||
}
|
||||
result = transform(window, self.CATEGORY_MAP, self.APP_CATEGORY_MAP)
|
||||
self.assertEqual(result["app"], "Email")
|
||||
self.assertNotIn("title", result)
|
||||
self.assertNotIn("url", result)
|
||||
|
||||
def test_non_browser_incognito_preserved(self):
|
||||
window = {"app": "Spotify", "title": "Now playing: Secret Playlist", "incognito": True}
|
||||
result = transform(window, self.CATEGORY_MAP, self.APP_CATEGORY_MAP)
|
||||
self.assertEqual(result["app"], "Music & Audio")
|
||||
self.assertEqual(result["incognito"], True)
|
||||
|
||||
def test_browser_unaffected_by_app_map(self):
|
||||
# Browser classification should not change even when an app map is present
|
||||
window = {"app": "Chrome", "title": "YouTube - Chrome"}
|
||||
result = transform(window, self.CATEGORY_MAP, self.APP_CATEGORY_MAP)
|
||||
self.assertEqual(result["title"], "Video Streaming")
|
||||
|
||||
def test_no_app_map_legacy_behaviour(self):
|
||||
# Without app map, non-browser keeps raw app name and drops title (backward compat)
|
||||
window = {"app": "Microsoft Outlook", "title": "Inbox"}
|
||||
result = transform(window, self.CATEGORY_MAP, None)
|
||||
self.assertEqual(result["app"], "Microsoft Outlook")
|
||||
self.assertNotIn("title", result)
|
||||
|
||||
def test_empty_app_map_falls_back_to_legacy(self):
|
||||
# An empty app map is treated as "not provided": research_enabled with no
|
||||
# configured app map must NOT classify every non-browser app as "Excluded".
|
||||
window = {"app": "Microsoft Outlook", "title": "Inbox"}
|
||||
result = transform(window, self.CATEGORY_MAP, {})
|
||||
self.assertEqual(result["app"], "Microsoft Outlook")
|
||||
self.assertNotIn("title", result)
|
||||
|
||||
def test_explicit_excluded_mapped_app(self):
|
||||
# "Finder" → "Excluded" (explicit map entry, not a lookup miss)
|
||||
window = {"app": "Finder", "title": "/Users/jdoe/Documents"}
|
||||
result = transform(window, self.CATEGORY_MAP, self.APP_CATEGORY_MAP)
|
||||
self.assertEqual(result["app"], "Excluded")
|
||||
self.assertNotIn("title", result)
|
||||
|
||||
def test_input_not_mutated_with_app_map(self):
|
||||
window = {"app": "Spotify", "title": "Now playing"}
|
||||
original = dict(window)
|
||||
transform(window, self.CATEGORY_MAP, self.APP_CATEGORY_MAP)
|
||||
self.assertEqual(window, original)
|
||||
|
||||
class TestSpecificHostBeatsGenericHost(unittest.TestCase):
|
||||
"""Regression: a generic host must not shadow a more specific one.
|
||||
|
||||
Study maps list ``google.com`` well before ``docs.google.com``. Under
|
||||
first-match-wins every Google Workspace URL was filed as *Search &
|
||||
Navigation* instead of *Work & Productivity* — reported from the
|
||||
Research Edition b4 field test.
|
||||
"""
|
||||
|
||||
CATEGORY_MAP = {
|
||||
"google.com": "Search & Navigation",
|
||||
"docs.google.com": "Work & Productivity",
|
||||
"drive.google.com": "Work & Productivity",
|
||||
"calendar.google.com": "Work & Productivity",
|
||||
"meet.google.com": "Work & Productivity",
|
||||
}
|
||||
|
||||
def test_workspace_urls_are_work_not_search(self):
|
||||
for url in (
|
||||
"https://docs.google.com/document/d/abc/edit",
|
||||
"https://drive.google.com/drive/my-drive",
|
||||
"https://calendar.google.com/calendar/u/0/r",
|
||||
"https://meet.google.com/xyz-abcd-efg",
|
||||
):
|
||||
with self.subTest(url=url):
|
||||
self.assertEqual(
|
||||
classify_title("", self.CATEGORY_MAP, url=url),
|
||||
"Work & Productivity",
|
||||
)
|
||||
|
||||
def test_plain_google_search_still_matches_generic(self):
|
||||
self.assertEqual(
|
||||
classify_title("", self.CATEGORY_MAP, url="https://www.google.com/search?q=aw"),
|
||||
"Search & Navigation",
|
||||
)
|
||||
|
||||
def test_specific_host_also_wins_on_title_fallback(self):
|
||||
self.assertEqual(
|
||||
classify_title("docs.google.com - Untitled", self.CATEGORY_MAP),
|
||||
"Work & Productivity",
|
||||
)
|
||||
|
||||
def test_empty_pattern_is_ignored(self):
|
||||
# An empty map key matches everything under `in`; it must not swallow input.
|
||||
category_map = {"": "Bogus", "youtube": "Youtube"}
|
||||
self.assertEqual(classify_title("youtube - Chrome", category_map), "Youtube")
|
||||
self.assertEqual(classify_title("unrelated", category_map), "excluded")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user