snapshot of https://github.com/ActivityWatch/aw-core.git (master, 2026-09-16; blobs > 131072B omitted)
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
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
This commit is contained in:
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import context # noqa: F401
|
||||
6
tests/context.py
Normal file
6
tests/context.py
Normal file
@@ -0,0 +1,6 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
# This will cause imports to use the packages in the
|
||||
# source folder instead of those installed on the system.
|
||||
sys.path.insert(0, os.path.abspath(".."))
|
||||
64
tests/test_cli_log.py
Normal file
64
tests/test_cli_log.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Tests for aw_cli.log.find_oldest_log after profile isolation (#149).
|
||||
|
||||
With per-profile appname dirs, every .log file in a module dir belongs to the
|
||||
active profile — no filename-based filtering is needed.
|
||||
"""
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from aw_cli.log import find_oldest_log
|
||||
|
||||
from . import context # noqa: F401
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def log_dir(tmp_path: Path) -> Path:
|
||||
return tmp_path / "aw-server"
|
||||
|
||||
|
||||
def test_returns_none_for_missing_dir(tmp_path: Path) -> None:
|
||||
assert find_oldest_log(tmp_path / "nonexistent") is None
|
||||
|
||||
|
||||
def test_returns_none_for_empty_dir(log_dir: Path) -> None:
|
||||
log_dir.mkdir()
|
||||
assert find_oldest_log(log_dir) is None
|
||||
|
||||
|
||||
def test_returns_single_log(log_dir: Path) -> None:
|
||||
log_dir.mkdir()
|
||||
f = log_dir / "aw-server_2026-01-01.log"
|
||||
f.write_text("line\n")
|
||||
assert find_oldest_log(log_dir) == f
|
||||
|
||||
|
||||
def test_returns_newest_by_mtime(log_dir: Path) -> None:
|
||||
log_dir.mkdir()
|
||||
old = log_dir / "aw-server_old.log"
|
||||
new = log_dir / "aw-server_new.log"
|
||||
old.write_text("old\n")
|
||||
time.sleep(0.01)
|
||||
new.write_text("new\n")
|
||||
assert find_oldest_log(log_dir) == new
|
||||
|
||||
|
||||
def test_ignores_non_log_files(log_dir: Path) -> None:
|
||||
log_dir.mkdir()
|
||||
(log_dir / "notes.txt").write_text("not a log")
|
||||
assert find_oldest_log(log_dir) is None
|
||||
|
||||
|
||||
def test_no_profile_filtering_in_filenames(log_dir: Path) -> None:
|
||||
"""All .log files in the dir belong to the active profile — no name filter."""
|
||||
log_dir.mkdir()
|
||||
default_log = log_dir / "aw-server_2026-01-01.log"
|
||||
default_log.write_text("line\n")
|
||||
# A file named with 'testing' in its name is still returned;
|
||||
# profile selection happens at the directory level, not the filename level.
|
||||
testing_named = log_dir / "aw-server_testing_2026-01-02.log"
|
||||
time.sleep(0.01)
|
||||
testing_named.write_text("testing-named\n")
|
||||
assert find_oldest_log(log_dir) == testing_named
|
||||
102
tests/test_config.py
Normal file
102
tests/test_config.py
Normal file
@@ -0,0 +1,102 @@
|
||||
import shutil
|
||||
from configparser import ConfigParser
|
||||
from pathlib import Path
|
||||
|
||||
import deprecation
|
||||
import pytest
|
||||
from aw_core import dirs
|
||||
from aw_core.config import (
|
||||
load_config,
|
||||
load_config_toml,
|
||||
save_config,
|
||||
save_config_toml,
|
||||
)
|
||||
|
||||
appname = "aw-core-test"
|
||||
section = "section"
|
||||
config_dir = dirs.get_config_dir(appname)
|
||||
|
||||
default_config_str = f"""# A default config file, with comments!
|
||||
[{section}]
|
||||
somestring = "Hello World!" # A comment
|
||||
somevalue = 12.3 # Another comment
|
||||
somearray = ["asd", 123]"""
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_config():
|
||||
# Remove test config file if it already exists
|
||||
shutil.rmtree(config_dir, ignore_errors=True)
|
||||
|
||||
# Rerun get_config dir to create config directory
|
||||
dirs.get_config_dir(appname)
|
||||
|
||||
yield
|
||||
|
||||
# Remove test config file if it already exists
|
||||
shutil.rmtree(config_dir)
|
||||
|
||||
|
||||
def test_create():
|
||||
appname = "aw-core-test"
|
||||
config_dir = dirs.get_config_dir(appname)
|
||||
assert Path(config_dir).exists()
|
||||
|
||||
|
||||
def test_config_defaults():
|
||||
# Load non-existing config (will create a out-commented default config file)
|
||||
config = load_config_toml(appname, default_config_str)
|
||||
|
||||
# Check that load_config used defaults
|
||||
assert config[section]["somestring"] == "Hello World!"
|
||||
assert config[section]["somevalue"] == 12.3
|
||||
assert config[section]["somearray"] == ["asd", 123]
|
||||
|
||||
|
||||
def test_config_no_defaults():
|
||||
# Write defaults to file
|
||||
save_config_toml(appname, default_config_str)
|
||||
|
||||
# Load written defaults without defaults
|
||||
config = load_config_toml(appname, "")
|
||||
assert config[section]["somestring"] == "Hello World!"
|
||||
assert config[section]["somevalue"] == 12.3
|
||||
assert config[section]["somearray"] == ["asd", 123]
|
||||
|
||||
|
||||
def test_config_override():
|
||||
# Create a minimal config file with one overridden value
|
||||
config_str = """[section]
|
||||
somevalue = 1000.1"""
|
||||
save_config_toml(appname, config_str)
|
||||
|
||||
# Open non-default config file and verify that values are correct
|
||||
config = load_config_toml(appname, default_config_str)
|
||||
assert config[section]["somevalue"] == 1000.1
|
||||
|
||||
|
||||
@deprecation.fail_if_not_removed
|
||||
def test_config_ini():
|
||||
# Create default config
|
||||
default_config = ConfigParser()
|
||||
default_config[section] = {"somestring": "Hello World!", "somevalue": 12.3} # type: ignore
|
||||
|
||||
# Load non-existing config (will create a default config file)
|
||||
config = load_config(appname, default_config)
|
||||
|
||||
# Check that current config file is same as default config file
|
||||
assert config[section]["somestring"] == default_config[section]["somestring"]
|
||||
assert config[section].getfloat("somevalue") == default_config[section].getfloat(
|
||||
"somevalue"
|
||||
)
|
||||
|
||||
# Modify and save config file
|
||||
config[section]["somevalue"] = "1000.1"
|
||||
save_config(appname, config)
|
||||
|
||||
# Open non-default config file and verify that values are correct
|
||||
new_config = load_config(appname, default_config)
|
||||
assert new_config[section]["somestring"] == config[section]["somestring"]
|
||||
assert new_config[section].getfloat("somevalue") == config[section].getfloat(
|
||||
"somevalue"
|
||||
)
|
||||
586
tests/test_datastore.py
Normal file
586
tests/test_datastore.py
Normal file
@@ -0,0 +1,586 @@
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import iso8601
|
||||
import pytest
|
||||
from aw_core.models import Event
|
||||
from aw_datastore import get_storage_methods
|
||||
from aw_datastore.storages import PeeweeStorage
|
||||
|
||||
from . import context # noqa: F401
|
||||
from .utils import param_datastore_objects, param_testing_buckets_cm
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
# Useful when you just want some placeholder time in your events, saves typing
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
td1s = timedelta(seconds=1)
|
||||
td1d = timedelta(days=1)
|
||||
|
||||
|
||||
def test_get_storage_methods():
|
||||
assert get_storage_methods()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("datastore", param_datastore_objects())
|
||||
def test_get_buckets(datastore):
|
||||
"""
|
||||
Tests fetching buckets
|
||||
"""
|
||||
buckets = datastore.buckets()
|
||||
for bucket in buckets.values():
|
||||
assert bucket["id"] in buckets
|
||||
assert bucket["data"] == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("datastore", param_datastore_objects())
|
||||
def test_create_bucket(datastore):
|
||||
name = "A label/name for a test bucket"
|
||||
bid = "test-identifier"
|
||||
try:
|
||||
bucket = datastore.create_bucket(
|
||||
bucket_id=bid,
|
||||
type="testtype",
|
||||
client="testclient",
|
||||
hostname="testhost",
|
||||
name=name,
|
||||
created=now,
|
||||
data={"key": "value"},
|
||||
)
|
||||
metadata = bucket.metadata()
|
||||
assert bid == metadata["id"]
|
||||
assert name == metadata["name"]
|
||||
assert "testtype" == metadata["type"]
|
||||
assert "testclient" == metadata["client"]
|
||||
assert "testhost" == metadata["hostname"]
|
||||
assert now == iso8601.parse_date(metadata["created"])
|
||||
assert bid in datastore.buckets()
|
||||
assert {"key": "value"} == metadata["data"]
|
||||
finally:
|
||||
datastore.delete_bucket(bid)
|
||||
assert bid not in datastore.buckets()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("datastore", param_datastore_objects())
|
||||
def test_update_bucket(datastore):
|
||||
bid = "test-" + str(random.randint(0, 1000000))
|
||||
try:
|
||||
datastore.create_bucket(
|
||||
bucket_id=bid, type="test", client="test", hostname="test", name="test"
|
||||
)
|
||||
datastore.update_bucket(bid, name="new name")
|
||||
assert datastore[bid].metadata()["name"] == "new name"
|
||||
datastore.update_bucket(bid, data={"key": "value"})
|
||||
assert datastore[bid].metadata()["data"] == {"key": "value"}
|
||||
finally:
|
||||
datastore.delete_bucket(bid)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("datastore", param_datastore_objects())
|
||||
def test_delete_bucket(datastore):
|
||||
bid = "test-" + str(random.randint(0, 1000000))
|
||||
datastore.create_bucket(
|
||||
bucket_id=bid, type="test", client="test", hostname="test", name="test"
|
||||
)
|
||||
datastore.delete_bucket(bid)
|
||||
assert bid not in datastore.buckets()
|
||||
with pytest.raises(ValueError):
|
||||
datastore.delete_bucket(bid)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("datastore", param_datastore_objects())
|
||||
def test_nonexistent_bucket(datastore):
|
||||
"""
|
||||
Tests that a KeyError is raised if you request a non-existent bucket
|
||||
"""
|
||||
with pytest.raises(KeyError):
|
||||
datastore["I-do-not-exist"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
|
||||
def test_insert_one(bucket_cm):
|
||||
"""
|
||||
Tests inserting one event into a bucket
|
||||
"""
|
||||
with bucket_cm as bucket:
|
||||
n_events = len(bucket.get())
|
||||
event = Event(timestamp=now, duration=td1s, data={"key": "val"})
|
||||
bucket.insert(event)
|
||||
fetched_events = bucket.get()
|
||||
assert n_events + 1 == len(fetched_events)
|
||||
assert isinstance(fetched_events[0], Event)
|
||||
assert event == fetched_events[0]
|
||||
logging.info(event)
|
||||
logging.info(fetched_events[0].to_json_str())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
|
||||
def test_empty_bucket(bucket_cm):
|
||||
"""
|
||||
Ensures empty buckets are empty
|
||||
"""
|
||||
with bucket_cm as bucket:
|
||||
assert 0 == len(bucket.get())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
|
||||
def test_insert_many(bucket_cm):
|
||||
"""
|
||||
Tests that you can insert many events at the same time to a bucket
|
||||
"""
|
||||
num_events = 5000
|
||||
with bucket_cm as bucket:
|
||||
events = num_events * [Event(timestamp=now, duration=td1s, data={"key": "val"})]
|
||||
bucket.insert(events)
|
||||
fetched_events = bucket.get(limit=-1)
|
||||
assert num_events == len(fetched_events)
|
||||
for e, fe in zip(events, fetched_events):
|
||||
assert e == fe
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
|
||||
def test_insert_many_upsert(bucket_cm):
|
||||
"""
|
||||
Tests that you can update/upsert many events at the same time to a bucket
|
||||
"""
|
||||
num_events = 10
|
||||
with bucket_cm as bucket:
|
||||
events = num_events * [Event(timestamp=now, duration=td1s, data={"key": "val"})]
|
||||
# insert events to get IDs assigned
|
||||
bucket.insert(events)
|
||||
|
||||
events = bucket.get(limit=-1)
|
||||
assert num_events == len(events)
|
||||
for e in events:
|
||||
assert e.id is not None
|
||||
e.data["key"] = "new val"
|
||||
|
||||
# Upsert the events
|
||||
bucket.insert(events)
|
||||
|
||||
events = bucket.get(limit=-1)
|
||||
assert num_events == len(events)
|
||||
for e in events:
|
||||
assert e.data["key"] == "new val"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
|
||||
def test_delete(bucket_cm):
|
||||
"""
|
||||
Tests deleting single events
|
||||
"""
|
||||
num_events = 10
|
||||
with bucket_cm as bucket:
|
||||
events = num_events * [Event(timestamp=now, duration=td1s, data={"key": "val"})]
|
||||
bucket.insert(events)
|
||||
|
||||
fetched_events = bucket.get(limit=-1)
|
||||
print(fetched_events[0])
|
||||
assert num_events == len(fetched_events)
|
||||
|
||||
# Test deleting event
|
||||
assert bucket.delete(fetched_events[0]["id"])
|
||||
|
||||
# Test deleting non-existent event
|
||||
# FIXME: Doesn't work due to lazy evaluation in SqliteDatastore
|
||||
# assert not bucket.delete(fetched_events[0]["id"])
|
||||
|
||||
fetched_events = bucket.get(limit=-1)
|
||||
assert num_events - 1 == len(fetched_events)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
|
||||
def test_insert_badtype(bucket_cm):
|
||||
"""
|
||||
Tests that you cannot insert non-event types into a bucket
|
||||
"""
|
||||
with bucket_cm as bucket:
|
||||
bucket_len = len(bucket.get())
|
||||
badevent = 1
|
||||
with pytest.raises(TypeError):
|
||||
bucket.insert(badevent)
|
||||
assert bucket_len == len(bucket.get())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
|
||||
def test_get_ordered(bucket_cm):
|
||||
"""
|
||||
Makes sure that received events are ordered
|
||||
"""
|
||||
with bucket_cm as bucket:
|
||||
eventcount = 10
|
||||
events = []
|
||||
for i in range(10):
|
||||
events.append(Event(timestamp=now + i * td1s, duration=td1s))
|
||||
random.shuffle(events)
|
||||
print(events)
|
||||
bucket.insert(events)
|
||||
fetched_events = bucket.get(-1)
|
||||
for i in range(eventcount - 1):
|
||||
print("1:" + fetched_events[i].to_json_str())
|
||||
print("2:" + fetched_events[i + 1].to_json_str())
|
||||
assert fetched_events[i].timestamp > fetched_events[i + 1].timestamp
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
|
||||
def test_get_event_with_timezone(bucket_cm):
|
||||
"""Tries to retrieve an event using a timezone aware datetime."""
|
||||
hour = timedelta(hours=1)
|
||||
td_offset = 2 * hour
|
||||
tz = timezone(td_offset)
|
||||
|
||||
dt_utc = datetime(2017, 10, 27, hour=0, minute=5, tzinfo=timezone.utc)
|
||||
dt_with_tz = dt_utc.replace(tzinfo=tz)
|
||||
|
||||
with bucket_cm as bucket:
|
||||
bucket.insert(Event(timestamp=dt_with_tz))
|
||||
fetched_events = bucket.get(
|
||||
starttime=dt_with_tz - hour, endtime=dt_with_tz + hour
|
||||
)
|
||||
assert len(fetched_events) == 1
|
||||
|
||||
fetched_events = bucket.get(
|
||||
starttime=dt_utc - td_offset - hour, endtime=dt_utc - td_offset + hour
|
||||
)
|
||||
assert len(fetched_events) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
|
||||
def test_get_datefilter_simple(bucket_cm):
|
||||
with bucket_cm as bucket:
|
||||
eventcount = 3
|
||||
events = [
|
||||
Event(timestamp=now + i * td1s, duration=td1s) for i in range(eventcount)
|
||||
]
|
||||
bucket.insert(events)
|
||||
|
||||
# Get first event, but expect only half the event to match the interval
|
||||
fetched_events = bucket.get(
|
||||
-1,
|
||||
starttime=now - 0.5 * td1s,
|
||||
endtime=now + 0.5 * td1s,
|
||||
)
|
||||
assert 1 == len(fetched_events)
|
||||
|
||||
# Get first two events, but expect only half of each to match the interval
|
||||
fetched_events = bucket.get(
|
||||
-1,
|
||||
starttime=now + 0.5 * td1s,
|
||||
endtime=now + 1.5 * td1s,
|
||||
)
|
||||
assert 2 == len(fetched_events)
|
||||
|
||||
# Get last event, but expect only half to match the interval
|
||||
fetched_events = bucket.get(
|
||||
-1,
|
||||
starttime=now + 2.5 * td1s,
|
||||
endtime=now + 3.5 * td1s,
|
||||
)
|
||||
assert 1 == len(fetched_events)
|
||||
|
||||
# Check approx precision
|
||||
fetched_events = bucket.get(
|
||||
-1,
|
||||
starttime=now - 0.01 * td1s,
|
||||
endtime=now + 0.01 * td1s,
|
||||
)
|
||||
assert 1 == len(fetched_events)
|
||||
|
||||
# Check precision of start
|
||||
fetched_events = bucket.get(
|
||||
-1,
|
||||
starttime=now,
|
||||
endtime=now,
|
||||
)
|
||||
assert 1 == len(fetched_events)
|
||||
|
||||
# Check approx precision of end
|
||||
fetched_events = bucket.get(
|
||||
-1,
|
||||
starttime=now + 2.99 * td1s,
|
||||
endtime=now + 3.01 * td1s,
|
||||
)
|
||||
assert 1 == len(fetched_events)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
|
||||
def test_get_event_by_id(bucket_cm):
|
||||
"""Test that we can retrieve single events by their IDs"""
|
||||
with bucket_cm as bucket:
|
||||
eventcount = 2
|
||||
# Create 1-day long events
|
||||
events = [
|
||||
Event(timestamp=now + i * td1d, duration=td1d) for i in range(eventcount)
|
||||
]
|
||||
bucket.insert(events)
|
||||
|
||||
# Retrieve stored events
|
||||
events = bucket.get()
|
||||
for e in events:
|
||||
# Query them one-by-one
|
||||
event = bucket.get_by_id(e.id)
|
||||
assert e == event
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
|
||||
def test_get_event_by_id_notfound(bucket_cm):
|
||||
"""Test fetching an ID that does not exist"""
|
||||
with bucket_cm as bucket:
|
||||
assert bucket.get_by_id(1337 * 10**6) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
|
||||
def test_get_event_trimming(bucket_cm):
|
||||
"""Test that event trimming works correctly (when querying events that intersect with the query range)"""
|
||||
# TODO: Trimming should be possible to disable
|
||||
# (needed in raw data view, among other places where event editing is permitted)
|
||||
|
||||
with bucket_cm as bucket:
|
||||
if not isinstance(bucket.ds.storage_strategy, PeeweeStorage):
|
||||
pytest.skip("Trimming not supported for datastore")
|
||||
|
||||
eventcount = 2
|
||||
# Create 1-day long events
|
||||
events = [
|
||||
Event(timestamp=now + i * td1d, duration=td1d) for i in range(eventcount)
|
||||
]
|
||||
bucket.insert(events)
|
||||
|
||||
# Result should contain half of each event
|
||||
fetched_events = bucket.get(
|
||||
-1,
|
||||
starttime=now + td1d / 2,
|
||||
endtime=now + 1.5 * td1d,
|
||||
)
|
||||
assert 2 == len(fetched_events)
|
||||
total_duration = sum((e.duration for e in fetched_events), timedelta())
|
||||
assert td1d == timedelta(seconds=round(total_duration.total_seconds()))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
|
||||
def test_get_datefilter_start(bucket_cm):
|
||||
"""
|
||||
Tests the datetimefilter when fetching events
|
||||
"""
|
||||
with bucket_cm as bucket:
|
||||
eventcount = 10
|
||||
events = [
|
||||
Event(timestamp=now + i * td1s, duration=td1s) for i in range(eventcount)
|
||||
]
|
||||
bucket.insert(events)
|
||||
|
||||
# Starttime
|
||||
for i in range(eventcount):
|
||||
fetched_events = bucket.get(-1, starttime=events[i].timestamp + 0.01 * td1s)
|
||||
assert eventcount - i == len(fetched_events)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
|
||||
def test_get_datefilter_end(bucket_cm):
|
||||
"""
|
||||
Tests the datetimefilter when fetching events
|
||||
"""
|
||||
with bucket_cm as bucket:
|
||||
eventcount = 10
|
||||
events = [
|
||||
Event(timestamp=now + i * td1s, duration=td1s) for i in range(eventcount)
|
||||
]
|
||||
bucket.insert(events)
|
||||
|
||||
# Endtime
|
||||
for i in range(eventcount):
|
||||
fetched_events = bucket.get(-1, endtime=events[i].timestamp - 0.01 * td1s)
|
||||
assert i == len(fetched_events)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
|
||||
def test_get_datefilter_both(bucket_cm):
|
||||
"""
|
||||
Tests the datetimefilter when fetching events
|
||||
"""
|
||||
with bucket_cm as bucket:
|
||||
eventcount = 10
|
||||
events = [
|
||||
Event(timestamp=now + i * td1s, duration=td1s) for i in range(eventcount)
|
||||
]
|
||||
bucket.insert(events)
|
||||
|
||||
# Both
|
||||
for i in range(eventcount):
|
||||
for j in range(i + 1, eventcount):
|
||||
fetched_events = bucket.get(
|
||||
starttime=events[i].timestamp + timedelta(seconds=0.01),
|
||||
endtime=events[j].timestamp
|
||||
+ events[j].duration
|
||||
- timedelta(seconds=0.01),
|
||||
)
|
||||
assert j - i + 1 == len(fetched_events)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
|
||||
def test_insert_invalid(bucket_cm):
|
||||
with bucket_cm as bucket:
|
||||
event = "not a real event"
|
||||
with pytest.raises(TypeError):
|
||||
bucket.insert(event)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
|
||||
def test_replace(bucket_cm):
|
||||
"""
|
||||
Tests the replace event event in bucket functionality
|
||||
"""
|
||||
with bucket_cm as bucket:
|
||||
# Create two events
|
||||
e1 = bucket.insert(Event(data={"label": "test1"}, timestamp=now))
|
||||
assert e1
|
||||
assert e1.id is not None
|
||||
e2 = bucket.insert(
|
||||
Event(data={"label": "test2"}, timestamp=now + timedelta(seconds=1))
|
||||
)
|
||||
assert e2
|
||||
assert e2.id is not None
|
||||
|
||||
e1.data["label"] = "test1-replaced"
|
||||
bucket.replace(e1.id, e1)
|
||||
|
||||
bucket.insert(
|
||||
Event(data={"label": "test3"}, timestamp=now + timedelta(seconds=2))
|
||||
)
|
||||
|
||||
e2.data["label"] = "test2-replaced"
|
||||
bucket.replace(e2.id, e2)
|
||||
|
||||
# Assert length
|
||||
assert 3 == len(bucket.get(-1))
|
||||
assert bucket.get(-1)[0]["data"]["label"] == "test3"
|
||||
assert bucket.get(-1)[1]["data"]["label"] == "test2-replaced"
|
||||
assert bucket.get(-1)[2]["data"]["label"] == "test1-replaced"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
|
||||
def test_replace_last(bucket_cm):
|
||||
"""
|
||||
Tests the replace last event in bucket functionality (simple)
|
||||
"""
|
||||
with bucket_cm as bucket:
|
||||
# Create two events
|
||||
bucket.insert(Event(data={"label": "test1"}, timestamp=now))
|
||||
bucket.insert(
|
||||
Event(data={"label": "test2"}, timestamp=now + timedelta(seconds=1))
|
||||
)
|
||||
# Create second event to replace with the second one
|
||||
bucket.replace_last(
|
||||
Event(
|
||||
data={"label": "test2-replaced"}, timestamp=now + timedelta(seconds=1)
|
||||
)
|
||||
)
|
||||
bucket.insert(
|
||||
Event(data={"label": "test3"}, timestamp=now + timedelta(seconds=2))
|
||||
)
|
||||
# Assert data
|
||||
result = bucket.get(-1)
|
||||
assert 3 == len(result)
|
||||
assert result[1]["data"]["label"] == "test2-replaced"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
|
||||
def test_replace_last_complex(bucket_cm):
|
||||
"""
|
||||
Tests the replace last event in bucket functionality (complex)
|
||||
"""
|
||||
with bucket_cm as bucket:
|
||||
# Create first event
|
||||
event1 = Event(data={"label": "test1"}, timestamp=now, duration=timedelta(1))
|
||||
bucket.insert(event1)
|
||||
eventcount = len(bucket.get(-1))
|
||||
# Create second event to replace with the first one
|
||||
event2 = Event(
|
||||
data={"label": "test2"},
|
||||
duration=timedelta(0),
|
||||
timestamp=now + timedelta(seconds=1),
|
||||
)
|
||||
bucket.replace_last(event2)
|
||||
# Assert length and content
|
||||
result = bucket.get(-1)
|
||||
assert eventcount == len(result)
|
||||
assert event2 == result[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
|
||||
def test_get_last(bucket_cm):
|
||||
"""
|
||||
Tests setting the result limit when fetching events
|
||||
"""
|
||||
now = datetime.now()
|
||||
second = timedelta(seconds=1)
|
||||
with bucket_cm as bucket:
|
||||
events = [
|
||||
Event(data={"label": "test"}, timestamp=ts, duration=timedelta(0))
|
||||
for ts in [now + second, now + second * 2, now + second * 3]
|
||||
]
|
||||
|
||||
for event in events:
|
||||
bucket.insert(event)
|
||||
|
||||
assert bucket.get(limit=1)[0] == events[-1]
|
||||
for event in bucket.get(limit=5):
|
||||
print(event.timestamp, event.data["label"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
|
||||
def test_limit(bucket_cm):
|
||||
"""
|
||||
Tests setting the result limit when fetching events
|
||||
"""
|
||||
with bucket_cm as bucket:
|
||||
for _ in range(5):
|
||||
bucket.insert(Event(timestamp=now))
|
||||
|
||||
assert 0 == len(bucket.get(limit=0))
|
||||
assert 1 == len(bucket.get(limit=1))
|
||||
assert 3 == len(bucket.get(limit=3))
|
||||
assert 5 == len(bucket.get(limit=5))
|
||||
assert 5 == len(bucket.get(limit=-1))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
|
||||
def test_get_metadata(bucket_cm):
|
||||
"""
|
||||
Tests the get_metadata function
|
||||
"""
|
||||
with bucket_cm as bucket:
|
||||
print(bucket.ds.storage_strategy)
|
||||
metadata = bucket.metadata()
|
||||
print(metadata)
|
||||
assert "created" in metadata
|
||||
assert "client" in metadata
|
||||
assert "hostname" in metadata
|
||||
assert "id" in metadata
|
||||
assert "name" in metadata
|
||||
assert "type" in metadata
|
||||
with pytest.raises(ValueError):
|
||||
bucket.metadata()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bucket_cm", param_testing_buckets_cm())
|
||||
def test_get_eventcount(bucket_cm):
|
||||
"""
|
||||
Tests the get_eventcount function
|
||||
"""
|
||||
with bucket_cm as bucket:
|
||||
print(bucket.ds.storage_strategy)
|
||||
assert bucket.get_eventcount() == 0
|
||||
for _ in range(5):
|
||||
bucket.insert(Event(timestamp=now))
|
||||
assert bucket.get_eventcount() == 5
|
||||
# TODO: Test with timestamps and start/endtime filtering
|
||||
|
||||
bucket.insert(Event(timestamp=now + timedelta(seconds=5)))
|
||||
assert (
|
||||
bucket.get_eventcount(starttime=now - timedelta(seconds=1), endtime=now)
|
||||
== 5
|
||||
)
|
||||
assert bucket.get_eventcount(endtime=now + timedelta(seconds=1)) == 5
|
||||
assert bucket.get_eventcount(starttime=now + timedelta(seconds=1)) == 1
|
||||
201
tests/test_dirs.py
Normal file
201
tests/test_dirs.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""Tests for profile isolation via AW_PROFILE in aw_core.dirs.
|
||||
|
||||
Three profiles (default / testing / research) must yield completely disjoint
|
||||
directory roots so that an ActivityWatch research build cannot read from or
|
||||
write to a participant's personal datastore.
|
||||
|
||||
The ``testing`` profile additionally follows the new-root-plus-legacy-fallback
|
||||
rule from ActivityWatch/activitywatch#1399.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from aw_core.dirs import (
|
||||
_get_appname,
|
||||
get_cache_dir,
|
||||
get_config_dir,
|
||||
get_data_dir,
|
||||
legacy_testing_suffix,
|
||||
using_legacy_testing_root,
|
||||
)
|
||||
|
||||
from . import context # noqa: F401
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_dirs(tmp_path, monkeypatch):
|
||||
"""Point platformdirs at a tmp tree so tests never touch the real home."""
|
||||
data = tmp_path / "data"
|
||||
config = tmp_path / "config"
|
||||
cache = tmp_path / "cache"
|
||||
|
||||
def _join(root: Path, appname: str) -> str:
|
||||
return str(root / appname)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"aw_core.dirs.platformdirs.user_data_dir",
|
||||
lambda appname, *a, **k: _join(data, appname),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aw_core.dirs.platformdirs.user_config_dir",
|
||||
lambda appname, *a, **k: _join(config, appname),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aw_core.dirs.platformdirs.user_cache_dir",
|
||||
lambda appname, *a, **k: _join(cache, appname),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aw_core.dirs.platformdirs.user_cache_path",
|
||||
lambda appname, *a, **k: cache / appname,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aw_core.dirs.platformdirs.user_log_dir",
|
||||
lambda appname, *a, **k: str(cache / appname / "log"),
|
||||
)
|
||||
monkeypatch.delenv("AW_PROFILE", raising=False)
|
||||
return {"data": data, "config": config, "cache": cache, "root": tmp_path}
|
||||
|
||||
|
||||
def _plant_legacy_testing_db(fake_dirs) -> Path:
|
||||
aw_server = fake_dirs["data"] / "activitywatch" / "aw-server"
|
||||
aw_server.mkdir(parents=True)
|
||||
marker = aw_server / "peewee-sqlite-testing.v2.db"
|
||||
marker.write_text("")
|
||||
return marker
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _get_appname
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetAppname:
|
||||
def test_unset_returns_bare_name(self, fake_dirs):
|
||||
"""AW_PROFILE absent → bare 'activitywatch', identical to legacy."""
|
||||
with patch.dict(os.environ, {"AW_PROFILE": ""}):
|
||||
assert _get_appname() == "activitywatch"
|
||||
|
||||
def test_empty_string_returns_bare_name(self, fake_dirs):
|
||||
"""AW_PROFILE='' is treated the same as unset."""
|
||||
with patch.dict(os.environ, {"AW_PROFILE": ""}):
|
||||
assert _get_appname() == "activitywatch"
|
||||
|
||||
def test_testing_profile_fresh_uses_new_root(self, fake_dirs):
|
||||
with patch.dict(os.environ, {"AW_PROFILE": "testing"}):
|
||||
assert _get_appname() == "activitywatch-testing"
|
||||
|
||||
def test_research_profile(self, fake_dirs):
|
||||
with patch.dict(os.environ, {"AW_PROFILE": "research"}):
|
||||
assert _get_appname() == "activitywatch-research"
|
||||
|
||||
def test_arbitrary_profile(self, fake_dirs):
|
||||
with patch.dict(os.environ, {"AW_PROFILE": "myproject"}):
|
||||
assert _get_appname() == "activitywatch-myproject"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# testing-root fallback (ActivityWatch/activitywatch#1399)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTestingRootFallback:
|
||||
def test_fresh_setup_uses_new_root(self, fake_dirs):
|
||||
with patch.dict(os.environ, {"AW_PROFILE": "testing"}):
|
||||
assert using_legacy_testing_root() is False
|
||||
assert _get_appname() == "activitywatch-testing"
|
||||
assert legacy_testing_suffix(True) == ""
|
||||
data = get_data_dir()
|
||||
assert "activitywatch-testing" in data
|
||||
assert (fake_dirs["data"] / "activitywatch-testing").is_dir()
|
||||
|
||||
def test_legacy_artifacts_keep_shared_root(self, fake_dirs):
|
||||
_plant_legacy_testing_db(fake_dirs)
|
||||
with patch.dict(os.environ, {"AW_PROFILE": "testing"}):
|
||||
assert using_legacy_testing_root() is True
|
||||
assert _get_appname() == "activitywatch"
|
||||
assert legacy_testing_suffix(True) == "-testing"
|
||||
data = get_data_dir()
|
||||
assert "activitywatch-testing" not in data
|
||||
assert data.endswith("activitywatch") or data.endswith("activitywatch/")
|
||||
assert not (fake_dirs["data"] / "activitywatch-testing").exists()
|
||||
|
||||
def test_new_root_wins_over_legacy_artifacts(self, fake_dirs):
|
||||
_plant_legacy_testing_db(fake_dirs)
|
||||
(fake_dirs["data"] / "activitywatch-testing").mkdir()
|
||||
with patch.dict(os.environ, {"AW_PROFILE": "testing"}):
|
||||
assert using_legacy_testing_root() is False
|
||||
assert _get_appname() == "activitywatch-testing"
|
||||
assert legacy_testing_suffix(True) == ""
|
||||
|
||||
def test_config_testing_toml_is_a_legacy_marker(self, fake_dirs):
|
||||
cfg = fake_dirs["config"] / "activitywatch"
|
||||
cfg.mkdir(parents=True)
|
||||
(cfg / "config-testing.toml").write_text("")
|
||||
with patch.dict(os.environ, {"AW_PROFILE": "testing"}):
|
||||
assert using_legacy_testing_root() is True
|
||||
assert _get_appname() == "activitywatch"
|
||||
|
||||
def test_testing_without_aw_profile_keeps_filename_suffix(self, fake_dirs):
|
||||
"""``testing=True`` with no profile still shares the default root."""
|
||||
assert legacy_testing_suffix(True) == "-testing"
|
||||
assert legacy_testing_suffix(False) == ""
|
||||
|
||||
def test_named_profile_never_suffixes_filenames(self, fake_dirs):
|
||||
with patch.dict(os.environ, {"AW_PROFILE": "research"}):
|
||||
assert legacy_testing_suffix(True) == ""
|
||||
assert legacy_testing_suffix(False) == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Directory isolation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _data_dir(profile: str) -> str:
|
||||
"""Return get_data_dir() under the given profile (empty string = default)."""
|
||||
with patch.dict(os.environ, {"AW_PROFILE": profile}):
|
||||
return get_data_dir()
|
||||
|
||||
|
||||
class TestDirsIsolation:
|
||||
"""Three profiles must yield fully disjoint directory roots."""
|
||||
|
||||
def test_default_has_no_profile_suffix(self, fake_dirs):
|
||||
d = _data_dir("")
|
||||
assert "activitywatch" in d
|
||||
# The bare appname must not contain a dash after "activitywatch"
|
||||
assert "activitywatch-" not in d
|
||||
|
||||
def test_testing_suffix_present(self, fake_dirs):
|
||||
assert "activitywatch-testing" in _data_dir("testing")
|
||||
|
||||
def test_research_suffix_present(self, fake_dirs):
|
||||
assert "activitywatch-research" in _data_dir("research")
|
||||
|
||||
def test_three_profiles_are_disjoint(self, fake_dirs):
|
||||
"""default, testing, research all produce distinct, non-nested paths."""
|
||||
default = _data_dir("")
|
||||
testing = _data_dir("testing")
|
||||
research = _data_dir("research")
|
||||
|
||||
dirs = {default, testing, research}
|
||||
assert len(dirs) == 3, f"Profiles are not disjoint: {dirs}"
|
||||
|
||||
for a in dirs:
|
||||
for b in dirs:
|
||||
if a != b:
|
||||
assert not a.startswith(b + os.sep), f"{a!r} is a subpath of {b!r}"
|
||||
|
||||
def test_config_dir_isolated(self, fake_dirs):
|
||||
with patch.dict(os.environ, {"AW_PROFILE": "research"}):
|
||||
cfg = get_config_dir()
|
||||
assert "activitywatch-research" in cfg
|
||||
|
||||
def test_cache_dir_isolated(self, fake_dirs):
|
||||
with patch.dict(os.environ, {"AW_PROFILE": "testing"}):
|
||||
cache = get_cache_dir()
|
||||
assert "activitywatch-testing" in cache
|
||||
41
tests/test_event.py
Normal file
41
tests/test_event.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from aw_core.models import Event
|
||||
|
||||
valid_timestamp = "1937-01-01T12:00:27.87+00:20"
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
td1s = timedelta(seconds=1)
|
||||
|
||||
|
||||
def test_create() -> None:
|
||||
Event(timestamp=now, duration=timedelta(hours=13, minutes=37), data={"key": "val"})
|
||||
Event(
|
||||
timestamp=valid_timestamp,
|
||||
duration=timedelta(hours=13, minutes=37),
|
||||
data={"key": "val"},
|
||||
)
|
||||
|
||||
|
||||
def test_json_serialization() -> None:
|
||||
e = Event(
|
||||
timestamp=now, duration=timedelta(hours=13, minutes=37), data={"key": "val"}
|
||||
)
|
||||
assert e == Event(**json.loads(e.to_json_str()))
|
||||
|
||||
|
||||
def test_set_invalid_duration() -> None:
|
||||
e = Event()
|
||||
with pytest.raises(TypeError):
|
||||
e.duration = "12" # type: ignore
|
||||
|
||||
|
||||
def test_sort() -> None:
|
||||
e1 = Event(timestamp=now)
|
||||
e2 = Event(timestamp=now + td1s)
|
||||
e_sorted = sorted([e2, e1])
|
||||
assert e_sorted[0] == e1
|
||||
assert e_sorted[1] == e2
|
||||
167
tests/test_flood.py
Normal file
167
tests/test_flood.py
Normal file
@@ -0,0 +1,167 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from aw_core.models import Event
|
||||
from aw_transform import flood
|
||||
|
||||
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
td1s = timedelta(seconds=1)
|
||||
|
||||
|
||||
def test_flood_differing_data_meet_at_gap_midpoint():
|
||||
events = [
|
||||
Event(timestamp=now, duration=10, data={"a": 0}),
|
||||
Event(timestamp=now + 14 * td1s, duration=5, data={"b": 1}),
|
||||
]
|
||||
|
||||
flooded = flood(events)
|
||||
|
||||
assert flooded == [
|
||||
Event(timestamp=now, duration=12, data={"a": 0}),
|
||||
Event(timestamp=now + 12 * td1s, duration=7, data={"b": 1}),
|
||||
]
|
||||
|
||||
|
||||
def test_flood_forward_merge():
|
||||
events = [
|
||||
Event(timestamp=now, duration=10),
|
||||
Event(timestamp=now + 15 * td1s, duration=5),
|
||||
]
|
||||
flooded = flood(events)
|
||||
assert len(flooded) == 1
|
||||
assert flooded[0].duration == timedelta(seconds=20)
|
||||
|
||||
|
||||
def test_flood_backward_merge():
|
||||
events = [
|
||||
Event(timestamp=now, duration=5),
|
||||
Event(timestamp=now + 10 * td1s, duration=10),
|
||||
]
|
||||
flooded = flood(events)
|
||||
assert len(flooded) == 1
|
||||
assert flooded[0].duration == timedelta(seconds=20)
|
||||
|
||||
|
||||
def test_flood_negative_gap_same_data():
|
||||
events = [
|
||||
Event(timestamp=now, duration=100, data={"a": 0}),
|
||||
Event(timestamp=now, duration=5, data={"a": 0}),
|
||||
]
|
||||
flooded = flood(events)
|
||||
total_duration = sum((e.duration for e in flooded), timedelta(0))
|
||||
assert len(flooded) == 1
|
||||
assert total_duration == timedelta(seconds=100)
|
||||
|
||||
|
||||
def test_flood_negative_gap_differing_data():
|
||||
events = [
|
||||
Event(timestamp=now, duration=5, data={"a": 0}),
|
||||
Event(timestamp=now, duration=100, data={"b": 1}),
|
||||
]
|
||||
flooded = flood(events)
|
||||
assert flooded == [events[1]]
|
||||
|
||||
|
||||
def test_flood_zero_duration_chain_does_not_leave_overlaps():
|
||||
events = [
|
||||
Event(timestamp=now, duration=0, data={"title": "first"}),
|
||||
Event(timestamp=now, duration=1, data={"title": "first"}),
|
||||
Event(timestamp=now, duration=1, data={"title": "second"}),
|
||||
]
|
||||
|
||||
flooded = flood(events)
|
||||
|
||||
assert flooded == [events[2]]
|
||||
assert all(
|
||||
previous.timestamp + previous.duration <= current.timestamp
|
||||
for previous, current in zip(flooded, flooded[1:])
|
||||
)
|
||||
|
||||
|
||||
def test_flood_normalization_preserves_non_overlapping_tail():
|
||||
events = [
|
||||
Event(timestamp=now, duration=10, data={"title": "first"}),
|
||||
Event(timestamp=now + 5 * td1s, duration=2, data={"title": "second"}),
|
||||
Event(timestamp=now + 12 * td1s, duration=1, data={"title": "third"}),
|
||||
]
|
||||
|
||||
flooded = flood(events)
|
||||
|
||||
assert flooded == [
|
||||
Event(timestamp=now, duration=5, data={"title": "first"}),
|
||||
Event(timestamp=now + 5 * td1s, duration=4.5, data={"title": "second"}),
|
||||
Event(timestamp=now + 9.5 * td1s, duration=3.5, data={"title": "third"}),
|
||||
]
|
||||
|
||||
|
||||
def test_flood_normalization_merges_same_data_after_zero_duration_event():
|
||||
events = [
|
||||
Event(timestamp=now, duration=0, data={"title": "zero"}),
|
||||
Event(timestamp=now, duration=10, data={"title": "same"}),
|
||||
Event(timestamp=now + 5 * td1s, duration=10, data={"title": "same"}),
|
||||
]
|
||||
|
||||
flooded = flood(events)
|
||||
|
||||
assert flooded == [Event(timestamp=now, duration=15, data={"title": "same"})]
|
||||
|
||||
|
||||
def test_flood_negative_small_gap_differing_data():
|
||||
events = [
|
||||
Event(timestamp=now, duration=100, data={"b": 1}),
|
||||
Event(timestamp=now + 99.99 * td1s, duration=100, data={"a": 0}),
|
||||
]
|
||||
flooded = flood(events)
|
||||
duration = sum((e.duration for e in flooded), timedelta(0))
|
||||
assert duration == timedelta(seconds=100 + 99.99)
|
||||
|
||||
|
||||
def test_flood_with_custom_pulsetime():
|
||||
# Events with a 30s gap between them (simulating 30s poll_time)
|
||||
events = [
|
||||
Event(timestamp=now, duration=5, data={"a": 0}),
|
||||
Event(timestamp=now + 35 * td1s, duration=5, data={"b": 1}),
|
||||
]
|
||||
|
||||
# Default pulsetime=5: gap (30s) > pulsetime, so no flooding
|
||||
flooded_default = flood(events)
|
||||
assert len(flooded_default) == 2
|
||||
total_duration_default = sum((e.duration for e in flooded_default), timedelta(0))
|
||||
assert total_duration_default == timedelta(seconds=10)
|
||||
|
||||
# pulsetime=31: gap (30s) <= pulsetime, so both events extend to midpoint
|
||||
flooded_custom = flood(events, pulsetime=31)
|
||||
assert flooded_custom == [
|
||||
Event(timestamp=now, duration=20, data={"a": 0}),
|
||||
Event(timestamp=now + 20 * td1s, duration=20, data={"b": 1}),
|
||||
]
|
||||
|
||||
|
||||
def test_flood_idempotent():
|
||||
"""flood() applied repeatedly should produce the same result."""
|
||||
events = [
|
||||
# slight overlap, same data
|
||||
Event(timestamp=now, duration=10, data={"a": 0}),
|
||||
Event(timestamp=now + 9 * td1s, duration=5, data={"a": 0}),
|
||||
# different data, no overlap
|
||||
Event(timestamp=now + 15 * td1s, duration=5, data={"b": 0}),
|
||||
]
|
||||
flood_first = flood(events, pulsetime=0)
|
||||
flooded = flood_first
|
||||
for _ in range(2):
|
||||
flooded = flood(flooded, pulsetime=0)
|
||||
assert flood_first == flooded
|
||||
|
||||
assert sum((e.duration for e in flooded), timedelta(0)) == 19 * td1s
|
||||
|
||||
|
||||
def test_flood_unsafe_gap():
|
||||
"""Overlapping events with differing data must not double-count time."""
|
||||
events = [
|
||||
Event(timestamp=now, duration=10, data={"a": 0}),
|
||||
Event(timestamp=now + 9 * td1s, duration=5, data={"b": 0}),
|
||||
]
|
||||
flooded = flood(events, pulsetime=0)
|
||||
|
||||
# Total duration must not exceed the span covered by the two events
|
||||
assert sum((e.duration for e in flooded), timedelta(0)) == 14 * td1s
|
||||
84
tests/test_heartbeat.py
Normal file
84
tests/test_heartbeat.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from aw_core.models import Event
|
||||
from aw_transform import heartbeat_merge, heartbeat_reduce
|
||||
|
||||
|
||||
def test_heartbeat_merge():
|
||||
"""Events should merge"""
|
||||
now = datetime.now()
|
||||
td_1s = timedelta(seconds=1)
|
||||
|
||||
last_event, heartbeat = Event(timestamp=now), Event(timestamp=now + td_1s)
|
||||
merged = heartbeat_merge(last_event, heartbeat, pulsetime=2)
|
||||
assert merged is not None
|
||||
|
||||
|
||||
def test_heartbeat_merge_fail():
|
||||
"""Merge should not happen"""
|
||||
now = datetime.now()
|
||||
td_1s = timedelta(seconds=1)
|
||||
|
||||
# timestamp of heartbeat more than pulsetime away
|
||||
last_event, heartbeat = (
|
||||
Event(timestamp=now, data={"label": "test"}),
|
||||
Event(timestamp=now + 3 * td_1s, data={"label": "test"}),
|
||||
)
|
||||
merged = heartbeat_merge(last_event, heartbeat, pulsetime=2)
|
||||
assert merged is None
|
||||
|
||||
# labels not identical
|
||||
last_event, heartbeat = (
|
||||
Event(timestamp=now, data={"label": "test"}),
|
||||
Event(timestamp=now + td_1s, data={"label": "test2"}),
|
||||
)
|
||||
merged = heartbeat_merge(last_event, heartbeat, pulsetime=2)
|
||||
assert merged is None
|
||||
|
||||
|
||||
def test_heartbeat_reduce():
|
||||
"""Events should reduce"""
|
||||
now = datetime.now()
|
||||
td_1s = timedelta(seconds=1)
|
||||
|
||||
# Check that empty list works
|
||||
assert not heartbeat_reduce([], pulsetime=1)
|
||||
|
||||
events = [
|
||||
Event(timestamp=now, data={"label": "test"}),
|
||||
Event(timestamp=now + td_1s, data={"label": "test"}),
|
||||
]
|
||||
reduced_events = heartbeat_reduce(events, pulsetime=2)
|
||||
assert len(reduced_events) == 1
|
||||
|
||||
|
||||
def test_heartbeat_same_timestamp():
|
||||
now = datetime.now()
|
||||
td_1s = timedelta(seconds=1)
|
||||
td_0s = timedelta(seconds=0)
|
||||
|
||||
e1 = Event(timestamp=now, duration=td_1s, data={"label": "test"})
|
||||
e2 = Event(timestamp=now, duration=td_0s, data={"label": "test"})
|
||||
|
||||
# Should merge
|
||||
res = heartbeat_reduce(list([e1, e2]), pulsetime=5)
|
||||
assert len(res) == 1
|
||||
assert res[0].duration == td_1s
|
||||
|
||||
# Order shouldn't matter, should merge
|
||||
res = heartbeat_reduce([e2, e1], pulsetime=5)
|
||||
assert len(res) == 1
|
||||
assert res[0].duration == td_1s
|
||||
|
||||
|
||||
def test_heartbeat_reduce_fail():
|
||||
"""Events should not reduce"""
|
||||
now = datetime.now()
|
||||
td_1s = timedelta(seconds=1)
|
||||
|
||||
events = [
|
||||
Event(timestamp=now, data={"label": "test"}),
|
||||
Event(timestamp=now + 3 * td_1s, data={"label": "test"}),
|
||||
]
|
||||
reduced_events = heartbeat_reduce(events, pulsetime=2)
|
||||
assert len(reduced_events) == 2
|
||||
66
tests/test_peewee.py
Normal file
66
tests/test_peewee.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
Peewee-storage-specific tests, mainly for the schema/index migration in
|
||||
auto_migrate() and for guarding against query-plan regressions.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from aw_datastore.storages import PeeweeStorage
|
||||
from aw_datastore.storages import peewee as peewee_storage
|
||||
|
||||
from . import context # noqa: F401
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
|
||||
def _indexes(db) -> set:
|
||||
return {row[1] for row in db.execute_sql("PRAGMA index_list(eventmodel)")}
|
||||
|
||||
|
||||
def test_index_migration():
|
||||
"""auto_migrate() replaces the old single-column indexes with the composite one."""
|
||||
storage = PeeweeStorage(testing=True)
|
||||
db = storage.db
|
||||
path = db.database
|
||||
|
||||
assert "eventmodel_bucket_id_timestamp" in _indexes(db)
|
||||
|
||||
# Recreate the pre-migration state: single-column indexes, no composite
|
||||
db.execute_sql("DROP INDEX eventmodel_bucket_id_timestamp")
|
||||
db.execute_sql("CREATE INDEX eventmodel_bucket_id ON eventmodel (bucket_id)")
|
||||
db.execute_sql("CREATE INDEX eventmodel_timestamp ON eventmodel (timestamp)")
|
||||
|
||||
peewee_storage.auto_migrate(path)
|
||||
|
||||
indexes = _indexes(db)
|
||||
assert "eventmodel_bucket_id_timestamp" in indexes
|
||||
assert "eventmodel_bucket_id" not in indexes
|
||||
assert "eventmodel_timestamp" not in indexes
|
||||
|
||||
|
||||
def test_query_plan_uses_composite_index():
|
||||
"""
|
||||
The last-event query (issued by replace_last on every merged heartbeat) and
|
||||
range queries must be served by the composite index in a single pass.
|
||||
|
||||
Without it, SQLite reads *all* of a bucket's rows and sorts them in a temp
|
||||
B-tree even for LIMIT 1, which on multi-million-event buckets meant seconds
|
||||
of CPU/IO per heartbeat (and terabytes read per day).
|
||||
"""
|
||||
storage = PeeweeStorage(testing=True)
|
||||
db = storage.db
|
||||
|
||||
queries = [
|
||||
# replace_last/_get_last
|
||||
"SELECT * FROM eventmodel WHERE bucket_id = 1"
|
||||
" ORDER BY timestamp DESC LIMIT 1",
|
||||
# get_events with a time range
|
||||
"SELECT * FROM eventmodel WHERE bucket_id = 1"
|
||||
" AND timestamp >= '2026-01-01' AND timestamp <= '2026-01-02'"
|
||||
" ORDER BY timestamp DESC LIMIT 100",
|
||||
]
|
||||
for query in queries:
|
||||
rows = db.execute_sql(f"EXPLAIN QUERY PLAN {query}").fetchall()
|
||||
plan = "\n".join(str(row) for row in rows)
|
||||
assert "eventmodel_bucket_id_timestamp" in plan, plan
|
||||
assert "TEMP B-TREE" not in plan, plan
|
||||
715
tests/test_query2.py
Normal file
715
tests/test_query2.py
Normal file
@@ -0,0 +1,715 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict
|
||||
|
||||
import iso8601
|
||||
import pytest
|
||||
from aw_core.models import Event
|
||||
from aw_datastore.storages import MemoryStorage
|
||||
from aw_query import query
|
||||
from aw_query.exceptions import (
|
||||
QueryFunctionException,
|
||||
QueryInterpretException,
|
||||
QueryParseException,
|
||||
)
|
||||
from aw_query.query2 import (
|
||||
QDict,
|
||||
QFunction,
|
||||
QInteger,
|
||||
QList,
|
||||
QString,
|
||||
QVariable,
|
||||
_parse_token,
|
||||
_split_query_statements,
|
||||
)
|
||||
|
||||
from .utils import param_datastore_objects
|
||||
|
||||
|
||||
class MockDatastore(MemoryStorage):
|
||||
pass
|
||||
|
||||
|
||||
mock_ds = MockDatastore(testing=True)
|
||||
|
||||
|
||||
def test_query2_test_token_parsing():
|
||||
ns: Dict[str, Any] = {}
|
||||
(t, token), trash = _parse_token("123", ns)
|
||||
assert token == "123"
|
||||
assert t == QInteger
|
||||
(t, token), trash = _parse_token('"test"', ns)
|
||||
assert token == '"test"'
|
||||
assert t == QString
|
||||
(t, token), trash = _parse_token("'test'", ns)
|
||||
assert token == "'test'"
|
||||
assert t == QString
|
||||
(t, token), trash = _parse_token("'te\\'st'", ns)
|
||||
assert token == "'te\\'st'"
|
||||
assert t == QString
|
||||
(t, token), trash = _parse_token('"te\\"st"', ns)
|
||||
assert token == '"te\\"st"'
|
||||
assert t == QString
|
||||
(t, token), trash = _parse_token("test0xDEADBEEF", ns)
|
||||
assert token == "test0xDEADBEEF"
|
||||
assert t == QVariable
|
||||
(t, token), trash = _parse_token("test1337(')')", ns)
|
||||
assert token == "test1337(')')"
|
||||
assert t == QFunction
|
||||
(t, token), trash = _parse_token("test1337('test\\'test',\"test\\\"test\")", ns)
|
||||
assert token == "test1337('test\\'test',\"test\\\"test\")"
|
||||
assert t == QFunction
|
||||
(t, token), trash = _parse_token("[1, 'a', {}]", ns)
|
||||
assert token == "[1, 'a', {}]"
|
||||
assert t == QList
|
||||
(t, token), trash = _parse_token("{'a': 1, 'b}': 2}", ns)
|
||||
assert token == "{'a': 1, 'b}': 2}"
|
||||
assert t == QDict
|
||||
|
||||
assert _parse_token("", ns) == ((None, ""), "")
|
||||
|
||||
with pytest.raises(QueryParseException):
|
||||
_parse_token(None, ns) # type: ignore
|
||||
|
||||
with pytest.raises(QueryParseException):
|
||||
_parse_token('"', ns)
|
||||
|
||||
with pytest.raises(QueryParseException):
|
||||
_parse_token("#", ns)
|
||||
|
||||
|
||||
def test_dict():
|
||||
ds = mock_ds
|
||||
ns: Dict[str, Any] = {}
|
||||
d_str = "{'a': {'a': {'a': 1}}, 'b': {'b\\'\"': ':'}}"
|
||||
d = QDict.parse(d_str, ns)
|
||||
expected_res = {"a": {"a": {"a": 1}}, "b": {"b'\"": ":"}}
|
||||
assert expected_res == d.interpret(ds, ns)
|
||||
|
||||
# Key in dict is not a string
|
||||
with pytest.raises(QueryParseException):
|
||||
d_str = "{b: 1}"
|
||||
d = QDict.parse(d_str, ns)
|
||||
|
||||
# Key in dict without a value
|
||||
with pytest.raises(QueryParseException):
|
||||
d_str = "{'test': }"
|
||||
d = QDict.parse(d_str, ns)
|
||||
|
||||
# Char following key string is not a :
|
||||
with pytest.raises(QueryParseException):
|
||||
d_str = "{'test'p 1}"
|
||||
d = QDict.parse(d_str, ns)
|
||||
|
||||
with pytest.raises(QueryParseException):
|
||||
d_str = "{'test': #}"
|
||||
d = QDict.parse(d_str, ns)
|
||||
|
||||
# Semicolon without key
|
||||
with pytest.raises(QueryParseException):
|
||||
d_str = "{:}"
|
||||
d = QDict.parse(d_str, ns)
|
||||
|
||||
# Trailing comma
|
||||
with pytest.raises(QueryParseException):
|
||||
d_str = "{'test':1,}"
|
||||
d = QDict.parse(d_str, ns)
|
||||
|
||||
|
||||
def test_list():
|
||||
ds = mock_ds
|
||||
ns: Dict[str, Any] = {}
|
||||
l_str = "[1,2,[[3],4],5]"
|
||||
ls = QList.parse(l_str, ns)
|
||||
expected_res = [1, 2, [[3], 4], 5]
|
||||
assert expected_res == ls.interpret(ds, ns)
|
||||
|
||||
l_str = "['\\'',\"\\\"\"]"
|
||||
ls = QList.parse(l_str, ns)
|
||||
expected_res = ["'", '"']
|
||||
assert expected_res == ls.interpret(ds, ns)
|
||||
|
||||
l_str = "[]"
|
||||
ls = QList.parse(l_str, ns)
|
||||
expected_res = []
|
||||
assert expected_res == ls.interpret(ds, ns)
|
||||
|
||||
# Comma without pre/post value
|
||||
with pytest.raises(QueryParseException):
|
||||
l_str = "[,]"
|
||||
ls = QList.parse(l_str, ns)
|
||||
|
||||
# Comma without post value
|
||||
with pytest.raises(QueryParseException):
|
||||
l_str = "[1,]"
|
||||
ls = QList.parse(l_str, ns)
|
||||
|
||||
# Comma without pre value
|
||||
with pytest.raises(QueryParseException):
|
||||
l_str = "[,2]"
|
||||
ls = QList.parse(l_str, ns)
|
||||
|
||||
|
||||
def test_query2_bogus_query():
|
||||
ds = mock_ds
|
||||
qname = "test"
|
||||
qstartdate = datetime.now(tz=timezone.utc)
|
||||
qenddate = qstartdate
|
||||
|
||||
# Nothing to assign
|
||||
with pytest.raises(QueryParseException):
|
||||
example_query = "a="
|
||||
query(qname, example_query, qstartdate, qenddate, ds)
|
||||
|
||||
# Assign to non-variable
|
||||
with pytest.raises(QueryParseException):
|
||||
example_query = "1 = 2"
|
||||
query(qname, example_query, qstartdate, qenddate, ds)
|
||||
|
||||
# Unclosed function
|
||||
with pytest.raises(QueryParseException):
|
||||
example_query = "a = unclosed_function(var1"
|
||||
query(qname, example_query, qstartdate, qenddate, ds)
|
||||
|
||||
# Two tokens in assignment
|
||||
with pytest.raises(QueryParseException):
|
||||
example_query = "asd nop() = 2"
|
||||
query(qname, example_query, qstartdate, qenddate, ds)
|
||||
|
||||
# Unclosed string
|
||||
with pytest.raises(QueryParseException):
|
||||
example_query = 'asd = "something is wrong with me'
|
||||
query(qname, example_query, qstartdate, qenddate, ds)
|
||||
|
||||
# Two tokens in value
|
||||
with pytest.raises(QueryParseException):
|
||||
example_query = "asd = asd1 asd2"
|
||||
query(qname, example_query, qstartdate, qenddate, ds)
|
||||
|
||||
|
||||
def test_query2_query_function_calling():
|
||||
ds = mock_ds
|
||||
qname = "asd"
|
||||
starttime = iso8601.parse_date("1970-01-01")
|
||||
endtime = iso8601.parse_date("1970-01-02")
|
||||
|
||||
# Function which doesn't exist
|
||||
with pytest.raises(QueryInterpretException):
|
||||
example_query = "RETURN = asd();"
|
||||
query(qname, example_query, starttime, endtime, ds)
|
||||
|
||||
# Function which does exist with invalid arguments
|
||||
with pytest.raises(QueryInterpretException):
|
||||
example_query = "RETURN = nop(badarg);"
|
||||
query(qname, example_query, starttime, endtime, ds)
|
||||
|
||||
# Function which does exist with valid arguments
|
||||
example_query = "RETURN = nop();"
|
||||
query(qname, example_query, starttime, endtime, ds)
|
||||
|
||||
|
||||
def test_query2_return_value():
|
||||
ds = mock_ds
|
||||
qname = "asd"
|
||||
starttime = iso8601.parse_date("1970-01-01")
|
||||
endtime = iso8601.parse_date("1970-01-02")
|
||||
example_query = "RETURN = 1;"
|
||||
result = query(qname, example_query, starttime, endtime, ds)
|
||||
assert result == 1
|
||||
|
||||
example_query = "RETURN = 'testing 123'"
|
||||
result = query(qname, example_query, starttime, endtime, ds)
|
||||
assert result == "testing 123"
|
||||
|
||||
example_query = "RETURN = {'a': 1}"
|
||||
result = query(qname, example_query, starttime, endtime, ds)
|
||||
assert result == {"a": 1}
|
||||
|
||||
# Nothing to return
|
||||
with pytest.raises(QueryParseException):
|
||||
example_query = "a=1"
|
||||
result = query(qname, example_query, starttime, endtime, ds)
|
||||
|
||||
|
||||
def test_query2_semicolon_in_string():
|
||||
"""Test that semicolons inside string literals don't break query parsing (issue #145)."""
|
||||
ds = mock_ds
|
||||
qname = "asd"
|
||||
starttime = iso8601.parse_date("1970-01-01")
|
||||
endtime = iso8601.parse_date("1970-01-02")
|
||||
|
||||
# Semicolon in double-quoted string
|
||||
example_query = 'RETURN = "hello;world"'
|
||||
result = query(qname, example_query, starttime, endtime, ds)
|
||||
assert result == "hello;world"
|
||||
|
||||
# Semicolon in single-quoted string
|
||||
example_query = "RETURN = 'hello;world'"
|
||||
result = query(qname, example_query, starttime, endtime, ds)
|
||||
assert result == "hello;world"
|
||||
|
||||
# Multiple statements where one value contains a semicolon in a string
|
||||
example_query = 'a = "foo;bar"; RETURN = a'
|
||||
result = query(qname, example_query, starttime, endtime, ds)
|
||||
assert result == "foo;bar"
|
||||
|
||||
|
||||
def test_split_query_statements():
|
||||
"""Unit test for _split_query_statements."""
|
||||
# Basic split
|
||||
assert _split_query_statements("a=1;b=2") == ["a=1", "b=2"]
|
||||
# Semicolon inside double quotes should NOT split
|
||||
assert _split_query_statements('a="x;y";b=2') == ['a="x;y"', "b=2"]
|
||||
# Semicolon inside single quotes should NOT split
|
||||
assert _split_query_statements("a='x;y';b=2") == ["a='x;y'", "b=2"]
|
||||
# Trailing semicolon — empty trailing part is omitted
|
||||
assert _split_query_statements("a=1;") == ["a=1"]
|
||||
|
||||
|
||||
def test_query2_multiline():
|
||||
ds = mock_ds
|
||||
qname = "asd"
|
||||
starttime = iso8601.parse_date("1970-01-01")
|
||||
endtime = iso8601.parse_date("1970-01-02")
|
||||
example_query = """
|
||||
my_multiline_string = "a
|
||||
b";
|
||||
RETURN = my_multiline_string;
|
||||
"""
|
||||
result = query(qname, example_query, starttime, endtime, ds)
|
||||
assert result == "a\nb"
|
||||
|
||||
|
||||
def test_query2_function_invalid_types():
|
||||
"""Tests the q2_typecheck decorator"""
|
||||
ds = mock_ds
|
||||
qname = "asd"
|
||||
starttime = iso8601.parse_date("1970-01-01")
|
||||
endtime = iso8601.parse_date("1970-01-02")
|
||||
|
||||
# int instead of str
|
||||
example_query = """
|
||||
events = [];
|
||||
RETURN = filter_keyvals(events, 666, ["invalid_val"]);
|
||||
"""
|
||||
with pytest.raises(QueryFunctionException):
|
||||
query(qname, example_query, starttime, endtime, ds)
|
||||
|
||||
# str instead of list
|
||||
example_query = """
|
||||
events = [];
|
||||
RETURN = filter_keyvals(events, "2", "invalid_val");
|
||||
"""
|
||||
with pytest.raises(QueryFunctionException):
|
||||
query(qname, example_query, starttime, endtime, ds)
|
||||
|
||||
# FIXME: For unknown reasons, query2 drops the second argument
|
||||
# when the first argument is a bare []
|
||||
"""
|
||||
example_query = '''
|
||||
RETURN = filter_keyvals([], "2", "invalid_val");
|
||||
'''
|
||||
with pytest.raises(QueryFunctionException) as e:
|
||||
result = query(qname, example_query, starttime, endtime, None)
|
||||
"""
|
||||
|
||||
|
||||
def test_query2_function_invalid_argument_count():
|
||||
ds = mock_ds
|
||||
qname = "asd"
|
||||
starttime = iso8601.parse_date("1970-01-01")
|
||||
endtime = iso8601.parse_date("1970-01-02")
|
||||
example_query = "RETURN=nop(nop())"
|
||||
with pytest.raises(QueryInterpretException):
|
||||
query(qname, example_query, starttime, endtime, ds)
|
||||
|
||||
|
||||
def test_query2_merge_subwatcher_fields_invalid_conflict():
|
||||
ds = mock_ds
|
||||
qname = "asd"
|
||||
starttime = iso8601.parse_date("1970-01-01")
|
||||
endtime = iso8601.parse_date("1970-01-02")
|
||||
example_query = """
|
||||
base = [];
|
||||
sub = [];
|
||||
RETURN = merge_subwatcher_fields(base, sub, ["project"], "invalid");
|
||||
"""
|
||||
|
||||
with pytest.raises(QueryFunctionException, match="conflict must be"):
|
||||
query(qname, example_query, starttime, endtime, ds)
|
||||
|
||||
|
||||
def test_query2_categorize_invalid_priority():
|
||||
ds = mock_ds
|
||||
qname = "asd"
|
||||
starttime = iso8601.parse_date("1970-01-01")
|
||||
endtime = iso8601.parse_date("1970-01-02")
|
||||
example_query = """
|
||||
events = [];
|
||||
RETURN = categorize(events, [[["test"], {"regex": "test", "priority": "high"}]]);
|
||||
"""
|
||||
with pytest.raises(QueryFunctionException, match="integer"):
|
||||
query(qname, example_query, starttime, endtime, ds)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("datastore", param_datastore_objects())
|
||||
def test_query2_function_in_function(datastore):
|
||||
qname = "asd"
|
||||
bid = "test_bucket"
|
||||
starttime = iso8601.parse_date("1970-01-01")
|
||||
endtime = iso8601.parse_date("1970-01-02")
|
||||
example_query = f"""
|
||||
RETURN=limit_events(query_bucket("{bid}"), 1);
|
||||
"""
|
||||
try:
|
||||
# Setup buckets
|
||||
bucket1 = datastore.create_bucket(
|
||||
bucket_id=bid, type="test", client="test", hostname="test", name="test"
|
||||
)
|
||||
# Prepare buckets
|
||||
e1 = Event(data={}, timestamp=starttime, duration=timedelta(seconds=1))
|
||||
bucket1.insert(e1)
|
||||
result = query(qname, example_query, starttime, endtime, datastore)
|
||||
assert 1 == len(result)
|
||||
finally:
|
||||
datastore.delete_bucket(bid)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("datastore", param_datastore_objects())
|
||||
def test_query2_query_functions(datastore):
|
||||
"""
|
||||
Just test calling all functions just to see something isn't completely broken
|
||||
In many cases the functions doesn't change the result at all, so it's not a test
|
||||
for testing the validity of the data the functions transform
|
||||
"""
|
||||
bid = "test_'bucket"
|
||||
qname = "test"
|
||||
starttime = iso8601.parse_date("1970")
|
||||
endtime = starttime + timedelta(hours=1)
|
||||
|
||||
example_query = """
|
||||
bid = "{bid}";
|
||||
events = query_bucket("{bid}");
|
||||
events2 = query_bucket('{bid_escaped}');
|
||||
events2 = filter_keyvals(events2, "label", ["test1"]);
|
||||
events2 = exclude_keyvals(events2, "label", ["test2"]);
|
||||
events = filter_period_intersect(events, events2);
|
||||
events = filter_keyvals_regex(events, "label", ".*");
|
||||
events = limit_events(events, 1);
|
||||
events = merge_events_by_keys(events, ["label"]);
|
||||
events = chunk_events_by_key(events, "label");
|
||||
events = split_url_events(events);
|
||||
events = sort_by_timestamp(events);
|
||||
events = sort_by_duration(events);
|
||||
events = categorize(events, [[["test", "subtest"], {{"regex": "test1"}}]]);
|
||||
duration = sum_durations(events);
|
||||
eventcount = query_bucket_eventcount(bid);
|
||||
asd = nop();
|
||||
RETURN = {{"events": events, "eventcount": eventcount}};
|
||||
""".format(bid=bid, bid_escaped=bid.replace("'", "\\'"))
|
||||
try:
|
||||
bucket = datastore.create_bucket(
|
||||
bucket_id=bid, type="test", client="test", hostname="test", name="asd"
|
||||
)
|
||||
bucket.insert(
|
||||
Event(
|
||||
data={"label": "test1"},
|
||||
timestamp=starttime,
|
||||
duration=timedelta(seconds=1),
|
||||
)
|
||||
)
|
||||
result = query(qname, example_query, starttime, endtime, datastore)
|
||||
assert result["eventcount"] == 1
|
||||
assert len(result["events"]) == 1
|
||||
assert result["events"][0].data["label"] == "test1"
|
||||
assert result["events"][0].data["$category"] == ["test", "subtest"]
|
||||
finally:
|
||||
datastore.delete_bucket(bid)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("datastore", param_datastore_objects())
|
||||
def test_query2_basic_query(datastore):
|
||||
name = "A label/name for a test bucket"
|
||||
bid1 = "bucket1"
|
||||
bid2 = "bucket2"
|
||||
qname = "test_query_basic"
|
||||
starttime = iso8601.parse_date("1970")
|
||||
endtime = starttime + timedelta(hours=1)
|
||||
|
||||
example_query = f"""
|
||||
bid1 = "{bid1}";
|
||||
bid2 = "{bid2}";
|
||||
events = query_bucket(bid1);
|
||||
intersect_events = query_bucket(bid2);
|
||||
RETURN = filter_period_intersect(events, intersect_events);
|
||||
"""
|
||||
|
||||
try:
|
||||
# Setup buckets
|
||||
bucket1 = datastore.create_bucket(
|
||||
bucket_id=bid1, type="test", client="test", hostname="test", name=name
|
||||
)
|
||||
bucket2 = datastore.create_bucket(
|
||||
bucket_id=bid2, type="test", client="test", hostname="test", name=name
|
||||
)
|
||||
# Prepare buckets
|
||||
e1 = Event(
|
||||
data={"label": "test1"}, timestamp=starttime, duration=timedelta(seconds=1)
|
||||
)
|
||||
e2 = Event(
|
||||
data={"label": "test2"},
|
||||
timestamp=starttime + timedelta(seconds=2),
|
||||
duration=timedelta(seconds=1),
|
||||
)
|
||||
et = Event(
|
||||
data={"label": "intersect-label"},
|
||||
timestamp=starttime,
|
||||
duration=timedelta(seconds=1),
|
||||
)
|
||||
bucket1.insert(e1)
|
||||
bucket1.insert(e2)
|
||||
bucket2.insert(et)
|
||||
# Query
|
||||
result = query(qname, example_query, starttime, endtime, datastore)
|
||||
# Assert
|
||||
assert len(result) == 1
|
||||
assert result[0]["data"]["label"] == "test1"
|
||||
finally:
|
||||
datastore.delete_bucket(bid1)
|
||||
datastore.delete_bucket(bid2)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("datastore", param_datastore_objects())
|
||||
def test_query2_test_merged_keys(datastore):
|
||||
name = "A label/name for a test bucket"
|
||||
bid = "bucket1"
|
||||
qname = "test_query_merged_keys"
|
||||
starttime = iso8601.parse_date("2080")
|
||||
endtime = starttime + timedelta(hours=1)
|
||||
|
||||
example_query = f"""
|
||||
bid1 = "{bid}";
|
||||
events = query_bucket(bid1);
|
||||
events = merge_events_by_keys(events, ["label1", "label2"]);
|
||||
events = sort_by_duration(events);
|
||||
eventcount = query_bucket_eventcount(bid1);
|
||||
RETURN = {{"events": events, "eventcount": eventcount}};
|
||||
"""
|
||||
try:
|
||||
# Setup buckets
|
||||
bucket1 = datastore.create_bucket(
|
||||
bucket_id=bid, type="test", client="test", hostname="test", name=name
|
||||
)
|
||||
# Prepare buckets
|
||||
e1 = Event(
|
||||
data={"label1": "test1", "label2": "test1"},
|
||||
timestamp=starttime,
|
||||
duration=timedelta(seconds=1),
|
||||
)
|
||||
e2 = Event(
|
||||
data={"label1": "test1", "label2": "test1"},
|
||||
timestamp=starttime + timedelta(seconds=1),
|
||||
duration=timedelta(seconds=1),
|
||||
)
|
||||
e3 = Event(
|
||||
data={"label1": "test1", "label2": "test2"},
|
||||
timestamp=starttime + timedelta(seconds=2),
|
||||
duration=timedelta(seconds=1),
|
||||
)
|
||||
bucket1.insert(e3)
|
||||
bucket1.insert(e1)
|
||||
bucket1.insert(e2)
|
||||
# Query
|
||||
result = query(qname, example_query, starttime, endtime, datastore)
|
||||
# Assert
|
||||
print(result)
|
||||
assert len(result["events"]) == 2
|
||||
assert result["eventcount"] == 3
|
||||
assert result["events"][0]["data"]["label1"] == "test1"
|
||||
assert result["events"][0]["data"]["label2"] == "test1"
|
||||
assert result["events"][0]["duration"] == timedelta(seconds=2)
|
||||
assert result["events"][1]["data"]["label1"] == "test1"
|
||||
assert result["events"][1]["data"]["label2"] == "test2"
|
||||
assert result["events"][1]["duration"] == timedelta(seconds=1)
|
||||
finally:
|
||||
datastore.delete_bucket(bid)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("datastore", param_datastore_objects())
|
||||
def test_query2_fancy_query(datastore):
|
||||
"""
|
||||
Tests:
|
||||
- find_bucket
|
||||
- simplify_window_titles
|
||||
"""
|
||||
name = "A label/name for a test bucket"
|
||||
bid1 = "bucket-the-one"
|
||||
qname = "test_query_basic_fancy"
|
||||
starttime = iso8601.parse_date("1970")
|
||||
endtime = starttime + timedelta(hours=1)
|
||||
|
||||
example_query = f"""
|
||||
bid = find_bucket("{bid1[:10]}");
|
||||
events = query_bucket(bid);
|
||||
RETURN = simplify_window_titles(events, "title");
|
||||
"""
|
||||
|
||||
try:
|
||||
# Setup buckets
|
||||
bucket_main = datastore.create_bucket(
|
||||
bucket_id=bid1, type="test", client="test", hostname="test", name=name
|
||||
)
|
||||
# Prepare buckets
|
||||
e1 = Event(
|
||||
data={"title": "(2) YouTube"},
|
||||
timestamp=starttime,
|
||||
duration=timedelta(seconds=1),
|
||||
)
|
||||
bucket_main.insert(e1)
|
||||
# Query
|
||||
result = query(qname, example_query, starttime, endtime, datastore)
|
||||
# Assert
|
||||
assert result[0]["data"]["title"] == "YouTube"
|
||||
finally:
|
||||
datastore.delete_bucket(bid1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("datastore", param_datastore_objects())
|
||||
def test_query2_query_categorize(datastore):
|
||||
bid = "test_bucket"
|
||||
qname = "test"
|
||||
starttime = iso8601.parse_date("1970")
|
||||
endtime = starttime + timedelta(hours=1)
|
||||
|
||||
example_query = rf"""
|
||||
events = query_bucket("{bid}");
|
||||
events = sort_by_timestamp(events);
|
||||
events = categorize(events, [
|
||||
[["test"], {{"regex": "test"}}],
|
||||
[["test", "subtest"], {{"regex": "test\w"}}]
|
||||
]);
|
||||
events_by_cat = merge_events_by_keys(events, ["$category"]);
|
||||
RETURN = {{"events": events, "events_by_cat": events_by_cat}};
|
||||
"""
|
||||
try:
|
||||
bucket = datastore.create_bucket(
|
||||
bucket_id=bid, type="test", client="test", hostname="test", name="asd"
|
||||
)
|
||||
events = [
|
||||
Event(
|
||||
data={"label": "test"},
|
||||
timestamp=starttime,
|
||||
duration=timedelta(seconds=1),
|
||||
),
|
||||
Event(
|
||||
data={"label": "testwithmoredetail"},
|
||||
timestamp=starttime + timedelta(seconds=1),
|
||||
duration=timedelta(seconds=1),
|
||||
),
|
||||
Event(
|
||||
data={"label": "testwithmoredetail"},
|
||||
timestamp=starttime + timedelta(seconds=2),
|
||||
duration=timedelta(seconds=1),
|
||||
),
|
||||
]
|
||||
bucket.insert(events)
|
||||
result = query(qname, example_query, starttime, endtime, datastore)
|
||||
print(result)
|
||||
assert len(result["events"]) == 3
|
||||
assert result["events"][0].data["label"] == "test"
|
||||
assert result["events"][0].data["$category"] == ["test"]
|
||||
assert result["events"][1].data["$category"] == ["test", "subtest"]
|
||||
|
||||
assert len(result["events_by_cat"]) == 2
|
||||
assert result["events_by_cat"][0].data["$category"] == ["test"]
|
||||
assert result["events_by_cat"][1].data["$category"] == ["test", "subtest"]
|
||||
assert result["events_by_cat"][1].duration == timedelta(seconds=2)
|
||||
finally:
|
||||
datastore.delete_bucket(bid)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("datastore", param_datastore_objects())
|
||||
def test_query2_query_categorize_priority(datastore):
|
||||
bid = "test_bucket_priority"
|
||||
qname = "test"
|
||||
starttime = iso8601.parse_date("1970")
|
||||
endtime = starttime + timedelta(hours=1)
|
||||
|
||||
example_query = rf"""
|
||||
events = query_bucket("{bid}");
|
||||
events = categorize(events, [
|
||||
[["A"], {{"regex": "test", "priority": 25}}],
|
||||
[["B", "B1"], {{"regex": "test"}}]
|
||||
]);
|
||||
RETURN = events;
|
||||
"""
|
||||
try:
|
||||
bucket = datastore.create_bucket(
|
||||
bucket_id=bid, type="test", client="test", hostname="test", name="asd"
|
||||
)
|
||||
bucket.insert(
|
||||
Event(
|
||||
data={"label": "test"},
|
||||
timestamp=starttime,
|
||||
duration=timedelta(seconds=1),
|
||||
)
|
||||
)
|
||||
result = query(qname, example_query, starttime, endtime, datastore)
|
||||
assert len(result) == 1
|
||||
assert result[0].data["$category"] == ["A"]
|
||||
finally:
|
||||
datastore.delete_bucket(bid)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("datastore", param_datastore_objects())
|
||||
def test_query2_flood_with_pulsetime(datastore):
|
||||
"""Test that flood() accepts an optional pulsetime argument (fixes WorkReport query bug)."""
|
||||
bid = "test_flood_bucket"
|
||||
qname = "test_flood"
|
||||
starttime = iso8601.parse_date("1970")
|
||||
endtime = starttime + timedelta(hours=1)
|
||||
|
||||
try:
|
||||
bucket = datastore.create_bucket(
|
||||
bucket_id=bid,
|
||||
type="test",
|
||||
client="test",
|
||||
hostname="test",
|
||||
name="flood_test",
|
||||
)
|
||||
# Two events with a 60-second gap — flood with pulsetime=300 should merge them
|
||||
bucket.insert(
|
||||
Event(
|
||||
data={"app": "Firefox"},
|
||||
timestamp=starttime,
|
||||
duration=timedelta(seconds=10),
|
||||
)
|
||||
)
|
||||
bucket.insert(
|
||||
Event(
|
||||
data={"app": "Firefox"},
|
||||
timestamp=starttime + timedelta(seconds=70),
|
||||
duration=timedelta(seconds=10),
|
||||
)
|
||||
)
|
||||
|
||||
# flood with default pulsetime (5s) — gap is 60s, events should NOT merge
|
||||
result_default = query(
|
||||
qname,
|
||||
f'events = query_bucket("{bid}"); events = flood(events); RETURN = events;',
|
||||
starttime,
|
||||
endtime,
|
||||
datastore,
|
||||
)
|
||||
assert len(result_default) == 2
|
||||
|
||||
# flood with pulsetime=300 — gap is 60s < 300s, events SHOULD merge
|
||||
result_pulsetime = query(
|
||||
qname,
|
||||
f'events = query_bucket("{bid}"); events = flood(events, 300); RETURN = events;',
|
||||
starttime,
|
||||
endtime,
|
||||
datastore,
|
||||
)
|
||||
assert len(result_pulsetime) == 1
|
||||
finally:
|
||||
datastore.delete_bucket(bid)
|
||||
60
tests/test_schemas.py
Normal file
60
tests/test_schemas.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import unittest
|
||||
|
||||
from jsonschema import validate as _validate, FormatChecker
|
||||
from jsonschema.exceptions import ValidationError
|
||||
|
||||
from aw_core import schema
|
||||
from aw_core.models import Event
|
||||
|
||||
# TODO: Include date-time format
|
||||
# https://python-jsonschema.readthedocs.io/en/latest/validate/#jsonschema.FormatChecker
|
||||
|
||||
# The default FormatChecker, uses the date-time checker
|
||||
fc = FormatChecker(["date-time"])
|
||||
|
||||
valid_timestamp = "1937-01-01T12:00:27.87+00:20"
|
||||
|
||||
|
||||
event_schema = schema.get_json_schema("event")
|
||||
|
||||
|
||||
class EventSchemaTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.schema = event_schema
|
||||
|
||||
def validate(self, obj):
|
||||
_validate(obj, self.schema, format_checker=fc)
|
||||
|
||||
def test_event(self):
|
||||
event = Event(timestamp=valid_timestamp, data={"label": "test"})
|
||||
self.validate(event.to_json_dict())
|
||||
|
||||
def test_data(self):
|
||||
self.validate(
|
||||
{"timestamp": valid_timestamp, "data": {"label": "test", "number": 1.1}}
|
||||
)
|
||||
|
||||
def test_timestamp(self):
|
||||
self.validate({"timestamp": valid_timestamp})
|
||||
|
||||
def test_timestamp_invalid_string(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
self.validate({"timestamp": ""})
|
||||
with self.assertRaises(ValidationError):
|
||||
self.validate({"timestamp": "123"})
|
||||
|
||||
def test_timestamp_invalid_number(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
self.validate({"timestamp": 2})
|
||||
|
||||
def test_duration(self):
|
||||
self.validate({"timestamp": valid_timestamp, "duration": 1000})
|
||||
self.validate({"timestamp": valid_timestamp, "duration": 3.13})
|
||||
|
||||
def test_duration_invalid_string(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
self.validate({"timestamp": valid_timestamp, "duration": "not a number"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
830
tests/test_transforms.py
Normal file
830
tests/test_transforms.py
Normal file
@@ -0,0 +1,830 @@
|
||||
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")
|
||||
52
tests/utils.py
Normal file
52
tests/utils.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from aw_datastore import Datastore, get_storage_methods
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
# Useful when you just want some placeholder time in your events, saves typing
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class TempTestBucket:
|
||||
"""Context manager for creating a test bucket"""
|
||||
|
||||
def __init__(self, datastore):
|
||||
self.ds = datastore
|
||||
self.bucket_id = f"test-{random.randint(0, 10**4)}"
|
||||
|
||||
def __enter__(self):
|
||||
self.ds.create_bucket(
|
||||
bucket_id=self.bucket_id,
|
||||
type="testtype",
|
||||
client="testclient",
|
||||
hostname="testhost",
|
||||
name="testname",
|
||||
)
|
||||
return self.ds[self.bucket_id]
|
||||
|
||||
def __exit__(self, *_):
|
||||
self.ds.delete_bucket(bucket_id=self.bucket_id)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TempTestBucket using {self.ds.storage_strategy.__class__.__name__}>"
|
||||
|
||||
|
||||
_storage_methods = get_storage_methods()
|
||||
|
||||
|
||||
def param_datastore_objects():
|
||||
return [
|
||||
Datastore(storage_strategy=strategy, testing=True)
|
||||
for name, strategy in _storage_methods.items()
|
||||
]
|
||||
|
||||
|
||||
def param_testing_buckets_cm():
|
||||
datastores = [
|
||||
Datastore(storage_strategy=strategy, testing=True)
|
||||
for name, strategy in _storage_methods.items()
|
||||
]
|
||||
return [TempTestBucket(ds) for ds in datastores]
|
||||
Reference in New Issue
Block a user