diff --git a/aw_watcher_window/config.py b/aw_watcher_window/config.py index 81eabc2..0678921 100644 --- a/aw_watcher_window/config.py +++ b/aw_watcher_window/config.py @@ -8,6 +8,9 @@ exclude_titles = [] poll_time = 1.0 strategy_macos = "swift" +research_enabled = false + +[aw-watcher-window.research_category_map] """.strip() @@ -22,6 +25,7 @@ def parse_args(): default_exclude_title = config["exclude_title"] default_exclude_titles = config["exclude_titles"] default_strategy_macos = config["strategy_macos"] + default_research_enabled = config.get("research_enabled", False) parser = argparse.ArgumentParser( description="A cross platform window watcher for Activitywatch.\nSupported on: Linux (X11), macOS and Windows." @@ -53,5 +57,20 @@ def parse_args(): choices=["jxa", "applescript", "swift"], help="(macOS only) strategy to use for retrieving the active window", ) + research_group = parser.add_mutually_exclusive_group() + research_group.add_argument( + "--research", + dest="research_enabled", + action="store_true", + default=default_research_enabled, + help="Enable Research Edition mode: browser titles are classified into study categories, non-browser titles are dropped. Category map must be set in the config file.", + ) + research_group.add_argument( + "--no-research", + dest="research_enabled", + action="store_false", + help="Disable Research Edition mode, even when enabled in the config file.", + ) parsed_args = parser.parse_args() + parsed_args.research_category_map = dict(config.get("research_category_map", {})) return parsed_args diff --git a/aw_watcher_window/macos.swift b/aw_watcher_window/macos.swift index 5c0f22b..e687c64 100644 --- a/aw_watcher_window/macos.swift +++ b/aw_watcher_window/macos.swift @@ -63,7 +63,7 @@ extension SBApplication: SafariApplication {} struct NetworkMessage: Codable, Equatable { var app: String - var title: String + var title: String? var url: String? } @@ -125,6 +125,37 @@ var clientName = "aw-watcher-window" var bucketName = "\(clientName)_\(clientHostname)" var excludeTitle = false var excludeTitlePatterns: [NSRegularExpression] = [] +var researchEnabled = false +var researchCategoryMap: [(pattern: String, category: String)] = [] + +let researchBrowserApps = Set([ + "chrome", + "google chrome", + "google chrome canary", + "google-chrome", + "google-chrome-beta", + "google-chrome-unstable", + "chromium", + "chromium-browser", + "brave browser", + "brave", + "brave-browser", + "firefox", + "firefox developer edition", + "firefox-esr", + "safari", + "edge", + "microsoft edge", + "microsoft-edge", + "microsoft-edge-beta", + "microsoft-edge-dev", + "opera", + "chrome.exe", + "brave.exe", + "firefox.exe", + "msedge.exe", + "opera.exe", +]) let main = MainThing() var oldHeartbeat: Heartbeat? @@ -173,6 +204,24 @@ func parseOptionalArguments(_ arguments: ArraySlice) { continue } + if argument == "--research" { + researchEnabled = true + index = arguments.index(after: index) + continue + } + + if argument == "--research-category" { + let patternIndex = arguments.index(after: index) + let categoryIndex = patternIndex < arguments.endIndex ? arguments.index(after: patternIndex) : arguments.endIndex + guard patternIndex < arguments.endIndex, categoryIndex < arguments.endIndex else { + error("Missing pattern/category values for --research-category") + exit(1) + } + researchCategoryMap.append((pattern: arguments[patternIndex], category: arguments[categoryIndex])) + index = arguments.index(after: categoryIndex) + continue + } + error("Unknown argument: \(argument)") exit(1) } @@ -185,6 +234,39 @@ func titleShouldBeExcluded(_ title: String) -> Bool { } } +func isResearchBrowser(_ app: String) -> Bool { + return researchBrowserApps.contains(app.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) +} + +func classifyResearch(_ title: String, url: String?) -> String { + // Try URL first — more reliable than page title, which can change mid-load + if let url = url, !url.isEmpty { + for item in researchCategoryMap { + if url.range(of: item.pattern, options: [.caseInsensitive]) != nil { + return item.category + } + } + } + for item in researchCategoryMap { + if title.range(of: item.pattern, options: [.caseInsensitive]) != nil { + return item.category + } + } + return "excluded" +} + +func applyResearchFilter(_ data: NetworkMessage) -> NetworkMessage { + if !researchEnabled { + return data + } + + if isResearchBrowser(data.app) { + return NetworkMessage(app: data.app, title: classifyResearch(data.title ?? "", url: data.url), url: nil) + } + + return NetworkMessage(app: data.app, title: nil, url: nil) +} + func start() { // Arguments should be: // - url + port @@ -195,7 +277,7 @@ func start() { // Check that we get the 4 required arguments plus any optional flags if arguments.count < 5 { - print("Usage: aw-watcher-window [--exclude-title] [--exclude-titles ...]") + print("Usage: aw-watcher-window [--exclude-title] [--exclude-titles ...] [--research] [--research-category ...]") exit(1) } @@ -310,7 +392,7 @@ func sendHeartbeatSingle(_ heartbeat: Heartbeat, pulsetime: Double) async throws throw HeartbeatError.error(msg: "Failed to send heartbeat: \(response)") } - debug("[heartbeat] bucket: \(bucketName), timestamp: \(heartbeat.timestamp), pulsetime: \(round(pulsetime * 10) / 10), app: \(heartbeat.data.app), title: \(heartbeat.data.title), url: \(heartbeat.data.url ?? "")") + debug("[heartbeat] bucket: \(bucketName), timestamp: \(heartbeat.timestamp), pulsetime: \(round(pulsetime * 10) / 10), app: \(heartbeat.data.app), title: \(heartbeat.data.title ?? ""), url: \(heartbeat.data.url ?? "")") } class MainThing { @@ -434,7 +516,9 @@ class MainThing { } } - if excludeTitle || titleShouldBeExcluded(data.title) { + if researchEnabled { + data = applyResearchFilter(data) + } else if excludeTitle || titleShouldBeExcluded(data.title ?? "") { data.title = "excluded" } diff --git a/aw_watcher_window/macos_cli.py b/aw_watcher_window/macos_cli.py index 1ba4a3e..aac50aa 100644 --- a/aw_watcher_window/macos_cli.py +++ b/aw_watcher_window/macos_cli.py @@ -6,10 +6,15 @@ def build_swift_command( client_name, exclude_title=False, exclude_titles=None, + research_category_map=None, ): command = [binpath, server_address, bucket_id, client_hostname, client_name] if exclude_title: command.append("--exclude-title") for title in exclude_titles or []: command.extend(["--exclude-titles", title]) + if research_category_map is not None: + command.append("--research") + for pattern, category in research_category_map.items(): + command.extend(["--research-category", pattern, category]) return command diff --git a/aw_watcher_window/main.py b/aw_watcher_window/main.py index e271c70..6edc9c7 100644 --- a/aw_watcher_window/main.py +++ b/aw_watcher_window/main.py @@ -14,6 +14,7 @@ from .config import parse_args from .exceptions import FatalError from .lib import get_current_window +from .research_filter import transform as research_transform from .macos_cli import build_swift_command from .macos_permissions import background_ensure_permissions @@ -56,7 +57,6 @@ def main(): log_stderr=True, log_file=True, ) - if sys.platform == "darwin": background_ensure_permissions() @@ -73,6 +73,11 @@ def main(): client.wait_for_start() with client: + research_category_map = ( + args.research_category_map + if args.research_enabled + else None + ) if sys.platform == "darwin" and args.strategy == "swift": logger.info("Using swift strategy, calling out to swift binary") binpath = os.path.join( @@ -89,6 +94,7 @@ def main(): client.client_name, exclude_title=args.exclude_title, exclude_titles=args.exclude_titles, + research_category_map=research_category_map, ) ) # terminate swift process when this process dies @@ -109,11 +115,18 @@ def main(): for title in args.exclude_titles if title is not None ], + research_category_map=research_category_map, ) def heartbeat_loop( - client, bucket_id, poll_time, strategy, exclude_title=False, exclude_titles=[] + client, + bucket_id, + poll_time, + strategy, + exclude_title=False, + exclude_titles=[], + research_category_map=None, ): while True: if os.getppid() == 1: @@ -147,12 +160,12 @@ def heartbeat_loop( if current_window is None: logger.debug("Unable to fetch window, trying again on next poll") else: - for pattern in exclude_titles: - if pattern.search(current_window["title"]): - current_window["title"] = "excluded" - - if exclude_title: - current_window["title"] = "excluded" + current_window = transform_window( + current_window, + exclude_title=exclude_title, + exclude_titles=exclude_titles, + research_category_map=research_category_map, + ) now = datetime.now(timezone.utc) current_window_event = Event(timestamp=now, data=current_window) @@ -165,3 +178,22 @@ def heartbeat_loop( ) sleep(poll_time) + + +def transform_window( + current_window, + exclude_title=False, + exclude_titles=None, + research_category_map=None, +): + if research_category_map is not None: + return research_transform(current_window, research_category_map) + + for pattern in exclude_titles or []: + if pattern.search(current_window["title"]): + current_window["title"] = "excluded" + + if exclude_title: + current_window["title"] = "excluded" + + return current_window diff --git a/aw_watcher_window/research_filter.py b/aw_watcher_window/research_filter.py new file mode 100644 index 0000000..8dabf52 --- /dev/null +++ b/aw_watcher_window/research_filter.py @@ -0,0 +1,115 @@ +""" +Research Edition: privacy-sensitive window-title → category transform. + +Replicates the approach used by AW research forks: +- Browser apps: window titles get classified into study categories. + Unmatched titles → 'excluded'. +- Non-browser apps: title is dropped entirely (only app name recorded). + +Enable via config (``research_enabled = true``) and optionally supply a category +map in ``[aw-watcher-window.research_category_map]``. If the map is empty, +browser titles are classified as ``excluded`` and non-browser titles are still +dropped. + +macOS note: on macOS the default ``swift`` strategy captures browser URLs via +accessibility APIs and includes them in the window dict. This filter uses those +URLs for more reliable category classification (``classify_title`` prefers URL +over title) and strips them from the output before the event is recorded. +""" + +from typing import Optional + +# Known browser applications (lowercase, for case-insensitive matching) +BROWSER_APPS = frozenset( + { + "chrome", + "google chrome", + "google chrome canary", + "google-chrome", + "google-chrome-beta", + "google-chrome-unstable", + "chromium", + "chromium-browser", + "brave browser", + "brave", + "brave-browser", + "firefox", + "firefox developer edition", + "firefox-esr", + "safari", + "edge", + "microsoft edge", + "microsoft-edge", + "microsoft-edge-beta", + "microsoft-edge-dev", + "opera", + "chrome.exe", + "brave.exe", + "firefox.exe", + "msedge.exe", + "opera.exe", + } +) + + +def is_browser(app: str) -> bool: + """Return True if *app* is a known browser (case-insensitive).""" + return app.strip().lower() in BROWSER_APPS + + +def classify_title(title: str, category_map: dict, url: str = "") -> str: + """ + Classify a browser window into a study category via substring matching. + + Tries *url* first when provided (more reliable than page titles, which can + change mid-load), then falls back to *title*. + *category_map*: ``{substring: category_name}`` — first matching substring wins. + Returns the category name, or ``"excluded"`` if nothing matched. + """ + if url: + url_lower = url.lower() + for pattern, category in category_map.items(): + if pattern.lower() in url_lower: + return category + title_lower = title.lower() + for pattern, category in category_map.items(): + if pattern.lower() in title_lower: + return category + return "excluded" + + +def transform(window: dict, category_map: Optional[dict]) -> dict: + """ + Apply Research Edition transforms to a window-data dict. + + *window*: ``{"app": str, "title": str}`` (may also carry *url*, + *incognito* on macOS JXA — URLs are stripped and incognito is preserved). + *category_map*: study-specific mapping dict, or ``None`` to no-op. + + Returns the transformed window dict (never mutates the input). + """ + if category_map is None: + return window + + app = window.get("app", "") + + if is_browser(app): + # Browser: replace title with a study category + title = window.get("title", "") + url = window.get("url", "") + category = classify_title(title, category_map, url=url) + # Don't spread window dict — URLs must not be exposed for privacy + result = {"app": app, "title": category} + # Preserve incognito flag if present (metadata, not a privacy concern) + if "incognito" in window: + result["incognito"] = window["incognito"] + return result + else: + # Non-browser: drop the title entirely (only app name recorded) + result = {"app": app} + # Preserve incognito flag if present (metadata, not a privacy concern) + # Note: URL is NOT preserved for non-browser apps — on macOS JXA, + # apps like Mail or file managers can expose sensitive URLs (mailbox://, file://) + if "incognito" in window: + result["incognito"] = window["incognito"] + return result diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..7b30eba --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,43 @@ +import sys + +from aw_watcher_window import config as config_module + + +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 diff --git a/tests/test_main.py b/tests/test_main.py index 96e04b1..55c07ee 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,6 +1,70 @@ +import re +from types import SimpleNamespace + +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"}, + ), + ) + + 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", @@ -42,3 +106,92 @@ def test_build_swift_command_passes_title_filters(): "--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"} diff --git a/tests/test_research_filter.py b/tests/test_research_filter.py new file mode 100644 index 0000000..78f1984 --- /dev/null +++ b/tests/test_research_filter.py @@ -0,0 +1,218 @@ +"""Tests for aw-watcher-window Research Edition filter.""" + +import unittest + +from aw_watcher_window.research_filter import ( + BROWSER_APPS, + 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") + + +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_first_match_wins(self): + # Both "facebook" and "twitter" present — first pattern in iteration wins + result = classify_title("Facebook Twitter integration - Chrome", self.CATEGORY_MAP) + self.assertEqual(result, "Facebook") + + 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_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) + + +if __name__ == "__main__": + unittest.main()