fix(generator): сохранена фактура визита при восстановлении

- Зачем:
  - визит после восстановления не должен менять браузер и источники перехода внутри одного click_id.
- Что:
  - добавлен base_click_id в state v3 для восстановления донора фактуры.
  - исправлено восстановление timestamp offset без потери микросекунд.
  - расширены тесты и стыковая проверка browser/source и device/os/geo.
- Проверка:
  - uv run --with-requirements generator/requirements.txt pytest generator/tests -q.
  - bash -n scripts/check_generated_analytics.sh.
  - git diff --cached --check.
This commit is contained in:
2026-07-04 21:46:23 +03:00
parent 0cbfe9b32e
commit e2d06841de
13 changed files with 464 additions and 77 deletions
+116 -1
View File
@@ -18,6 +18,7 @@ from generator import (
generate_tick_batch,
hour_factor,
)
from clickstream_generator.runtime import _timestamp_to_state_offset
ALLOWED_PAGE_PATHS = {
@@ -37,6 +38,24 @@ def _parse_event_timestamps(batch):
]
def _without_fields(batch, excluded_fields):
return {
topic: [
{
key: value
for key, value in event.items()
if key not in excluded_fields
}
for event in events
]
for topic, events in batch.items()
}
def _without_event_ids(batch):
return _without_fields(batch, {"event_id"})
def _page_path_visits(generator, visits_count: int):
return [
[
@@ -297,7 +316,7 @@ class TestEventGeneration:
def test_tick_stream_state_stays_compact_at_active_session_limit(
self, event_dictionary, base_config
):
"""State v2 не хранит полные события активных визитов."""
"""State не хранит полные события активных визитов."""
config = replace(
base_config,
max_session_events=30,
@@ -386,6 +405,102 @@ class TestEventGeneration:
for timestamp in resumed_timestamps
)
def test_tick_stream_restored_visit_keeps_per_event_fixture(
self, event_dictionary, base_config
):
"""Восстановленный визит продолжает ту же фактуру событий."""
config = replace(base_config, max_session_events=8)
tick_at = datetime(2026, 6, 11, 12, 0)
resume_at = tick_at + timedelta(hours=2)
generator = EventGenerator(event_dictionary, config)
stream = TickStreamGenerator(generator)
stream.generate_tick(event_budget=10, tick_started_at=tick_at)
state = stream.to_state(
tick=1,
rng_state=generator.rng.getstate(),
last_batch_id="batch-1",
last_timestamp=tick_at,
)
restored_state = type(state).from_dict(json.loads(json.dumps(state.to_dict())))
restored_generator = EventGenerator(event_dictionary, config)
restored_stream = TickStreamGenerator(restored_generator)
restored_stream.restore_state(restored_state, resume_model_at=tick_at)
uninterrupted = stream.generate_tick(event_budget=0, tick_started_at=resume_at)
restored = restored_stream.generate_tick(event_budget=0, tick_started_at=resume_at)
assert restored["browser_events"]
assert _without_event_ids(restored) == _without_event_ids(uninterrupted)
def test_tick_stream_restored_visit_keeps_cyclic_fallback_fixture(
self, event_dictionary, base_config
):
"""Короткий донор фактуры после восстановления повторяется так же."""
config = replace(base_config, max_session_events=30)
tick_at = datetime(2026, 6, 11, 12, 0)
resume_at = tick_at + timedelta(hours=12)
generator = EventGenerator(event_dictionary, config)
stream = TickStreamGenerator(generator)
stream.generate_tick(event_budget=100, tick_started_at=tick_at)
state = stream.to_state(
tick=1,
rng_state=generator.rng.getstate(),
last_batch_id="batch-1",
last_timestamp=tick_at,
)
assert any(
len(event_dictionary.browser_by_click_id[visit["base_click_id"]])
< len(visit["offsets_us"])
for visit in state.active_visits
)
restored_state = type(state).from_dict(json.loads(json.dumps(state.to_dict())))
restored_generator = EventGenerator(event_dictionary, config)
restored_stream = TickStreamGenerator(restored_generator)
restored_stream.restore_state(restored_state, resume_model_at=tick_at)
uninterrupted = stream.generate_tick(event_budget=0, tick_started_at=resume_at)
restored = restored_stream.generate_tick(event_budget=0, tick_started_at=resume_at)
assert restored["browser_events"]
assert _without_event_ids(restored) == _without_event_ids(uninterrupted)
def test_state_offset_preserves_microseconds_without_float_rounding(self):
"""Смещение state не теряет микросекунду на float-округлении."""
started_at = datetime(2026, 6, 11, 12, 0, 0)
timestamp = datetime(2026, 6, 11, 12, 8, 34, 130779)
assert _timestamp_to_state_offset(started_at, timestamp) == 514_130_779
def test_tick_stream_restore_rejects_unknown_fixture_donor(
self, event_dictionary, base_config
):
"""Неизвестный донор фактуры в state даёт ошибку восстановления."""
config = replace(base_config, max_session_events=5)
tick_at = datetime(2026, 6, 11, 12, 0)
generator = EventGenerator(event_dictionary, config)
stream = TickStreamGenerator(generator)
stream.generate_tick(event_budget=10, tick_started_at=tick_at)
state = stream.to_state(
tick=1,
rng_state=generator.rng.getstate(),
last_batch_id="batch-1",
last_timestamp=tick_at,
)
state_data = json.loads(json.dumps(state.to_dict()))
state_data["active_visits"][0]["base_click_id"] = "missing-click-id"
restored_state = type(state).from_dict(state_data)
restored_generator = EventGenerator(event_dictionary, config)
restored_stream = TickStreamGenerator(restored_generator)
with pytest.raises(ValueError, match="Unknown fixture base_click_id"):
restored_stream.restore_state(restored_state, resume_model_at=tick_at)
def test_tick_stream_restored_after_long_idle_closes_overdue_visit_without_replay(
self, event_dictionary, base_config
):