Files
training-software/tests/test_dwell_tracking.py
Paperclip CTO 80df6d4baf
Some checks failed
CI / lint (push) Successful in 5s
CI / test (push) Failing after 29s
CI / build-container (push) Has been skipped
fix(tests): align dwell tracking user creation with email-only account model
2026-05-18 15:07:37 +02:00

255 lines
10 KiB
Python

"""
Dwell-time tracking tests (TRA-238).
Covers:
- compute_eligible_seconds (pure-function replay):
- basic accumulation
- reconnect tolerance: gaps ≤ RECONNECT_TOLERANCE_SECONDS are bridged
- anti-idle cap: merged windows > MAX_VALID_EVENT_SECONDS are capped
- invalid events are excluded
- events with no ended_at are excluded
- overlapping events are merged, not double-counted
- check_navigation_gate:
- returns False when accumulated < required
- returns True when accumulated >= required
- returns True when required == 0 (no gate)
- PageProgress.can_advance property mirrors the gate logic
The pure tests (TestComputeEligibleSeconds, TestNavigationGate) run without a
database. DB-backed tests are marked @pytest.mark.django_db.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import MagicMock, PropertyMock
import pytest
from tracking.models import MAX_VALID_EVENT_SECONDS, RECONNECT_TOLERANCE_SECONDS
from tracking.services import check_navigation_gate, compute_eligible_seconds
UTC = timezone.utc
def _t(offset_seconds: int) -> datetime:
"""Helper: datetime anchored at epoch + offset seconds (UTC)."""
return datetime(2024, 1, 1, tzinfo=UTC) + timedelta(seconds=offset_seconds)
def _ev(started: int, ended: int, is_valid: bool = True):
"""Build a minimal dwell-event-like object (no DB required)."""
return SimpleNamespace(
started_at=_t(started),
ended_at=_t(ended),
is_valid=is_valid,
)
# ── compute_eligible_seconds (pure) ──────────────────────────────────────────
class TestComputeEligibleSeconds:
"""Event replay logic with reconnect merging and anti-idle capping."""
def test_empty_list_returns_zero(self):
assert compute_eligible_seconds([]) == 0
def test_single_event_returns_duration(self):
assert compute_eligible_seconds([_ev(0, 60)]) == 60
def test_two_disjoint_events_accumulate(self):
# [0,60] gap=120 [180,240] → 60 + 60 = 120
events = [_ev(0, 60), _ev(180, 240)]
assert compute_eligible_seconds(events) == 120
def test_adjacent_events_within_tolerance_are_merged(self):
# [0,60] gap=10s [70,130] → merged to [0,130] → 130s, capped at 120
events = [_ev(0, 60), _ev(70, 130)]
gap = 70 - 60 # 10 ≤ RECONNECT_TOLERANCE_SECONDS
assert gap <= RECONNECT_TOLERANCE_SECONDS
result = compute_eligible_seconds(events)
# Merged window is 130s → capped at MAX_VALID_EVENT_SECONDS
assert result == MAX_VALID_EVENT_SECONDS
def test_gap_exactly_at_tolerance_is_merged(self):
# Gap exactly RECONNECT_TOLERANCE_SECONDS → should merge
gap = RECONNECT_TOLERANCE_SECONDS
events = [_ev(0, 60), _ev(60 + gap, 90 + gap)]
result = compute_eligible_seconds(events)
# Merged: [0, 90+gap] → 90+gap seconds, likely under cap
window = 90 + gap
assert result == min(window, MAX_VALID_EVENT_SECONDS)
def test_gap_one_second_over_tolerance_is_not_merged(self):
gap = RECONNECT_TOLERANCE_SECONDS + 1
events = [_ev(0, 60), _ev(60 + gap, 100 + gap)]
# Two separate windows: 60s + 40s = 100s
assert compute_eligible_seconds(events) == 100
def test_anti_idle_cap_applied_per_merged_window(self):
# Single merged window of 300s → capped at MAX_VALID_EVENT_SECONDS
events = [_ev(0, 300)]
result = compute_eligible_seconds(events)
assert result == MAX_VALID_EVENT_SECONDS
def test_two_capped_windows_each_capped_independently(self):
# Two windows of 200s each separated by a large gap → 2 * cap
events = [_ev(0, 200), _ev(1000, 1200)]
result = compute_eligible_seconds(events)
assert result == 2 * MAX_VALID_EVENT_SECONDS
def test_invalid_events_excluded(self):
valid = _ev(0, 60)
invalid = _ev(100, 200, is_valid=False)
assert compute_eligible_seconds([valid, invalid]) == 60
def test_event_with_no_ended_at_excluded(self):
open_ev = SimpleNamespace(started_at=_t(0), ended_at=None, is_valid=True)
valid = _ev(200, 260)
assert compute_eligible_seconds([open_ev, valid]) == 60
def test_events_out_of_order_are_sorted(self):
# Supply in reverse order; service must sort by started_at
events = [_ev(100, 160), _ev(0, 60)]
# Gap between [0,60] and [100,160] = 40s > RECONNECT_TOLERANCE_SECONDS
# → two separate 60s windows → 120s total (60+60)
assert compute_eligible_seconds(events) == 120
def test_overlapping_events_merged_not_double_counted(self):
# [0,80] and [40,100] overlap → merged to [0,100] = 100s
events = [_ev(0, 80), _ev(40, 100)]
assert compute_eligible_seconds(events) == 100
def test_replay_idempotent(self):
# Calling twice with the same events returns the same result
events = [_ev(0, 60), _ev(200, 250)]
r1 = compute_eligible_seconds(events)
r2 = compute_eligible_seconds(events)
assert r1 == r2
# ── Reconnect tolerance edge cases ───────────────────────────────────────────
class TestReconnectTolerance:
"""Ensure brief disconnects do not lose eligible progress."""
def test_three_events_with_small_gaps_all_merged(self):
# Three events, each gap < tolerance → one merged window
g = RECONNECT_TOLERANCE_SECONDS - 1
e1 = _ev(0, 30)
e2 = _ev(30 + g, 60 + g)
e3 = _ev(60 + 2 * g, 80 + 2 * g)
result = compute_eligible_seconds([e1, e2, e3])
# Merged window: [0, 80+2g] → 80 + 2g seconds, likely > cap
expected_window = 80 + 2 * g
assert result == min(expected_window, MAX_VALID_EVENT_SECONDS)
def test_disconnect_exactly_at_tolerance_preserves_progress(self):
"""A reconnect at exactly the tolerance boundary keeps progress."""
g = RECONNECT_TOLERANCE_SECONDS
events = [_ev(0, 50), _ev(50 + g, 80 + g)]
window = 80 + g
assert compute_eligible_seconds(events) == min(window, MAX_VALID_EVENT_SECONDS)
def test_long_disconnect_creates_two_windows(self):
g = RECONNECT_TOLERANCE_SECONDS + 5
events = [_ev(0, 50), _ev(50 + g, 100 + g)]
# Two windows of 50s each → 100s
assert compute_eligible_seconds(events) == 100
# ── check_navigation_gate (pure mock) ─────────────────────────────────────────
class TestNavigationGate:
"""check_navigation_gate must gate progression on required_seconds."""
def _progress(self, accumulated: int, required: int) -> MagicMock:
p = MagicMock()
page = MagicMock()
page.required_seconds = required
p.page = page
p.accumulated_seconds = accumulated
# Replicate can_advance logic for the mock
type(p).can_advance = PropertyMock(return_value=(accumulated >= required))
return p
def test_gate_blocks_when_below_threshold(self):
p = self._progress(accumulated=30, required=60)
assert check_navigation_gate(p) is False
def test_gate_passes_when_threshold_met(self):
p = self._progress(accumulated=60, required=60)
assert check_navigation_gate(p) is True
def test_gate_passes_when_above_threshold(self):
p = self._progress(accumulated=100, required=60)
assert check_navigation_gate(p) is True
def test_no_gate_when_required_is_zero(self):
p = self._progress(accumulated=0, required=0)
assert check_navigation_gate(p) is True
def test_gate_blocks_at_zero_accumulated_with_nonzero_required(self):
p = self._progress(accumulated=0, required=1)
assert check_navigation_gate(p) is False
# ── PageProgress.can_advance (DB) ────────────────────────────────────────────
@pytest.mark.django_db
class TestCanAdvanceProperty:
"""PageProgress.can_advance reflects the page's required_seconds gate."""
def _make_full_hierarchy(self, required_seconds=60):
from courses.models import Course, Module, Lesson, Page
course = Course.objects.create(
org_id=uuid.uuid4(),
title="C",
slug=f"slug-{uuid.uuid4().hex[:6]}",
)
module = Module.objects.create(course=course, title="M", order=0)
lesson = Lesson.objects.create(module=module, title="L", order=0)
page = Page.objects.create(
lesson=lesson, title="P", order=0, required_seconds=required_seconds
)
return page
def _make_enrollment(self, page):
from django.contrib.auth import get_user_model
from tracking.models import Enrollment, PageProgress
User = get_user_model()
user = User.objects.create_user(
email=f"u-{uuid.uuid4().hex[:8]}@example.com",
password="x",
)
enrollment = Enrollment.objects.create(
user=user, course=page.lesson.module.course, org_id=uuid.uuid4()
)
progress = PageProgress.objects.create(
enrollment=enrollment,
page=page,
accumulated_seconds=0,
)
return progress
def test_can_advance_false_when_not_enough(self):
page = self._make_full_hierarchy(required_seconds=60)
progress = self._make_enrollment(page)
progress.accumulated_seconds = 30
assert progress.can_advance is False
def test_can_advance_true_when_threshold_met(self):
page = self._make_full_hierarchy(required_seconds=60)
progress = self._make_enrollment(page)
progress.accumulated_seconds = 60
assert progress.can_advance is True
def test_can_advance_true_when_no_gate(self):
page = self._make_full_hierarchy(required_seconds=0)
progress = self._make_enrollment(page)
progress.accumulated_seconds = 0
assert progress.can_advance is True