Phase 4: kids' Windows computer client (AUD-97)
Adds windows/, the Python client for the kids' Windows computers, and proves both halves of the Phase 4 definition of done on the real box (DESKTOP-TE2RHV2). The client: - subscribes, retained, to its own config on the 8883 TLS device plane with its own broker account (FR-3, FR-6); - keeps the last valid config on disk and loads it before connecting, so a broker outage changes nothing (FR-14); - terminates every process on the blocklist while the device is effectively locked, with the allowlist winning and the client's own tree plus critical system processes protected (FR-4, FR-16); - publishes presence and daily per-app usage; the day's totals are held on disk and republished when the link returns (FR-7); - shows an always-on-top warning window naming what is blocked, why and until when (FR-9). install-scheduled-task.ps1 registers the logon Scheduled Task for the kid's account with an interactive token, and grants that account Modify on the state directory: a file created from an elevated shell inherits Administrators only, and the limited task is otherwise refused with Access denied. Evidence (windows/EVIDENCE.md, from the machine's own log): notepad.exe killed inside a scheduled window (pid 16600, then 9724), the device online in the Control Service with locked/until/reason, a telemetry row for the device (app=690s), enforcement continuing with the broker unreachable (192.168.20.13:8884), the warning window on the interactive desktop, and the Scheduled Task registered at logon as New Comp. Domains are deliberately not reported: Windows has no cheap per-process DNS attribution, so the client sends "domains": [] and the gap is documented rather than guessed. 54 unit tests, run with `make windows-test` (no broker, no Windows, no psutil). AGENTS.md §11 is now 10 of 10.
This commit is contained in:
9
windows/tests/__init__.py
Normal file
9
windows/tests/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Unit tests for the kids' Windows client (AGENTS.md §11 Phase 4).
|
||||
|
||||
Pure logic only: no broker, no Windows, no psutil. The tests run anywhere with
|
||||
``python -m unittest`` so the block decision cannot regress unnoticed::
|
||||
|
||||
cd windows && python -m unittest discover -s tests -t .
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
79
windows/tests/test_blocker.py
Normal file
79
windows/tests/test_blocker.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Block selection: what the enforcer may and may not kill."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from webrestrict.blocker import select_victims, self_protection
|
||||
from webrestrict.config import DeviceConfig
|
||||
|
||||
NOW = datetime(2026, 10, 14, 20, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def config(**overrides) -> DeviceConfig:
|
||||
base = dict(
|
||||
locked=True,
|
||||
until=(NOW + timedelta(minutes=30)).isoformat(),
|
||||
block=["notepad.exe", "steam.exe"],
|
||||
allow=["Spotify.exe"],
|
||||
)
|
||||
base.update(overrides)
|
||||
return DeviceConfig(**base)
|
||||
|
||||
|
||||
PROCESSES = [
|
||||
(100, "notepad.exe"),
|
||||
(101, "Notepad.EXE"),
|
||||
(200, "steam.exe"),
|
||||
(300, "Spotify.exe"),
|
||||
(400, "explorer.exe"),
|
||||
(500, "python.exe"),
|
||||
]
|
||||
|
||||
|
||||
class SelectVictimsTests(unittest.TestCase):
|
||||
def test_blocks_every_matching_process(self):
|
||||
victims = select_victims(PROCESSES, config())
|
||||
self.assertEqual([(v.pid, v.matched) for v in victims], [(100, "notepad"), (101, "notepad"), (200, "steam")])
|
||||
|
||||
def test_allowlist_wins(self):
|
||||
victims = select_victims(PROCESSES, config(allow=["spotify"]))
|
||||
self.assertNotIn(300, [v.pid for v in victims])
|
||||
|
||||
def test_nothing_is_blocked_when_unlocked(self):
|
||||
self.assertEqual(select_victims(PROCESSES, config(locked=False)), [])
|
||||
|
||||
def test_nothing_is_blocked_when_the_blocklist_is_empty(self):
|
||||
self.assertEqual(select_victims(PROCESSES, config(block=[])), [])
|
||||
|
||||
def test_nothing_is_blocked_after_until(self):
|
||||
expired = config(until=(NOW - timedelta(minutes=1)).isoformat())
|
||||
self.assertEqual(select_victims(PROCESSES, expired, now=NOW), [])
|
||||
|
||||
def test_protected_names_are_never_victims(self):
|
||||
victims = select_victims(PROCESSES, config(block=["explorer.exe"]), protected_names=["explorer.exe"])
|
||||
self.assertEqual(victims, [])
|
||||
|
||||
def test_protected_pids_are_never_victims(self):
|
||||
victims = select_victims(PROCESSES, config(block=["python.exe"]), protected_pids=[500])
|
||||
self.assertEqual(victims, [])
|
||||
|
||||
def test_duplicate_pids_are_reported_once(self):
|
||||
victims = select_victims([(100, "notepad.exe"), (100, "notepad.exe")], config())
|
||||
self.assertEqual(len(victims), 1)
|
||||
|
||||
def test_bad_rows_are_ignored(self):
|
||||
victims = select_victims([("x", "notepad.exe"), (0, ""), (None, None)], config())
|
||||
self.assertEqual(victims, [])
|
||||
|
||||
|
||||
class SelfProtectionTests(unittest.TestCase):
|
||||
def test_includes_the_process_and_its_parents(self):
|
||||
pids, names = self_protection(10, ["python.exe", "pythonw.exe"], [5, 3, 1])
|
||||
self.assertEqual(pids, {10, 5, 3, 1})
|
||||
self.assertEqual(names, {"python.exe", "pythonw.exe"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
149
windows/tests/test_config.py
Normal file
149
windows/tests/test_config.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""Config parsing, effective lock state and the keep-last-state cache."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from webrestrict.config import ConfigCache, DeviceConfig, bare, parse_time
|
||||
|
||||
NOW = datetime(2026, 10, 14, 20, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def payload(**overrides) -> str:
|
||||
body = {
|
||||
"v": 1,
|
||||
"device_id": 2,
|
||||
"name": "Finn's computer",
|
||||
"platform": "windows",
|
||||
"locked": True,
|
||||
"until": (NOW + timedelta(minutes=30)).isoformat().replace("+00:00", "Z"),
|
||||
"allow": ["Spotify.exe"],
|
||||
"block": ["notepad.exe", "steam"],
|
||||
"reason": "schedule",
|
||||
"generated_at": "2026-10-14T19:00:00Z",
|
||||
}
|
||||
body.update(overrides)
|
||||
return json.dumps(body)
|
||||
|
||||
|
||||
class ParseTests(unittest.TestCase):
|
||||
def test_parses_the_phase3_shape(self):
|
||||
config = DeviceConfig.from_json(payload(extra_field="ignored"))
|
||||
self.assertEqual(config.v, 1)
|
||||
self.assertEqual(config.device_id, 2)
|
||||
self.assertTrue(config.locked)
|
||||
self.assertEqual(config.allow, ["Spotify.exe"])
|
||||
self.assertEqual(config.block, ["notepad.exe", "steam"])
|
||||
self.assertEqual(config.reason, "schedule")
|
||||
self.assertIsNotNone(config.until_at())
|
||||
|
||||
def test_missing_lists_become_empty_arrays(self):
|
||||
config = DeviceConfig.from_json('{"v":1,"locked":true}')
|
||||
self.assertEqual(config.allow, [])
|
||||
self.assertEqual(config.block, [])
|
||||
|
||||
def test_rejects_a_newer_schema(self):
|
||||
with self.assertRaises(ValueError):
|
||||
DeviceConfig.from_json(payload(v=2))
|
||||
|
||||
def test_rejects_malformed_json(self):
|
||||
with self.assertRaises(json.JSONDecodeError):
|
||||
DeviceConfig.from_json("{not json")
|
||||
|
||||
def test_parse_time_accepts_z_and_offsets(self):
|
||||
self.assertEqual(parse_time("2026-10-14T20:00:00Z").tzinfo, timezone.utc)
|
||||
self.assertEqual(parse_time("2026-10-14T20:00:00+10:00").utcoffset(), timedelta(hours=10))
|
||||
self.assertIsNone(parse_time("nonsense"))
|
||||
self.assertIsNone(parse_time(None))
|
||||
|
||||
|
||||
class EffectiveStateTests(unittest.TestCase):
|
||||
def test_not_locked_never_blocks(self):
|
||||
config = DeviceConfig.from_json(payload(locked=False))
|
||||
self.assertFalse(config.is_blocking(NOW))
|
||||
|
||||
def test_locked_without_until_blocks_indefinitely(self):
|
||||
config = DeviceConfig.from_json(payload(until=None))
|
||||
self.assertTrue(config.is_blocking(NOW))
|
||||
self.assertTrue(config.is_blocking(NOW + timedelta(days=30)))
|
||||
|
||||
def test_locked_until_a_future_time_blocks(self):
|
||||
config = DeviceConfig.from_json(payload())
|
||||
self.assertTrue(config.is_blocking(NOW))
|
||||
|
||||
def test_locked_until_a_past_time_does_not_block(self):
|
||||
config = DeviceConfig.from_json(payload(until=(NOW - timedelta(minutes=1)).isoformat()))
|
||||
self.assertFalse(config.is_blocking(NOW))
|
||||
|
||||
def test_a_live_grant_never_blocks(self):
|
||||
config = DeviceConfig.from_json(
|
||||
payload(locked=False, until=None, grant_until=(NOW + timedelta(minutes=10)).isoformat())
|
||||
)
|
||||
self.assertFalse(config.is_blocking(NOW))
|
||||
|
||||
def test_an_expired_grant_does_not_block_either(self):
|
||||
config = DeviceConfig.from_json(
|
||||
payload(locked=False, until=None, grant_until=(NOW - timedelta(minutes=10)).isoformat())
|
||||
)
|
||||
self.assertFalse(config.is_blocking(NOW))
|
||||
|
||||
|
||||
class NameTests(unittest.TestCase):
|
||||
def test_bare_normalises_case_and_exe(self):
|
||||
self.assertEqual(bare("Notepad.EXE"), "notepad")
|
||||
self.assertEqual(bare(" STEAM.exe "), "steam")
|
||||
self.assertEqual(bare(None), "")
|
||||
|
||||
def test_blocked_names_subtract_the_allowlist(self):
|
||||
config = DeviceConfig.from_json(payload(block=["notepad.exe", "Spotify"], allow=["spotify.EXE"]))
|
||||
self.assertEqual(config.blocked_names(), {"notepad"})
|
||||
|
||||
def test_allowlist_wins_per_name(self):
|
||||
config = DeviceConfig.from_json(payload(block=["notepad.exe", "steam.exe"], allow=["Notepad"]))
|
||||
self.assertFalse(config.is_blocked_name("NOTEPAD.EXE"))
|
||||
self.assertTrue(config.is_blocked_name("steam.exe"))
|
||||
|
||||
|
||||
class CacheTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.path = Path(self.tmp.name) / "config-cache.json"
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
|
||||
def test_round_trips_through_disk(self):
|
||||
cache = ConfigCache(self.path)
|
||||
stored = cache.accept(payload())
|
||||
self.assertIsNotNone(stored)
|
||||
again = ConfigCache(self.path).load()
|
||||
self.assertEqual(again.block, stored.block)
|
||||
self.assertTrue(again.locked)
|
||||
|
||||
def test_a_malformed_payload_keeps_the_last_config(self):
|
||||
cache = ConfigCache(self.path)
|
||||
cache.accept(payload())
|
||||
self.assertIsNone(cache.accept("{not json"))
|
||||
self.assertEqual(cache.current.block, ["notepad.exe", "steam"])
|
||||
|
||||
def test_an_older_config_is_ignored(self):
|
||||
cache = ConfigCache(self.path)
|
||||
cache.accept(payload(generated_at="2026-10-14T19:30:00Z", block=["steam.exe"]))
|
||||
self.assertIsNone(cache.accept(payload(generated_at="2026-10-14T18:00:00Z", block=["notepad.exe"])))
|
||||
self.assertEqual(cache.current.block, ["steam.exe"])
|
||||
|
||||
def test_a_newer_config_replaces_it(self):
|
||||
cache = ConfigCache(self.path)
|
||||
cache.accept(payload(generated_at="2026-10-14T19:30:00Z", block=["steam.exe"]))
|
||||
stored = cache.accept(payload(generated_at="2026-10-14T19:45:00Z", block=["notepad.exe"]))
|
||||
self.assertIsNotNone(stored)
|
||||
self.assertEqual(cache.current.block, ["notepad.exe"])
|
||||
|
||||
def test_a_missing_file_is_not_an_error(self):
|
||||
self.assertIsNone(ConfigCache(self.path).load())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
102
windows/tests/test_settings_and_link.py
Normal file
102
windows/tests/test_settings_and_link.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Settings loading and the MQTT payload builders (no broker, no paho)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from webrestrict.mqtt_link import iso_now, status_payload, telemetry_payload
|
||||
from webrestrict.settings import DEFAULT_PROTECTED, Settings
|
||||
|
||||
RAW = {
|
||||
"broker_host": "192.168.20.13",
|
||||
"broker_port": 8883,
|
||||
"device_topic": "restrict/finn_s_computer_2/config",
|
||||
"mqtt_username": "finn_s_computer_2",
|
||||
"mqtt_password": "secret",
|
||||
"ca_file": "C:/ProgramData/WebRestrict/ca.crt",
|
||||
"state_dir": "C:/ProgramData/WebRestrict",
|
||||
"unknown_future_field": "ignored",
|
||||
}
|
||||
|
||||
|
||||
class SettingsTests(unittest.TestCase):
|
||||
def test_derives_topics_and_token(self):
|
||||
settings = Settings.from_dict(RAW)
|
||||
self.assertEqual(settings.device_token, "finn_s_computer_2")
|
||||
self.assertEqual(settings.status_topic, "restrict/finn_s_computer_2/status")
|
||||
self.assertEqual(settings.telemetry_topic, "restrict/finn_s_computer_2/telemetry")
|
||||
self.assertEqual(settings.effective_client_id, "kid-finn_s_computer_2")
|
||||
|
||||
def test_unknown_fields_are_ignored(self):
|
||||
settings = Settings.from_dict(RAW)
|
||||
self.assertFalse(hasattr(settings, "unknown_future_field"))
|
||||
|
||||
def test_loads_from_a_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "client.json"
|
||||
path.write_text(json.dumps({**RAW, "state_dir": tmp}), encoding="utf-8")
|
||||
settings = Settings.load(path)
|
||||
self.assertEqual(settings.broker_port, 8883)
|
||||
self.assertEqual(settings.log_path(), Path(tmp) / "client.log")
|
||||
|
||||
def test_validate_rejects_a_bad_topic(self):
|
||||
settings = Settings.from_dict({**RAW, "device_topic": "restrict/finn"})
|
||||
with self.assertRaises(ValueError):
|
||||
settings.validate()
|
||||
|
||||
def test_validate_rejects_a_missing_account(self):
|
||||
bad = {k: v for k, v in RAW.items() if k != "mqtt_username"}
|
||||
with self.assertRaises(ValueError):
|
||||
Settings.from_dict(bad).validate()
|
||||
|
||||
def test_validate_rejects_a_bad_port(self):
|
||||
with self.assertRaises(ValueError):
|
||||
Settings.from_dict({**RAW, "broker_port": 70000}).validate()
|
||||
|
||||
def test_protected_list_covers_the_system_and_the_interpreter(self):
|
||||
for name in ("explorer", "svchost", "lsass", "python"):
|
||||
self.assertIn(name, DEFAULT_PROTECTED)
|
||||
|
||||
def test_the_default_config_path_is_overridable(self):
|
||||
from webrestrict.settings import default_config_path
|
||||
|
||||
previous = os.environ.get("WEBRESTRICT_CONFIG")
|
||||
os.environ["WEBRESTRICT_CONFIG"] = "/tmp/webrestrict-test.json"
|
||||
try:
|
||||
self.assertEqual(str(default_config_path()), "/tmp/webrestrict-test.json")
|
||||
finally:
|
||||
if previous is None:
|
||||
os.environ.pop("WEBRESTRICT_CONFIG", None)
|
||||
else:
|
||||
os.environ["WEBRESTRICT_CONFIG"] = previous
|
||||
|
||||
|
||||
class PayloadTests(unittest.TestCase):
|
||||
def test_status_reports_online_and_the_lock(self):
|
||||
body = json.loads(status_payload(True, "notepad.exe"))
|
||||
self.assertTrue(body["online"])
|
||||
self.assertTrue(body["locked"])
|
||||
self.assertEqual(body["app"], "notepad.exe")
|
||||
self.assertTrue(body["at"].endswith("Z"))
|
||||
|
||||
def test_status_can_carry_a_tamper_report(self):
|
||||
body = json.loads(status_payload(True, "", tamper={"kind": "client_stopped", "detail": "watchdog"}))
|
||||
self.assertEqual(body["tamper"]["kind"], "client_stopped")
|
||||
self.assertTrue(body["tamper"]["at"].endswith("Z"))
|
||||
|
||||
def test_telemetry_shape_is_the_list_form(self):
|
||||
body = json.loads(telemetry_payload("2026-10-14", {"notepad.exe": 60, "steam.exe": 0}))
|
||||
self.assertEqual(body["day"], "2026-10-14")
|
||||
self.assertEqual(body["apps"], [{"name": "notepad.exe", "seconds": 60}])
|
||||
self.assertEqual(body["domains"], [])
|
||||
|
||||
def test_iso_now_is_utc(self):
|
||||
self.assertRegex(iso_now(), r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
115
windows/tests/test_telemetry.py
Normal file
115
windows/tests/test_telemetry.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""The usage roll-up: payload shape, accumulation, pruning and sampling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from webrestrict.telemetry import (
|
||||
MAX_DAYS,
|
||||
MAX_SAMPLE_SECONDS,
|
||||
TelemetryStore,
|
||||
UsageSampler,
|
||||
build_payload,
|
||||
day_key,
|
||||
to_entries,
|
||||
)
|
||||
|
||||
NOW = datetime(2026, 10, 14, 20, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class PayloadTests(unittest.TestCase):
|
||||
def test_shape_matches_the_control_service(self):
|
||||
body = build_payload("2026-10-14", {"notepad.exe": 120, "steam.exe": 60})
|
||||
self.assertEqual(body["day"], "2026-10-14")
|
||||
self.assertEqual(body["apps"], [{"name": "notepad.exe", "seconds": 120}, {"name": "steam.exe", "seconds": 60}])
|
||||
# Windows cannot attribute domains cheaply; the gap is explicit, not guessed.
|
||||
self.assertEqual(body["domains"], [])
|
||||
|
||||
def test_zero_and_blank_entries_are_dropped(self):
|
||||
self.assertEqual(to_entries({"": 10, "steam.exe": 0, "notepad.exe": 5}), [{"name": "notepad.exe", "seconds": 5}])
|
||||
|
||||
def test_day_key_is_local(self):
|
||||
self.assertEqual(day_key(datetime(2026, 1, 2, 23, 59)), "2026-01-02")
|
||||
|
||||
def test_an_aware_timestamp_is_converted_to_local_time(self):
|
||||
# The day boundary must be the child's midnight, not UTC's.
|
||||
offset = datetime.now().astimezone().utcoffset()
|
||||
utc = datetime(2026, 1, 2, 23, 30, tzinfo=timezone.utc)
|
||||
self.assertEqual(day_key(utc), (utc + offset).strftime("%Y-%m-%d"))
|
||||
|
||||
|
||||
class StoreTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.path = Path(self.tmp.name) / "telemetry.json"
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
|
||||
def test_accumulates_and_persists(self):
|
||||
store = TelemetryStore(self.path)
|
||||
store.add_seconds("2026-10-14", {"notepad.exe": 60})
|
||||
store.add_seconds("2026-10-14", {"notepad.exe": 30, "steam.exe": 10})
|
||||
self.assertEqual(store.day("2026-10-14"), {"notepad.exe": 90, "steam.exe": 10})
|
||||
|
||||
reloaded = TelemetryStore(self.path)
|
||||
self.assertEqual(reloaded.day("2026-10-14"), {"notepad.exe": 90, "steam.exe": 10})
|
||||
|
||||
def test_ignores_non_positive_samples(self):
|
||||
store = TelemetryStore(self.path)
|
||||
store.add_seconds("2026-10-14", {"notepad.exe": 0, "steam.exe": -5})
|
||||
self.assertEqual(store.day("2026-10-14"), {})
|
||||
|
||||
def test_prunes_to_the_most_recent_days(self):
|
||||
store = TelemetryStore(self.path)
|
||||
for day in range(1, MAX_DAYS + 5):
|
||||
store.add_seconds(f"2026-01-{day:02d}", {"notepad.exe": 1})
|
||||
self.assertEqual(len(store.days), MAX_DAYS)
|
||||
self.assertIn("2026-01-18", store.days)
|
||||
self.assertNotIn("2026-01-01", store.days)
|
||||
|
||||
def test_an_unreadable_file_starts_empty(self):
|
||||
self.path.write_text("{not json", encoding="utf-8")
|
||||
store = TelemetryStore(self.path).load()
|
||||
self.assertEqual(store.days, {})
|
||||
|
||||
def test_written_json_is_readable(self):
|
||||
store = TelemetryStore(self.path)
|
||||
store.add_seconds("2026-10-14", {"notepad.exe": 5})
|
||||
self.assertIn("days", json.loads(self.path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
class SamplerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.store = TelemetryStore(Path(self.tmp.name) / "telemetry.json")
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
|
||||
def test_the_first_sample_only_starts_the_clock(self):
|
||||
sampler = UsageSampler(self.store)
|
||||
self.assertEqual(sampler.sample(["notepad.exe"], now=NOW, day="2026-10-14"), {})
|
||||
|
||||
def test_credits_elapsed_seconds_to_each_visible_app(self):
|
||||
sampler = UsageSampler(self.store)
|
||||
sampler.sample(["notepad.exe"], now=NOW, day="2026-10-14")
|
||||
totals = sampler.sample(["notepad.exe", "steam.exe"], now=NOW + timedelta(seconds=60), day="2026-10-14")
|
||||
self.assertEqual(totals, {"notepad.exe": 60, "steam.exe": 60})
|
||||
|
||||
def test_a_long_gap_is_capped(self):
|
||||
sampler = UsageSampler(self.store)
|
||||
sampler.sample(["notepad.exe"], now=NOW, day="2026-10-14")
|
||||
totals = sampler.sample(
|
||||
["notepad.exe"], now=NOW + timedelta(hours=5), day="2026-10-14"
|
||||
)
|
||||
self.assertEqual(totals["notepad.exe"], int(MAX_SAMPLE_SECONDS))
|
||||
|
||||
def test_no_visible_apps_adds_nothing(self):
|
||||
sampler = UsageSampler(self.store)
|
||||
sampler.sample([], now=NOW, day="2026-10-14")
|
||||
self.assertEqual(sampler.sample([], now=NOW + timedelta(seconds=60), day="2026-10-14"), {})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user