255 lines
10 KiB
Python
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
|