Some checks failed
Build / Test on ubuntu-latest, py-3.8 (push) Successful in 4m21s
Lint / lint (push) Successful in 25s
Build / Test on macOS-latest, py-3.8 (push) Has been cancelled
Build / Test on windows-latest, py-3.8 (push) Has been cancelled
CodeQL / Analyze (python) (push) Failing after 1s
831 lines
28 KiB
Python
831 lines
28 KiB
Python
from pprint import pprint
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
from aw_core.models import Event
|
|
from aw_transform import (
|
|
filter_period_intersect,
|
|
filter_keyvals_regex,
|
|
filter_keyvals,
|
|
merge_subwatcher_fields,
|
|
period_union,
|
|
sort_by_timestamp,
|
|
sort_by_duration,
|
|
sum_durations,
|
|
merge_events_by_keys,
|
|
chunk_events_by_key,
|
|
split_url_events,
|
|
simplify_string,
|
|
union,
|
|
union_no_overlap,
|
|
categorize,
|
|
tag,
|
|
Rule,
|
|
)
|
|
from aw_transform.filter_period_intersect import _intersecting_eventpairs
|
|
|
|
|
|
def test_simplify_string():
|
|
events = [
|
|
Event(data={"label": "(99) Facebook"}),
|
|
Event(data={"label": "(14) YouTube"}),
|
|
]
|
|
assert simplify_string(events, "label")[0].data["label"] == "Facebook"
|
|
assert simplify_string(events, "label")[1].data["label"] == "YouTube"
|
|
|
|
events = [Event(data={"app": "Cemu.exe", "title": "Cemu - FPS: 133.7 - BotW"})]
|
|
assert simplify_string(events, "title")[0].data["title"] == "Cemu - FPS: ... - BotW"
|
|
|
|
events = [
|
|
Event(data={"app": "VSCode.exe", "title": "● report.md - Visual Studio Code"})
|
|
]
|
|
assert (
|
|
simplify_string(events, "title")[0].data["title"]
|
|
== "report.md - Visual Studio Code"
|
|
)
|
|
|
|
events = [Event(data={"app": "Gedit", "title": "*test.md - gedit"})]
|
|
assert simplify_string(events, "title")[0].data["title"] == "test.md - gedit"
|
|
|
|
|
|
def test_filter_keyval():
|
|
labels = ["aa", "cc"]
|
|
events = [
|
|
Event(data={"label": "aa"}),
|
|
Event(data={"label": "bb"}),
|
|
Event(data={"label": "cc"}),
|
|
]
|
|
included_events = filter_keyvals(events, "label", labels)
|
|
excluded_events = filter_keyvals(events, "label", labels, exclude=True)
|
|
assert len(included_events) == 2
|
|
assert len(excluded_events) == 1
|
|
|
|
|
|
def test_filter_keyval_regex():
|
|
events = [
|
|
Event(data={"label": "aa"}),
|
|
Event(data={"label": "bb"}),
|
|
Event(data={"label": "cc"}),
|
|
]
|
|
events_re = filter_keyvals_regex(events, "label", "aa|cc")
|
|
assert len(events_re) == 2
|
|
|
|
|
|
def test_filter_keyval_regex_keyerror():
|
|
events = [
|
|
Event(data={"label": "aa"}),
|
|
Event(),
|
|
Event(data={"label": "cc"}),
|
|
]
|
|
events_re = filter_keyvals_regex(events, "label", "aa|cc")
|
|
assert len(events_re) == 2
|
|
|
|
|
|
def test_intersecting_eventpairs():
|
|
td1h = timedelta(hours=1)
|
|
now = datetime.now()
|
|
|
|
# Test with two identical lists
|
|
e1 = [
|
|
Event(timestamp=now, duration=td1h),
|
|
Event(timestamp=now + td1h, duration=td1h),
|
|
]
|
|
e2 = [
|
|
Event(timestamp=now, duration=td1h),
|
|
Event(timestamp=now + td1h, duration=td1h),
|
|
]
|
|
intersecting = list(_intersecting_eventpairs(e1, e2))
|
|
assert len(intersecting) == 2
|
|
|
|
# Test with events in first list being in between events of second list
|
|
e1 = [
|
|
Event(timestamp=now + td1h, duration=td1h),
|
|
]
|
|
e2 = [
|
|
Event(timestamp=now, duration=td1h),
|
|
Event(timestamp=now + 2 * td1h, duration=td1h),
|
|
]
|
|
intersecting = list(_intersecting_eventpairs(e1, e2))
|
|
assert not intersecting
|
|
|
|
# Test with event in first list being identical to middle event in second list
|
|
e1 = [
|
|
Event(timestamp=now + td1h, duration=td1h),
|
|
]
|
|
e2 = [
|
|
Event(timestamp=now, duration=td1h),
|
|
Event(timestamp=now + 1 * td1h, duration=td1h),
|
|
Event(timestamp=now + 2 * td1h, duration=td1h),
|
|
]
|
|
intersecting = list(_intersecting_eventpairs(e1, e2))
|
|
assert len(intersecting) == 1
|
|
|
|
# Test same as before, but reversed
|
|
e1 = list(reversed(e1))
|
|
e2 = list(reversed(e2))
|
|
intersecting = list(_intersecting_eventpairs(e1, e2))
|
|
assert len(intersecting) == 1
|
|
|
|
|
|
def test_filter_period_intersect():
|
|
td1h = timedelta(hours=1)
|
|
td30min = timedelta(minutes=30)
|
|
now = datetime.now()
|
|
|
|
# Filter 1h event with another 1h event at a 30min offset
|
|
to_filter = [Event(timestamp=now, duration=td1h)]
|
|
filter_with = [Event(timestamp=now + td30min, duration=td1h)]
|
|
filtered_events = filter_period_intersect(to_filter, filter_with)
|
|
assert filtered_events[0].duration == td30min
|
|
|
|
# Filter 2x 30min events with a 15min gap with another 45min event in between intersecting both
|
|
to_filter = [
|
|
Event(timestamp=now, duration=td30min),
|
|
Event(timestamp=now + timedelta(minutes=45), duration=td30min),
|
|
]
|
|
filter_with = [
|
|
Event(timestamp=now + timedelta(minutes=15), duration=timedelta(minutes=45))
|
|
]
|
|
filtered_events = filter_period_intersect(to_filter, filter_with)
|
|
assert len(filtered_events) == 2
|
|
assert filtered_events[0].duration == timedelta(minutes=15)
|
|
assert filtered_events[1].duration == timedelta(minutes=15)
|
|
|
|
# Same as previous intersection, but reversing filter and to_filter events
|
|
to_filter = [
|
|
Event(timestamp=now + timedelta(minutes=15), duration=timedelta(minutes=45))
|
|
]
|
|
filter_with = [
|
|
Event(timestamp=now, duration=td30min),
|
|
Event(timestamp=now + timedelta(minutes=45), duration=td30min),
|
|
]
|
|
filtered_events = filter_period_intersect(to_filter, filter_with)
|
|
assert len(filtered_events) == 2
|
|
assert filtered_events[0].duration == timedelta(minutes=15)
|
|
assert filtered_events[1].duration == timedelta(minutes=15)
|
|
|
|
|
|
def test_period_union():
|
|
now = datetime.now(timezone.utc)
|
|
|
|
# Events overlapping
|
|
events1 = [Event(timestamp=now, duration=timedelta(seconds=10))]
|
|
events2 = [
|
|
Event(timestamp=now + timedelta(seconds=9), duration=timedelta(seconds=10))
|
|
]
|
|
unioned_events = period_union(events1, events2)
|
|
assert len(unioned_events) == 1
|
|
|
|
# Events adjacent but not overlapping
|
|
events1 = [Event(timestamp=now, duration=timedelta(seconds=10))]
|
|
events2 = [
|
|
Event(timestamp=now + timedelta(seconds=10), duration=timedelta(seconds=10))
|
|
]
|
|
unioned_events = period_union(events1, events2)
|
|
assert len(unioned_events) == 1
|
|
|
|
# Events not overlapping or adjacent
|
|
events1 = [Event(timestamp=now, duration=timedelta(seconds=10))]
|
|
events2 = [
|
|
Event(timestamp=now + timedelta(seconds=11), duration=timedelta(seconds=10))
|
|
]
|
|
unioned_events = period_union(events1, events2)
|
|
assert len(unioned_events) == 2
|
|
|
|
|
|
def test_sort_by_timestamp():
|
|
now = datetime.now(timezone.utc)
|
|
events = []
|
|
events.append(
|
|
Event(timestamp=now + timedelta(seconds=2), duration=timedelta(seconds=1))
|
|
)
|
|
events.append(
|
|
Event(timestamp=now + timedelta(seconds=1), duration=timedelta(seconds=2))
|
|
)
|
|
events_sorted = sort_by_timestamp(events)
|
|
assert events_sorted == events[::-1]
|
|
|
|
|
|
def test_sort_by_duration():
|
|
now = datetime.now(timezone.utc)
|
|
events = []
|
|
events.append(
|
|
Event(timestamp=now + timedelta(seconds=2), duration=timedelta(seconds=1))
|
|
)
|
|
events.append(
|
|
Event(timestamp=now + timedelta(seconds=1), duration=timedelta(seconds=2))
|
|
)
|
|
events_sorted = sort_by_duration(events)
|
|
assert events_sorted == events[::-1]
|
|
|
|
|
|
def test_sum_durations():
|
|
now = datetime.now(timezone.utc)
|
|
events = []
|
|
for i in range(10):
|
|
events.append(
|
|
Event(timestamp=now + timedelta(seconds=i), duration=timedelta(seconds=1))
|
|
)
|
|
result = sum_durations(events)
|
|
assert result == timedelta(seconds=10)
|
|
|
|
|
|
def test_merge_events_by_keys_1():
|
|
now = datetime.now(timezone.utc)
|
|
events = []
|
|
e1_data = {"label": "a"}
|
|
e2_data = {"label": "b"}
|
|
e1 = Event(data=e1_data, timestamp=now, duration=timedelta(seconds=1))
|
|
e2 = Event(data=e2_data, timestamp=now, duration=timedelta(seconds=1))
|
|
events = events + [e1] * 10
|
|
events = events + [e2] * 5
|
|
|
|
# Check that an empty key list has no effect
|
|
assert merge_events_by_keys(events, []) == events
|
|
|
|
# Check that trying to merge on unavailable key has no effect
|
|
assert len(merge_events_by_keys(events, ["unknown"])) == 1
|
|
|
|
result = merge_events_by_keys(events, ["label"])
|
|
result = sort_by_duration(result)
|
|
print(result)
|
|
print(len(result))
|
|
assert len(result) == 2
|
|
assert result[0].duration == timedelta(seconds=10)
|
|
assert result[1].duration == timedelta(seconds=5)
|
|
|
|
|
|
def test_merge_events_by_keys_2():
|
|
now = datetime.now(timezone.utc)
|
|
events = []
|
|
e1_data = {"k1": "a", "k2": "a"}
|
|
e2_data = {"k1": "a", "k2": "c"}
|
|
e3_data = {"k1": "b", "k2": "a"}
|
|
e1 = Event(data=e1_data, timestamp=now, duration=timedelta(seconds=1))
|
|
e2 = Event(data=e2_data, timestamp=now, duration=timedelta(seconds=1))
|
|
e3 = Event(data=e3_data, timestamp=now, duration=timedelta(seconds=1))
|
|
events = events + [e1] * 10
|
|
events = events + [e2] * 9
|
|
events = events + [e3] * 8
|
|
result = merge_events_by_keys(events, ["k1", "k2"])
|
|
result = sort_by_duration(result)
|
|
print(result)
|
|
print(len(result))
|
|
assert len(result) == 3
|
|
assert result[0].data == e1_data
|
|
assert result[0].duration == timedelta(seconds=10)
|
|
assert result[1].data == e2_data
|
|
assert result[1].duration == timedelta(seconds=9)
|
|
assert result[2].data == e3_data
|
|
assert result[2].duration == timedelta(seconds=8)
|
|
|
|
|
|
def test_chunk_events_by_key():
|
|
now = datetime.now(timezone.utc)
|
|
events = []
|
|
e1_data = {"label1": "1a", "label2": "2a"}
|
|
e2_data = {"label1": "1a", "label2": "2b"}
|
|
e3_data = {"label1": "1b", "label2": "2b"}
|
|
e1 = Event(data=e1_data, timestamp=now, duration=timedelta(seconds=1))
|
|
e2 = Event(data=e2_data, timestamp=now, duration=timedelta(seconds=1))
|
|
e3 = Event(data=e3_data, timestamp=now, duration=timedelta(seconds=1))
|
|
events = [e1, e2, e3]
|
|
result = chunk_events_by_key(events, "label1")
|
|
print(len(result))
|
|
pprint(result)
|
|
assert len(result) == 2
|
|
# Check root label
|
|
assert result[0].data["label1"] == "1a"
|
|
assert result[1].data["label1"] == "1b"
|
|
# Check timestamp
|
|
assert result[0].timestamp == e1.timestamp
|
|
assert result[1].timestamp == e3.timestamp
|
|
# Check duration
|
|
assert result[0].duration == e1.duration + e2.duration
|
|
assert result[1].duration == e3.duration
|
|
# Check subevents
|
|
assert result[0].data["subevents"][0] == e1
|
|
assert result[0].data["subevents"][1] == e2
|
|
assert result[1].data["subevents"][0] == e3
|
|
|
|
|
|
def test_url_parse_event():
|
|
now = datetime.now(timezone.utc)
|
|
e = Event(
|
|
data={"url": "http://asd.com/test/?a=1"},
|
|
timestamp=now,
|
|
duration=timedelta(seconds=1),
|
|
)
|
|
result = split_url_events([e])
|
|
print(result)
|
|
assert result[0].data["$protocol"] == "http"
|
|
assert result[0].data["$domain"] == "asd.com"
|
|
assert result[0].data["$path"] == "/test/"
|
|
assert result[0].data["$params"] == ""
|
|
assert result[0].data["$options"] == "a=1"
|
|
assert result[0].data["$identifier"] == ""
|
|
|
|
e2 = Event(
|
|
data={"url": "https://www.asd.asd.com/test/test2/meh;meh2?asd=2&asdf=3#id"},
|
|
timestamp=now,
|
|
duration=timedelta(seconds=1),
|
|
)
|
|
result = split_url_events([e2])
|
|
print(result)
|
|
assert result[0].data["$protocol"] == "https"
|
|
assert result[0].data["$domain"] == "asd.asd.com"
|
|
assert result[0].data["$path"] == "/test/test2/meh"
|
|
assert result[0].data["$params"] == "meh2"
|
|
assert result[0].data["$options"] == "asd=2&asdf=3"
|
|
assert result[0].data["$identifier"] == "id"
|
|
|
|
e3 = Event(
|
|
data={"url": "file:///home/johan/myfile.txt"},
|
|
timestamp=now,
|
|
duration=timedelta(seconds=1),
|
|
)
|
|
result = split_url_events([e3])
|
|
print(result)
|
|
assert result[0].data["$protocol"] == "file"
|
|
assert result[0].data["$domain"] == "file"
|
|
assert result[0].data["$path"] == "/home/johan/myfile.txt"
|
|
assert result[0].data["$params"] == ""
|
|
assert result[0].data["$options"] == ""
|
|
assert result[0].data["$identifier"] == ""
|
|
|
|
# Test about: URLs
|
|
e4 = Event(
|
|
data={"url": "about:blank"},
|
|
timestamp=now,
|
|
duration=timedelta(seconds=1),
|
|
)
|
|
result = split_url_events([e4])
|
|
assert result[0].data["$protocol"] == "about"
|
|
assert result[0].data["$domain"] == "about"
|
|
|
|
|
|
def test_union():
|
|
now = datetime.now(timezone.utc)
|
|
|
|
e1 = Event(timestamp=now - timedelta(seconds=20), duration=timedelta(seconds=5))
|
|
e2 = Event(timestamp=now - timedelta(seconds=10), duration=timedelta(seconds=5))
|
|
e3 = Event(timestamp=now, duration=timedelta(seconds=1))
|
|
e4 = Event(timestamp=now + timedelta(seconds=20), duration=timedelta(seconds=1))
|
|
|
|
# union separate event lists with duplicates
|
|
events_union = union([e1, e2, e4], [e2, e3])
|
|
assert events_union == [e1, e2, e3, e4]
|
|
|
|
e1 = Event(timestamp=now - timedelta(seconds=20), duration=timedelta(seconds=5))
|
|
e2 = Event(timestamp=now - timedelta(seconds=10), duration=timedelta(seconds=5))
|
|
e3 = Event(timestamp=now - timedelta(seconds=10), duration=timedelta(seconds=10))
|
|
e4 = Event(timestamp=now - timedelta(seconds=5), duration=timedelta(seconds=5))
|
|
e5 = Event(timestamp=now, duration=timedelta(seconds=10))
|
|
|
|
# union event lists with intersecting duplicates
|
|
events_union = union([e3, e2, e5], [e1, e3, e4, e5])
|
|
assert events_union == [e1, e2, e3, e4, e5]
|
|
|
|
e1 = Event(timestamp=now - timedelta(seconds=30), duration=timedelta(seconds=15))
|
|
e2 = Event(timestamp=now, duration=timedelta(seconds=3))
|
|
e3 = Event(timestamp=now, duration=timedelta(seconds=5))
|
|
e4 = Event(timestamp=now, duration=timedelta(seconds=10))
|
|
|
|
# union event lists with same timestamp but different duration duplicates
|
|
events_union = union([e1, e2, e4], [e3, e2, e1])
|
|
assert events_union == [e1, e2, e3, e4]
|
|
|
|
|
|
def test_categorize():
|
|
now = datetime.now(timezone.utc)
|
|
|
|
classes = [
|
|
(["Test"], Rule({"regex": "^just"})),
|
|
(["Test", "Subtest"], Rule({"regex": "subtest$"})),
|
|
(["Test", "Ignorecase"], Rule({"regex": "ignorecase", "ignore_case": True})),
|
|
]
|
|
events = [
|
|
Event(timestamp=now, duration=0, data={"key": "just a test"}),
|
|
Event(timestamp=now, duration=0, data={"key": "just a subtest"}),
|
|
Event(timestamp=now, duration=0, data={"key": "just a IGNORECASE test"}),
|
|
Event(timestamp=now, duration=0, data={}),
|
|
]
|
|
events = categorize(events, classes)
|
|
|
|
assert events[0].data["$category"] == ["Test"]
|
|
assert events[1].data["$category"] == ["Test", "Subtest"]
|
|
assert events[2].data["$category"] == ["Test", "Ignorecase"]
|
|
assert events[3].data["$category"] == ["Uncategorized"]
|
|
|
|
|
|
def _event(value: str = "just a test") -> Event:
|
|
return Event(timestamp=datetime.now(timezone.utc), duration=0, data={"key": value})
|
|
|
|
|
|
def test_categorize_depth_wins_without_priority():
|
|
events = categorize(
|
|
[_event()],
|
|
[
|
|
(["A"], Rule({"regex": "test"})),
|
|
(["B", "B1"], Rule({"regex": "test"})),
|
|
],
|
|
)
|
|
assert events[0].data["$category"] == ["B", "B1"]
|
|
|
|
|
|
def test_categorize_explicit_priority_overrides_depth():
|
|
# Default for B1 is depth 2 → 20; 25 beats it.
|
|
events = categorize(
|
|
[_event()],
|
|
[
|
|
(["A"], Rule({"regex": "test", "priority": 25})),
|
|
(["B", "B1"], Rule({"regex": "test"})),
|
|
],
|
|
)
|
|
assert events[0].data["$category"] == ["A"]
|
|
|
|
|
|
def test_categorize_weight_alias():
|
|
events = categorize(
|
|
[_event()],
|
|
[
|
|
(["A"], Rule({"regex": "test", "weight": 25})),
|
|
(["B", "B1"], Rule({"regex": "test"})),
|
|
],
|
|
)
|
|
assert events[0].data["$category"] == ["A"]
|
|
|
|
|
|
def test_categorize_inter_level_priority():
|
|
between = categorize(
|
|
[_event()],
|
|
[
|
|
(["A"], Rule({"regex": "test"})),
|
|
(["A2"], Rule({"regex": "test", "priority": 15})),
|
|
],
|
|
)
|
|
assert between[0].data["$category"] == ["A2"]
|
|
|
|
still_loses_to_deeper = categorize(
|
|
[_event()],
|
|
[
|
|
(["A2"], Rule({"regex": "test", "priority": 15})),
|
|
(["B", "B1"], Rule({"regex": "test"})),
|
|
],
|
|
)
|
|
assert still_loses_to_deeper[0].data["$category"] == ["B", "B1"]
|
|
|
|
|
|
def test_categorize_lower_priority_loses_to_default_depth():
|
|
events = categorize(
|
|
[_event()],
|
|
[
|
|
(["A"], Rule({"regex": "test"})),
|
|
(["B", "B1"], Rule({"regex": "test", "priority": 0})),
|
|
],
|
|
)
|
|
assert events[0].data["$category"] == ["A"]
|
|
|
|
|
|
def test_categorize_equal_priority_keeps_later_match():
|
|
events = categorize(
|
|
[_event()],
|
|
[
|
|
(["First"], Rule({"regex": "test", "priority": 5})),
|
|
(["Second"], Rule({"regex": "test", "priority": 5})),
|
|
],
|
|
)
|
|
assert events[0].data["$category"] == ["Second"]
|
|
|
|
|
|
def test_categorize_negative_priority_still_beats_uncategorized():
|
|
events = categorize(
|
|
[_event()],
|
|
[(["Low"], Rule({"regex": "test", "priority": -100}))],
|
|
)
|
|
assert events[0].data["$category"] == ["Low"]
|
|
|
|
|
|
def test_categorize_priority_below_i64_min_still_beats_uncategorized():
|
|
# Python ints are unbounded; a signed-64-bit fallback sentinel would
|
|
# incorrectly keep Uncategorized for values below -(2**63).
|
|
events = categorize(
|
|
[_event()],
|
|
[(["Low"], Rule({"regex": "test", "priority": -(2**63) - 1}))],
|
|
)
|
|
assert events[0].data["$category"] == ["Low"]
|
|
|
|
|
|
def test_categorize_empty_category_keeps_uncategorized():
|
|
events = categorize(
|
|
[_event()],
|
|
[([], Rule({"regex": "test"}))],
|
|
)
|
|
assert events[0].data["$category"] == ["Uncategorized"]
|
|
|
|
|
|
def test_rule_invalid_priority():
|
|
with pytest.raises(ValueError, match="integer"):
|
|
Rule({"regex": "test", "priority": 1.5})
|
|
with pytest.raises(ValueError, match="integer"):
|
|
Rule({"regex": "test", "priority": "high"})
|
|
with pytest.raises(ValueError, match="integer"):
|
|
Rule({"regex": "test", "priority": True})
|
|
|
|
|
|
def test_categorize_cache_correctness():
|
|
"""Cache reuses category for identical data; distinct data gets its own category."""
|
|
now = datetime.now(timezone.utc)
|
|
|
|
classes = [
|
|
(["Browser"], Rule({"regex": "Firefox"})),
|
|
(["Editor"], Rule({"regex": "vim"})),
|
|
]
|
|
firefox_data = {"app": "Firefox", "title": "Home"}
|
|
vim_data = {"app": "vim", "title": "classify.py"}
|
|
|
|
# 50 Firefox events, 1 vim event, 50 more Firefox events
|
|
events = (
|
|
[Event(timestamp=now, duration=0, data=dict(firefox_data)) for _ in range(50)]
|
|
+ [Event(timestamp=now, duration=0, data=dict(vim_data))]
|
|
+ [Event(timestamp=now, duration=0, data=dict(firefox_data)) for _ in range(50)]
|
|
)
|
|
result = categorize(events, classes)
|
|
|
|
for e in result[:50]:
|
|
assert e.data["$category"] == ["Browser"]
|
|
assert result[50].data["$category"] == ["Editor"]
|
|
for e in result[51:]:
|
|
assert e.data["$category"] == ["Browser"]
|
|
|
|
# Mutating one event's category must not affect others sharing the same data fingerprint
|
|
result[0].data["$category"].append("MUTATED")
|
|
assert result[1].data["$category"] == ["Browser"]
|
|
|
|
|
|
def test_tags():
|
|
now = datetime.now(timezone.utc)
|
|
|
|
classes = [
|
|
("Test", Rule({"regex": "value$"})),
|
|
("Test", Rule({"regex": "^just"})),
|
|
]
|
|
events = [
|
|
Event(timestamp=now, duration=0, data={"key": "just a test value"}),
|
|
Event(timestamp=now, duration=0, data={}),
|
|
]
|
|
events = tag(events, classes)
|
|
|
|
assert len(events[0].data["$tags"]) == 2
|
|
assert len(events[1].data["$tags"]) == 0
|
|
|
|
|
|
def test_union_no_overlap():
|
|
from pprint import pprint
|
|
|
|
now = datetime(2018, 1, 1, 0, 0)
|
|
td1h = timedelta(hours=1)
|
|
events1 = [
|
|
Event(timestamp=now + 2 * i * td1h, duration=td1h, data={"test": 1})
|
|
for i in range(3)
|
|
]
|
|
events2 = [
|
|
Event(timestamp=now + (2 * i + 0.5) * td1h, duration=td1h, data={"test": 2})
|
|
for i in range(3)
|
|
]
|
|
|
|
events_union = union_no_overlap(events1, events2)
|
|
# pprint(events_union)
|
|
dur = sum((e.duration for e in events_union), timedelta(0))
|
|
assert dur == timedelta(hours=4, minutes=30)
|
|
assert sorted(events_union, key=lambda e: e.timestamp)
|
|
|
|
events_union = union_no_overlap(events2, events1)
|
|
# pprint(events_union)
|
|
dur = sum((e.duration for e in events_union), timedelta(0))
|
|
assert dur == timedelta(hours=4, minutes=30)
|
|
assert sorted(events_union, key=lambda e: e.timestamp)
|
|
|
|
events1 = [
|
|
Event(timestamp=now + (2 * i) * td1h, duration=td1h, data={"test": 1})
|
|
for i in range(3)
|
|
]
|
|
events2 = [Event(timestamp=now, duration=5 * td1h, data={"test": 2})]
|
|
events_union = union_no_overlap(events1, events2)
|
|
pprint(events_union)
|
|
dur = sum((e.duration for e in events_union), timedelta(0))
|
|
assert dur == timedelta(hours=5, minutes=0)
|
|
assert sorted(events_union, key=lambda e: e.timestamp)
|
|
|
|
|
|
def test_merge_subwatcher_fields_basic():
|
|
"""Fully overlapping subwatcher fields are injected without changing duration."""
|
|
now = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)
|
|
td1h = timedelta(hours=1)
|
|
|
|
base = [
|
|
Event(
|
|
timestamp=now,
|
|
duration=td1h,
|
|
data={"app": "vim", "title": "file.py"},
|
|
)
|
|
]
|
|
sub = [
|
|
Event(
|
|
timestamp=now,
|
|
duration=td1h,
|
|
data={"project": "myproject", "file": "file.py", "language": "python"},
|
|
)
|
|
]
|
|
result = merge_subwatcher_fields(base, sub, ["project", "file", "language"])
|
|
|
|
assert len(result) == 1
|
|
# Original base fields preserved
|
|
assert result[0].data["app"] == "vim"
|
|
assert result[0].data["title"] == "file.py"
|
|
# Subwatcher fields injected
|
|
assert result[0].data["project"] == "myproject"
|
|
assert result[0].data["language"] == "python"
|
|
# Exact overlap means no extra segmentation
|
|
assert result[0].timestamp == now
|
|
assert result[0].duration == td1h
|
|
|
|
|
|
def test_merge_subwatcher_fields_partial_overlap_splits_base():
|
|
"""Partial overlap only enriches the covered slice of the base event."""
|
|
now = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)
|
|
td15m = timedelta(minutes=15)
|
|
td30m = timedelta(minutes=30)
|
|
td1h = timedelta(hours=1)
|
|
|
|
base = [Event(timestamp=now, duration=td1h, data={"app": "vim"})]
|
|
sub = [
|
|
Event(
|
|
timestamp=now + td15m,
|
|
duration=td30m,
|
|
data={"project": "myproject"},
|
|
)
|
|
]
|
|
|
|
result = merge_subwatcher_fields(base, sub, ["project"])
|
|
|
|
assert len(result) == 3
|
|
assert [event.duration for event in result] == [td15m, td30m, td15m]
|
|
assert [event.timestamp for event in result] == [
|
|
now,
|
|
now + td15m,
|
|
now + td15m + td30m,
|
|
]
|
|
assert "project" not in result[0].data
|
|
assert result[1].data["project"] == "myproject"
|
|
assert "project" not in result[2].data
|
|
assert sum_durations(result) == td1h
|
|
|
|
|
|
def test_merge_subwatcher_fields_overlapping_subwatchers_prefer_latest_start():
|
|
"""Newer overlapping subwatcher events take over from their own start time."""
|
|
now = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)
|
|
td20m = timedelta(minutes=20)
|
|
td40m = timedelta(minutes=40)
|
|
td1h = timedelta(hours=1)
|
|
|
|
base = [Event(timestamp=now, duration=td1h, data={"app": "vim"})]
|
|
sub = [
|
|
Event(timestamp=now, duration=td40m, data={"project": "alpha"}),
|
|
Event(timestamp=now + td20m, duration=td40m, data={"project": "beta"}),
|
|
]
|
|
|
|
result = merge_subwatcher_fields(base, sub, ["project"])
|
|
|
|
assert len(result) == 2
|
|
assert [event.timestamp for event in result] == [now, now + td20m]
|
|
assert [event.duration for event in result] == [td20m, td40m]
|
|
assert [event.data.get("project") for event in result] == ["alpha", "beta"]
|
|
|
|
by_project = {
|
|
event.data.get("project"): event.duration
|
|
for event in merge_events_by_keys(result, ["project"])
|
|
}
|
|
assert by_project["alpha"] == td20m
|
|
assert by_project["beta"] == td40m
|
|
assert sum_durations(result) == td1h
|
|
|
|
|
|
def test_merge_subwatcher_fields_no_overlap():
|
|
"""Base events with no overlapping subwatcher event are returned unchanged."""
|
|
now = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)
|
|
td1h = timedelta(hours=1)
|
|
|
|
base = [Event(timestamp=now, duration=td1h, data={"app": "vim"})]
|
|
# Subwatcher event is entirely after the base event
|
|
sub = [
|
|
Event(
|
|
timestamp=now + 2 * td1h,
|
|
duration=td1h,
|
|
data={"project": "other"},
|
|
)
|
|
]
|
|
result = merge_subwatcher_fields(base, sub, ["project"])
|
|
|
|
assert len(result) == 1
|
|
assert "project" not in result[0].data
|
|
assert result[0].data["app"] == "vim"
|
|
|
|
|
|
def test_merge_subwatcher_fields_base_wins_conflict():
|
|
"""With conflict='base_wins' (default), existing base keys are not overwritten."""
|
|
now = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)
|
|
td1h = timedelta(hours=1)
|
|
|
|
base = [Event(timestamp=now, duration=td1h, data={"app": "vim", "file": "base.py"})]
|
|
sub = [Event(timestamp=now, duration=td1h, data={"file": "sub.py", "project": "p"})]
|
|
|
|
result = merge_subwatcher_fields(
|
|
base, sub, ["file", "project"], conflict="base_wins"
|
|
)
|
|
# base's "file" must not be overwritten
|
|
assert result[0].data["file"] == "base.py"
|
|
# "project" not in base → injected from sub
|
|
assert result[0].data["project"] == "p"
|
|
|
|
|
|
def test_merge_subwatcher_fields_sub_wins_conflict():
|
|
"""With conflict='sub_wins', subwatcher fields overwrite base fields."""
|
|
now = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)
|
|
td1h = timedelta(hours=1)
|
|
|
|
base = [Event(timestamp=now, duration=td1h, data={"app": "vim", "file": "base.py"})]
|
|
sub = [Event(timestamp=now, duration=td1h, data={"file": "sub.py"})]
|
|
|
|
result = merge_subwatcher_fields(base, sub, ["file"], conflict="sub_wins")
|
|
assert result[0].data["file"] == "sub.py"
|
|
|
|
|
|
def test_merge_subwatcher_fields_multiple_subsegments_preserve_duration():
|
|
"""Repeated subwatcher values aggregate to their true covered duration."""
|
|
now = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)
|
|
td15m = timedelta(minutes=15)
|
|
td1h = timedelta(hours=1)
|
|
|
|
base = [Event(timestamp=now, duration=td1h, data={"app": "vim"})]
|
|
sub = [
|
|
Event(timestamp=now, duration=td15m, data={"project": "alpha"}),
|
|
Event(timestamp=now + td15m, duration=td15m, data={"project": "beta"}),
|
|
Event(
|
|
timestamp=now + 2 * td15m,
|
|
duration=td15m,
|
|
data={"project": "alpha"},
|
|
),
|
|
]
|
|
|
|
result = merge_subwatcher_fields(base, sub, ["project"])
|
|
|
|
by_project = {
|
|
event.data.get("project"): event.duration
|
|
for event in merge_events_by_keys(result, ["project"])
|
|
}
|
|
by_app = {
|
|
event.data.get("app"): event.duration
|
|
for event in merge_events_by_keys(result, ["app"])
|
|
}
|
|
|
|
assert by_project["alpha"] == 2 * td15m
|
|
assert by_project["beta"] == td15m
|
|
assert by_project[None] == td15m
|
|
assert by_app["vim"] == td1h
|
|
assert sum_durations(result) == td1h
|
|
|
|
|
|
def test_merge_subwatcher_fields_empty_inputs():
|
|
"""Empty sub or keys returns a defensive copy of base (not the same list)."""
|
|
now = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)
|
|
td1h = timedelta(hours=1)
|
|
base = [Event(timestamp=now, duration=td1h, data={"app": "vim"})]
|
|
|
|
# Empty subwatcher list — returns a new list, data unchanged
|
|
result = merge_subwatcher_fields(base, [], ["project"])
|
|
assert result[0].data == {"app": "vim"}
|
|
assert result is not base
|
|
|
|
# Empty keys list
|
|
sub = [Event(timestamp=now, duration=td1h, data={"project": "p"})]
|
|
result = merge_subwatcher_fields(base, sub, [])
|
|
assert "project" not in result[0].data
|
|
assert result is not base
|
|
|
|
# Both empty
|
|
result = merge_subwatcher_fields(base, [], [])
|
|
assert result[0].data == {"app": "vim"}
|
|
assert result is not base
|
|
|
|
|
|
def test_merge_subwatcher_fields_invalid_conflict():
|
|
"""Invalid conflict value raises ValueError immediately."""
|
|
now = datetime(2024, 1, 1, 12, 0, tzinfo=timezone.utc)
|
|
td1h = timedelta(hours=1)
|
|
base = [Event(timestamp=now, duration=td1h, data={"app": "vim"})]
|
|
sub = [Event(timestamp=now, duration=td1h, data={"project": "p"})]
|
|
|
|
with pytest.raises(ValueError, match="conflict must be"):
|
|
merge_subwatcher_fields(base, sub, ["project"], conflict="invalid")
|