Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
38 changes: 30 additions & 8 deletions aw_watcher_window/macos.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
38 changes: 30 additions & 8 deletions aw_watcher_window/research_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +78 to +80

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unicode ranking semantics diverge

If a category map contains overlapping non-ASCII patterns, Python ranks lowercased code points while Swift ranks original extended grapheme clusters and uses different case-folding semantics, producing different research categories for the same activity across platforms.

Knowledge Base Used:

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4906753. replaces in so Swift matches Python's code-point length semantics. For the study maps in use today (ASCII domain names) there is no behavioural difference, but the fix prevents divergence when non-ASCII patterns are added.

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"


Expand Down
21 changes: 18 additions & 3 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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()

Expand All @@ -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(
Expand Down Expand Up @@ -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()
Expand Down
57 changes: 55 additions & 2 deletions tests/test_research_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Regression tests bypass CI

The new longest-match regression tests are not run by make test or the pull-request workflow, so CI remains green when these classification cases fail.

Knowledge Base Used: Quality automation

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4906753. Added poetry run python -m pytest tests/ to the Makefile test target — the same target the CI workflow calls with make test. Both the 44 pre-existing tests and the 6 new regression cases now run on every push across ubuntu, macOS, and Windows.

"""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()
Loading