From 4f0a8359609143de0464c7fab2b14092f0ccf7f8 Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 26 Aug 2026 13:21:11 +0000 Subject: [PATCH 1/3] fix(research): classify by longest matching pattern, not map order Study category maps list both a generic and a specific form of the same host: `google.com` -> Search & Navigation appears well before `docs.google.com` -> Work & Productivity. Because classify_title matched in insertion order, every Google Workspace URL (Docs, Drive, Calendar, Meet) was filed under Search & Navigation. Reported from a Research Edition field test: the participant's Top Applications view showed Workspace time as Search. Select the longest matching pattern instead, in both the Python filter and its Swift twin used by the macOS capture path. Ties resolve to map order, so existing single-form maps behave exactly as before. Empty pattern keys are now ignored rather than matching everything. 6 regression tests added; all fail against the previous implementation. --- aw_watcher_window/macos.swift | 33 ++++++++++++---- aw_watcher_window/research_filter.py | 38 +++++++++++++++---- tests/test_research_filter.py | 57 +++++++++++++++++++++++++++- 3 files changed, 110 insertions(+), 18 deletions(-) diff --git a/aw_watcher_window/macos.swift b/aw_watcher_window/macos.swift index e8e0136..b3d68de 100644 --- a/aw_watcher_window/macos.swift +++ b/aw_watcher_window/macos.swift @@ -251,19 +251,36 @@ 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 } + if haystack.range(of: item.pattern, options: [.caseInsensitive]) != nil, + item.pattern.count > bestLength + { + bestCategory = item.category + bestLength = item.pattern.count + } + } + 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_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() From 4906753865218dd4d13bf6100ece0b873eeb4c7d Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 26 Aug 2026 13:30:49 +0000 Subject: [PATCH 2/3] fix(research): align Swift length semantics with Python; run tests in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from review: - Swift's String.count measures grapheme clusters while Python's len() measures code points, so the two longest-match twins could pick different winners for non-ASCII patterns of equal visual length. Compare unicodeScalars.count instead. - 'make test' only ran '--help' and mypy, so tests/ has never executed in CI — not the new regression cases, and not the 44 that predate them. Add pytest to the test target. --- Makefile | 1 + aw_watcher_window/macos.swift | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) 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 b3d68de..961a846 100644 --- a/aw_watcher_window/macos.swift +++ b/aw_watcher_window/macos.swift @@ -262,11 +262,16 @@ func longestResearchMatch(_ haystack: String) -> 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, - item.pattern.count > bestLength + patternLength > bestLength { bestCategory = item.category - bestLength = item.pattern.count + bestLength = patternLength } } return bestCategory From adb59365304c49f0aa9b65f1714ec868b858654b Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 26 Aug 2026 13:53:52 +0000 Subject: [PATCH 3/3] fix(tests): use cross-platform config dir patch instead of XDG_CONFIG_HOME XDG_CONFIG_HOME is Linux-only; appdirs ignores it on macOS and Windows, so the two config tests that needed a controlled config dir were failing on those platforms (StopIteration / False is True assertions). Replace the env-var approach with a monkeypatch of aw_core.dirs.get_config_dir that redirects to tmp_path on all platforms. Apply consistently across all three XDG_CONFIG_HOME test sites. --- tests/test_config.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) 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()