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

This commit is contained in:
upstream-mirror
2026-09-16 00:21:37 +05:00
commit 8e9cceaeae
29 changed files with 4586 additions and 0 deletions

176
tests/test_config.py Normal file
View 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
View 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

View 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()