diff --git a/Makefile b/Makefile index 9e3e70d..54362c3 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,7 @@ aw_watcher_window/aw-watcher-window-macos: aw_watcher_window/macos.swift test: poetry run aw-watcher-window --help # Ensures that it at least starts + poetry run python -m pytest tests/ make typecheck typecheck: diff --git a/aw_watcher_window/macos.swift b/aw_watcher_window/macos.swift index e8e0136..961a846 100644 --- a/aw_watcher_window/macos.swift +++ b/aw_watcher_window/macos.swift @@ -251,19 +251,41 @@ func isResearchBrowser(_ app: String) -> Bool { return researchBrowserApps.contains(app.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) } +// Return the category of the *longest* pattern contained in `haystack`. +// Longest-match beats map order because study maps routinely contain both a +// generic and a specific form of the same host ("google.com" vs +// "docs.google.com"); first-match-wins would file every Google Docs/Drive/ +// Calendar/Meet URL under the generic entry. Ties resolve to map order. +// Twin of `_longest_match` in research_filter.py — keep the two in sync. +func longestResearchMatch(_ haystack: String) -> String? { + var bestCategory: String? + var bestLength = -1 + for item in researchCategoryMap { + if item.pattern.isEmpty { continue } + // Compare by Unicode scalar count, not Character count: Swift's `count` + // measures grapheme clusters while Python's `len()` measures code points, + // which would let the two twins pick different winners for non-ASCII + // patterns of equal visual length. + let patternLength = item.pattern.unicodeScalars.count + if haystack.range(of: item.pattern, options: [.caseInsensitive]) != nil, + patternLength > bestLength + { + bestCategory = item.category + bestLength = patternLength + } + } + return bestCategory +} + 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 - } + if let category = longestResearchMatch(url) { + return category } } - for item in researchCategoryMap { - if title.range(of: item.pattern, options: [.caseInsensitive]) != nil { - return item.category - } + if let category = longestResearchMatch(title) { + return category } return "excluded" } diff --git a/aw_watcher_window/research_filter.py b/aw_watcher_window/research_filter.py index db5e16e..e3c7189 100644 --- a/aw_watcher_window/research_filter.py +++ b/aw_watcher_window/research_filter.py @@ -60,24 +60,46 @@ def is_browser(app: str) -> bool: return app.strip().lower() in BROWSER_APPS +def _longest_match(haystack: str, category_map: dict) -> Optional[str]: + """ + Return the category of the *longest* pattern contained in *haystack*. + + Longest-match beats insertion order because study maps routinely contain + both a generic and a specific form of the same host: ``google.com`` → + *Search & Navigation* and ``docs.google.com`` → *Work & Productivity*. + First-match-wins would file every Google Docs/Drive/Calendar/Meet URL as + Search, since ``google.com`` is a substring of all of them. + + Ties (equal-length patterns) resolve to the first one in map order. + """ + best_category: Optional[str] = None + best_len = -1 + for pattern, category in category_map.items(): + pattern_lower = pattern.lower() + if pattern_lower and pattern_lower in haystack and len(pattern_lower) > best_len: + best_category = category + best_len = len(pattern_lower) + return best_category + + 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. + *category_map*: ``{substring: category_name}`` — the **longest** matching + substring wins, so a specific host (``docs.google.com``) is not shadowed by + a generic one (``google.com``) that happens to be listed earlier. 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: + category = _longest_match(url.lower(), category_map) + if category is not None: return category + category = _longest_match(title.lower(), category_map) + if category is not None: + return category return "excluded" diff --git a/tests/test_config.py b/tests/test_config.py index 668104a..3c79231 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,11 +2,26 @@ import re import sys +import aw_core.dirs import tomlkit from aw_watcher_window import config as config_module +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. @@ -15,7 +30,7 @@ def test_first_run_config_has_no_research_keys(tmp_path, monkeypatch): 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. """ - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + _patch_config_dir(monkeypatch, tmp_path) config_module.load_config() @@ -28,7 +43,7 @@ def test_first_run_config_has_no_research_keys(tmp_path, monkeypatch): 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.""" - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + _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( @@ -95,7 +110,7 @@ def test_research_edition_sed_target_is_intact(): def test_parse_args_defaults_research_off_without_config(tmp_path, monkeypatch): """No research keys anywhere => disabled, with empty maps.""" - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + _patch_config_dir(monkeypatch, tmp_path) monkeypatch.setattr(sys, "argv", ["aw-watcher-window"]) args = config_module.parse_args() diff --git a/tests/test_research_filter.py b/tests/test_research_filter.py index 9a6f5fa..41c50ac 100644 --- a/tests/test_research_filter.py +++ b/tests/test_research_filter.py @@ -76,11 +76,16 @@ def test_no_match_returns_excluded(self): "excluded", ) - def test_first_match_wins(self): - # Both "facebook" and "twitter" present — first pattern in iteration wins + 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") @@ -341,6 +346,54 @@ def test_input_not_mutated_with_app_map(self): 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()