""" Тесты генерации событий. """ import random import uuid from dataclasses import replace from datetime import datetime, timedelta import pytest from generator import ( EventGenerator, EventDictionary, EXPECTED_VISIT_EVENTS, TickStreamGenerator, calculate_events_count, generate_tick_batch, ) ALLOWED_PAGE_PATHS = { "/home", "/product_a", "/product_b", "/cart", "/payment", "/confirmation", } def _parse_event_timestamps(batch): return [ datetime.fromisoformat(event["event_timestamp"].replace(" ", "T")) for event in batch["browser_events"] ] def _page_path_visits(generator, visits_count: int): return [ [ event["page_url_path"] for event in generator.generate_batch(30)["location_events"] ] for _ in range(visits_count) ] def _run_stream( stream, tick_at: datetime, ticks_count: int, event_budget: int, tick_seconds: int, ): events = [] for tick_index in range(ticks_count): batch = stream.generate_tick( event_budget=event_budget, tick_started_at=tick_at + timedelta(seconds=tick_seconds * tick_index), ) events.extend(batch["device_events"]) return events def _run_stream_batches( stream, tick_at: datetime, ticks_count: int, event_budget: int, tick_seconds: int, ): batches = [] for tick_index in range(ticks_count): batches.append( stream.generate_tick( event_budget=event_budget, tick_started_at=tick_at + timedelta(seconds=tick_seconds * tick_index), ) ) return batches class TestEventGeneration: """Тесты генерации событий.""" def test_event_dictionary_loads(self, event_dictionary): """Словарь событий загружается корректно.""" assert len(event_dictionary.browser_events) == 1000 assert len(event_dictionary.location_events) == 1000 assert len(event_dictionary.device_events) == 1000 assert len(event_dictionary.geo_events) == 1000 def test_dictionary_consistency(self, event_dictionary): """Все записи в словаре имеют корректные связи.""" browser_event_ids = {e["event_id"] for e in event_dictionary.browser_events} browser_click_ids = {e["click_id"] for e in event_dictionary.browser_events} location_orphaned = sum( 1 for loc in event_dictionary.location_events if loc["event_id"] not in browser_event_ids ) device_orphaned = sum( 1 for dev in event_dictionary.device_events if dev["click_id"] not in browser_click_ids ) geo_orphaned = sum( 1 for geo in event_dictionary.geo_events if geo["click_id"] not in browser_click_ids ) assert location_orphaned == 0, f"Found {location_orphaned} orphaned location records" assert device_orphaned == 0, f"Found {device_orphaned} orphaned device records" assert geo_orphaned == 0, f"Found {geo_orphaned} orphaned geo records" def test_generate_batch_structure(self, event_dictionary, base_config): """Батч имеет правильную структуру.""" generator = EventGenerator(event_dictionary, base_config) batch = generator.generate_batch(10) assert "browser_events" in batch assert "location_events" in batch assert "device_events" in batch assert "geo_events" in batch def test_generate_batch_respects_requested_visit_budget(self, event_dictionary, base_config): """Визит не превышает запрошенный бюджет событий.""" generator = EventGenerator(event_dictionary, base_config) batch = generator.generate_batch(10) browser_count = len(batch["browser_events"]) assert 1 <= browser_count <= 10 assert len(batch["location_events"]) == browser_count assert len(batch["device_events"]) == browser_count assert len(batch["geo_events"]) == browser_count def test_generate_tick_batch_uses_requested_event_budget(self, event_dictionary, base_config): """Тиковый батч трактует входное число как событийный бюджет.""" generator = EventGenerator(event_dictionary, base_config) batch = generate_tick_batch(generator, 20) browser_count = len(batch["browser_events"]) assert 1 <= browser_count <= 20 assert len(batch["location_events"]) == browser_count assert len(batch["device_events"]) == browser_count assert len(batch["geo_events"]) == browser_count assert len({event["click_id"] for event in batch["browser_events"]}) == browser_count def test_tick_stream_keeps_long_window_intensity_near_event_budget( self, event_dictionary, base_config ): """Длинное окно держит среднюю интенсивность около событийного бюджета.""" config = replace( base_config, tick_seconds=5, jitter_pct=0, min_events_per_tick=17, max_events_per_tick=17, max_active_sessions=10_000, population_max=10_001, ) generator = EventGenerator(event_dictionary, config) stream = TickStreamGenerator(generator) tick_at = datetime(2026, 6, 11, 12, 0) ticks_count = 12 * 60 total_events = 0 for tick_index in range(ticks_count): batch = stream.generate_tick( event_budget=17, tick_started_at=tick_at + timedelta(seconds=5 * tick_index), ) total_events += len(batch["browser_events"]) events_per_minute = total_events / (ticks_count * config.tick_seconds / 60) expected_events_per_minute = 17 * 60 / config.tick_seconds assert events_per_minute == pytest.approx(expected_events_per_minute, rel=0.10) def test_tick_stream_births_visits_from_average_visit_length_budget( self, event_dictionary, base_config ): """Новый визит рождается после накопления бюджета средней длины визита.""" config = replace( base_config, tick_seconds=60, max_active_sessions=100, population_max=101, ) generator = EventGenerator(event_dictionary, config) stream = TickStreamGenerator(generator) tick_at = datetime(2026, 6, 11, 12, 0) partial_budget_tick = stream.generate_tick( event_budget=9, tick_started_at=tick_at, ) completed_budget_tick = stream.generate_tick( event_budget=1, tick_started_at=tick_at + timedelta(seconds=config.tick_seconds), ) assert partial_budget_tick["browser_events"] == [] assert len(completed_budget_tick["browser_events"]) == 1 def test_tick_stream_keeps_active_visit_between_ticks(self, event_dictionary, base_config): """Один визит может выпускать события в нескольких последовательных тиках.""" config = replace(base_config, tick_seconds=30 * 60, max_session_events=5) generator = EventGenerator(event_dictionary, config) stream = TickStreamGenerator(generator) first_tick_at = datetime(2026, 6, 11, 12, 0) first_tick = stream.generate_tick(event_budget=10, tick_started_at=first_tick_at) second_tick = stream.generate_tick( event_budget=0, tick_started_at=first_tick_at + timedelta(seconds=config.tick_seconds), ) first_click_id = first_tick["browser_events"][0]["click_id"] second_click_ids = { event["click_id"] for event in second_tick["browser_events"] } second_timestamps = _parse_event_timestamps(second_tick) assert first_click_id in second_click_ids assert all( timestamp < first_tick_at + timedelta(seconds=config.tick_seconds) for timestamp in second_timestamps ) def test_tick_stream_releases_only_matured_events(self, event_dictionary, base_config): """Тик не выпускает будущие события активного визита.""" generator = EventGenerator(event_dictionary, base_config) stream = TickStreamGenerator(generator) tick_at = datetime(2026, 6, 11, 12, 0) first_tick = stream.generate_tick(event_budget=10, tick_started_at=tick_at) same_time_tick = stream.generate_tick(event_budget=0, tick_started_at=tick_at) assert len(first_tick["browser_events"]) == 1 assert same_time_tick["browser_events"] == [] assert same_time_tick["location_events"] == [] assert same_time_tick["device_events"] == [] assert same_time_tick["geo_events"] == [] def test_tick_stream_does_not_reemit_finished_visit(self, event_dictionary, base_config): """Завершённый визит больше не выпускает события в следующих тиках.""" config = replace(base_config, max_session_events=2) generator = EventGenerator(event_dictionary, config) stream = TickStreamGenerator(generator) tick_at = datetime(2026, 6, 11, 12, 0) first_tick = stream.generate_tick(event_budget=10, tick_started_at=tick_at) final_tick = stream.generate_tick( event_budget=0, tick_started_at=tick_at + timedelta(hours=1), ) later_tick = stream.generate_tick( event_budget=0, tick_started_at=tick_at + timedelta(hours=2), ) click_id = first_tick["browser_events"][0]["click_id"] assert {event["click_id"] for event in final_tick["browser_events"]} == {click_id} assert later_tick["browser_events"] == [] def test_tick_stream_drops_births_when_active_limit_is_reached( self, event_dictionary, base_config ): """При заполненном потолке новые рождения пропускаются без накопления бюджета.""" config = replace( base_config, max_session_events=2, max_active_sessions=1, population_max=2, ) generator = EventGenerator(event_dictionary, config) stream = TickStreamGenerator(generator) tick_at = datetime(2026, 6, 11, 12, 0) first_tick = stream.generate_tick(event_budget=10, tick_started_at=tick_at) blocked_tick = stream.generate_tick(event_budget=10, tick_started_at=tick_at) final_tick = stream.generate_tick( event_budget=0, tick_started_at=tick_at + timedelta(hours=1), ) later_tick = stream.generate_tick( event_budget=0, tick_started_at=tick_at + timedelta(hours=2), ) click_id = first_tick["browser_events"][0]["click_id"] assert len(first_tick["browser_events"]) == 1 assert blocked_tick["browser_events"] == [] assert {event["click_id"] for event in final_tick["browser_events"]} == {click_id} assert later_tick["browser_events"] == [] def test_tick_stream_reuses_users_across_visits(self, event_dictionary, base_config): """Длинная симуляция даёт пирамиду users < sessions < events.""" config = replace( base_config, tick_seconds=60, max_session_events=3, max_active_sessions=50, population_max=60, p_new_user=0, min_return_minutes=0, ) generator = EventGenerator(event_dictionary, config) stream = TickStreamGenerator(generator) events = _run_stream( stream=stream, tick_at=datetime(2026, 6, 11, 12, 0), ticks_count=240, event_budget=6, tick_seconds=config.tick_seconds, ) users_by_session = {} for event in events: users_by_session.setdefault(event["click_id"], set()).add(event["user_domain_id"]) unique_users = {event["user_domain_id"] for event in events} repeated_users = [ user_id for user_id in unique_users if len({ event["click_id"] for event in events if event["user_domain_id"] == user_id }) > 1 ] assert len(unique_users) < len(users_by_session) < len(events) assert repeated_users assert all(len(user_ids) == 1 for user_ids in users_by_session.values()) def test_tick_stream_new_user_share_matches_config_after_warmup( self, event_dictionary, base_config ): """Доля новых пользователей в потоке близка к GEN_P_NEW_USER.""" config = replace( base_config, tick_seconds=60, jitter_pct=0, min_events_per_tick=30, max_events_per_tick=30, lambda_base_per_min=30, max_active_sessions=200, population_max=300, p_new_user=0.15, min_return_minutes=30, ) generator = EventGenerator(event_dictionary, config) stream = TickStreamGenerator(generator) tick_at = datetime(2026, 6, 11, 12, 0) warmup_ticks = 240 measure_ticks = 480 seen_users = set() seen_sessions = set() total_sessions = 0 new_user_sessions = 0 for tick_index in range(warmup_ticks + measure_ticks): batch = stream.generate_tick( event_budget=30, tick_started_at=tick_at + timedelta(seconds=60 * tick_index), ) users_by_session = { event["click_id"]: event["user_domain_id"] for event in batch["device_events"] } for click_id, user_id in users_by_session.items(): if click_id in seen_sessions: continue seen_sessions.add(click_id) if tick_index >= warmup_ticks: total_sessions += 1 if user_id not in seen_users: new_user_sessions += 1 seen_users.add(user_id) new_user_share = new_user_sessions / total_sessions assert new_user_share == pytest.approx(config.p_new_user, rel=0.35) def test_tick_stream_replays_same_flow_with_same_seed( self, event_dictionary, base_config ): """Одинаковый seed даёт одинаковые решения популяции и визитов.""" config = replace( base_config, tick_seconds=60, max_session_events=3, max_active_sessions=5, population_max=6, p_new_user=0, min_return_minutes=0, ) tick_at = datetime(2026, 6, 11, 12, 0) first_stream = TickStreamGenerator(EventGenerator(event_dictionary, config)) second_stream = TickStreamGenerator(EventGenerator(event_dictionary, config)) first_events = [] second_events = [] for tick_index in range(10): tick_time = tick_at + timedelta(seconds=config.tick_seconds * tick_index) first_batch = first_stream.generate_tick( event_budget=3, tick_started_at=tick_time, ) second_batch = second_stream.generate_tick( event_budget=3, tick_started_at=tick_time, ) first_events.extend(first_batch["device_events"]) second_events.extend(second_batch["device_events"]) first_decisions = [ (event["click_id"], event["user_domain_id"]) for event in first_events ] second_decisions = [ (event["click_id"], event["user_domain_id"]) for event in second_events ] assert first_decisions == second_decisions def test_returning_user_waits_for_cooldown_after_visit_end( self, event_dictionary, base_config ): """Пользователь не получает новый визит раньше кулдауна возврата.""" config = replace( base_config, tick_seconds=60, max_session_events=3, max_active_sessions=5, population_max=20, p_new_user=0, min_return_minutes=30, ) generator = EventGenerator(event_dictionary, config) stream = TickStreamGenerator(generator) batches = _run_stream_batches( stream=stream, tick_at=datetime(2026, 6, 11, 12, 0), ticks_count=240, event_budget=1, tick_seconds=config.tick_seconds, ) users_by_session = {} times_by_session = {} for batch in batches: for device_event in batch["device_events"]: users_by_session.setdefault( device_event["click_id"], device_event["user_domain_id"], ) for browser_event in batch["browser_events"]: times_by_session.setdefault(browser_event["click_id"], []).append( datetime.fromisoformat( browser_event["event_timestamp"].replace(" ", "T") ) ) sessions_by_user = {} for click_id, timestamps in times_by_session.items(): sessions_by_user.setdefault(users_by_session[click_id], []).append( (min(timestamps), max(timestamps)) ) repeated_users = [ sessions for sessions in sessions_by_user.values() if len(sessions) > 1 ] assert repeated_users for sessions in repeated_users: ordered_sessions = sorted(sessions) for previous, current in zip(ordered_sessions, ordered_sessions[1:]): previous_end = previous[1] current_start = current[0] assert current_start - previous_end >= timedelta( minutes=config.min_return_minutes ) def test_returning_user_pause_average_matches_model_scale( self, event_dictionary, base_config ): """Средняя пауза между визитами одного пользователя имеет часовой масштаб.""" config = replace( base_config, tick_seconds=60, jitter_pct=0, min_events_per_tick=30, max_events_per_tick=30, lambda_base_per_min=30, max_active_sessions=200, population_max=300, p_new_user=0.15, min_return_minutes=30, ) generator = EventGenerator(event_dictionary, config) stream = TickStreamGenerator(generator) batches = _run_stream_batches( stream=stream, tick_at=datetime(2026, 6, 11, 12, 0), ticks_count=16 * 60, event_budget=30, tick_seconds=config.tick_seconds, ) users_by_session = {} times_by_session = {} for batch in batches: for device_event in batch["device_events"]: users_by_session.setdefault( device_event["click_id"], device_event["user_domain_id"], ) for browser_event in batch["browser_events"]: times_by_session.setdefault(browser_event["click_id"], []).append( datetime.fromisoformat( browser_event["event_timestamp"].replace(" ", "T") ) ) sessions_by_user = {} for click_id, timestamps in times_by_session.items(): sessions_by_user.setdefault(users_by_session[click_id], []).append( (min(timestamps), max(timestamps)) ) pauses_minutes = [] visit_durations_minutes = [] for sessions in sessions_by_user.values(): ordered_sessions = sorted(sessions) visit_durations_minutes.extend( (finished_at - started_at).total_seconds() / 60 for started_at, finished_at in ordered_sessions ) for previous, current in zip(ordered_sessions, ordered_sessions[1:]): pauses_minutes.append((current[0] - previous[1]).total_seconds() / 60) mean_pause_minutes = sum(pauses_minutes) / len(pauses_minutes) mean_visit_duration_minutes = ( sum(visit_durations_minutes) / len(visit_durations_minutes) ) expected_pause_minutes = ( config.population_max / ( config.lambda_base_per_min / EXPECTED_VISIT_EVENTS * (1 - config.p_new_user) ) - mean_visit_duration_minutes ) assert min(pauses_minutes) >= config.min_return_minutes assert mean_pause_minutes == pytest.approx(expected_pause_minutes, rel=0.30) def test_visit_cooldown_starts_from_planned_last_event_time( self, event_dictionary, base_config ): """Кулдаун считается от запланированного конца визита, а не от позднего тика.""" config = replace( base_config, tick_seconds=60, max_session_events=3, max_active_sessions=1, population_max=2, p_new_user=0, min_return_minutes=30, ) generator = EventGenerator(event_dictionary, config) stream = TickStreamGenerator(generator) tick_at = datetime(2026, 6, 11, 12, 0) first_tick = stream.generate_tick(event_budget=10, tick_started_at=tick_at) user_id = first_tick["device_events"][0]["user_domain_id"] planned_visit_end = stream.active_visits[0].timestamps[-1] stream.generate_tick( event_budget=0, tick_started_at=planned_visit_end + timedelta(hours=1), ) user = next( user for user in stream.population.users if user.user_domain_id == user_id ) assert user.last_finished_at == planned_visit_end def test_tick_stream_creates_new_user_when_no_returning_user_is_available( self, event_dictionary, base_config ): """Если все прежние пользователи в кулдауне, новый визит получает нового пользователя.""" config = replace( base_config, tick_seconds=60, max_session_events=2, max_active_sessions=1, population_max=2, p_new_user=0, min_return_minutes=240, ) generator = EventGenerator(event_dictionary, config) stream = TickStreamGenerator(generator) tick_at = datetime(2026, 6, 11, 12, 0) first_tick = stream.generate_tick(event_budget=10, tick_started_at=tick_at) second_tick = stream.generate_tick( event_budget=10, tick_started_at=tick_at + timedelta(hours=1), ) third_tick = stream.generate_tick( event_budget=10, tick_started_at=tick_at + timedelta(hours=2), ) first_two_users = { event["user_domain_id"] for event in first_tick["device_events"] + second_tick["device_events"] } later_users = { event["user_domain_id"] for event in third_tick["device_events"] } first_user = first_tick["device_events"][0]["user_domain_id"] second_new_users = first_two_users - {first_user} new_users = later_users - first_two_users assert new_users assert stream.population_size <= config.population_max assert first_user not in stream.population_user_ids assert second_new_users <= stream.population_user_ids assert new_users <= stream.population_user_ids def test_generate_batch_creates_one_connected_visit(self, event_dictionary, base_config): """Публичный вызов генератора создаёт один связанный визит.""" generator = EventGenerator(event_dictionary, base_config) batch = generator.generate_batch(3) original_event_ids = {e["event_id"] for e in event_dictionary.browser_events} original_click_ids = {e["click_id"] for e in event_dictionary.browser_events} browser_events = batch["browser_events"] location_events = batch["location_events"] device_events = batch["device_events"] geo_events = batch["geo_events"] click_ids = {event["click_id"] for event in browser_events} event_ids = [event["event_id"] for event in browser_events] assert len(browser_events) > 1 assert len(click_ids) == 1 click_id = next(iter(click_ids)) assert click_id not in original_click_ids uuid.UUID(click_id) assert len(set(event_ids)) == len(event_ids) assert all(event_id not in original_event_ids for event_id in event_ids) for event_id in event_ids: uuid.UUID(event_id) assert {event["event_id"] for event in location_events} == set(event_ids) assert {event["click_id"] for event in device_events} == {click_id} assert {event["click_id"] for event in geo_events} == {click_id} device_context = [{k: v for k, v in event.items() if k != "click_id"} for event in device_events] geo_context = [{k: v for k, v in event.items() if k != "click_id"} for event in geo_events] assert len({event["user_domain_id"] for event in device_events}) == 1 assert all(context == device_context[0] for context in device_context) assert all(context == geo_context[0] for context in geo_context) def test_generate_batch_creates_multi_event_visit_when_budget_allows( self, event_dictionary, base_config ): """Минимальный связанный визит не схлопывается в одно событие.""" config = replace(base_config, seed=2) generator = EventGenerator(event_dictionary, config) batch = generator.generate_batch(3) assert len(batch["browser_events"]) >= 2 def test_event_ids_are_new_uuids(self, event_dictionary, base_config): """event_id и click_id — новые UUID, не из оригинальных данных.""" generator = EventGenerator(event_dictionary, base_config) batch = generator.generate_batch(1) original_event_ids = {e["event_id"] for e in event_dictionary.browser_events} original_click_ids = {e["click_id"] for e in event_dictionary.browser_events} browser_event = batch["browser_events"][0] assert browser_event["event_id"] not in original_event_ids assert browser_event["click_id"] not in original_click_ids # Проверяем, что это валидный UUID uuid.UUID(browser_event["event_id"]) uuid.UUID(browser_event["click_id"]) def test_event_timestamp_format(self, event_dictionary, base_config): """event_timestamp имеет правильный формат.""" generator = EventGenerator(event_dictionary, base_config) batch = generator.generate_batch(1) browser_event = batch["browser_events"][0] timestamp = browser_event["event_timestamp"] # Должен парситься как datetime dt = datetime.fromisoformat(timestamp.replace(" ", "T")) assert dt.year >= 2024 def test_visit_event_timestamps_strictly_increase(self, event_dictionary, base_config): """Время событий внутри одного визита строго возрастает.""" generator = EventGenerator(event_dictionary, base_config) batch = generator.generate_batch(8) timestamps = _parse_event_timestamps(batch) assert all( previous < current for previous, current in zip(timestamps, timestamps[1:]) ) def test_visit_event_timestamps_use_planned_user_pauses(self, event_dictionary, base_config): """Метки времени визита разделены пользовательскими паузами, а не временем цикла.""" generator = EventGenerator(event_dictionary, base_config) batch = generator.generate_batch(8) timestamps = _parse_event_timestamps(batch) pauses_seconds = [ (current - previous).total_seconds() for previous, current in zip(timestamps, timestamps[1:]) ] assert min(pauses_seconds) >= 1.0 def test_visit_path_uses_known_funnel_pages(self, event_dictionary, base_config): """Путь визита состоит из страниц воронки.""" generator = EventGenerator(event_dictionary, base_config) batch = generator.generate_batch(30) page_paths = [event["page_url_path"] for event in batch["location_events"]] assert page_paths assert set(page_paths) <= ALLOWED_PAGE_PATHS def test_visit_starts_from_calibrated_start_distribution(self, event_dictionary, base_config): """Визиты стартуют не только с /home, а по стартовому распределению.""" generator = EventGenerator(event_dictionary, base_config) visits = _page_path_visits(generator, 1000) first_pages = [visit[0] for visit in visits] home_share = first_pages.count("/home") / len(first_pages) product_entry_share = ( first_pages.count("/product_a") + first_pages.count("/product_b") ) / len(first_pages) assert 0.54 <= home_share <= 0.64 assert product_entry_share >= 0.25 def test_visit_length_is_capped_by_session_limit(self, event_dictionary, base_config): """Длина визита ограничена потолком, который защищает от петель.""" config = replace(base_config, max_session_events=4) generator = EventGenerator(event_dictionary, config) visits = _page_path_visits(generator, 200) assert max(len(visit) for visit in visits) <= 4 assert any(len(visit) == 4 for visit in visits) def test_visit_length_distribution_matches_seed_scale( self, event_dictionary, base_config ): """Длина визита сопоставима с сидом: медиана и среднее около 10.""" generator = EventGenerator(event_dictionary, base_config) visits = _page_path_visits(generator, 2000) visit_lengths = sorted(len(visit) for visit in visits) median_length = visit_lengths[len(visit_lengths) // 2] mean_length = sum(visit_lengths) / len(visit_lengths) assert 8 <= median_length <= 12 assert mean_length == pytest.approx(EXPECTED_VISIT_EVENTS, rel=0.10) assert max(visit_lengths) <= base_config.max_session_events def test_visit_pauses_stay_below_session_timeout_scale(self, event_dictionary, base_config): """Паузы внутри визита остаются меньше 30 минут, p95 — единицы минут.""" generator = EventGenerator(event_dictionary, base_config) pauses_seconds = [] for _ in range(500): timestamps = _parse_event_timestamps(generator.generate_batch(30)) pauses_seconds.extend( (current - previous).total_seconds() for previous, current in zip(timestamps, timestamps[1:]) ) pauses_seconds.sort() p95 = pauses_seconds[int(len(pauses_seconds) * 0.95)] assert max(pauses_seconds) < 30 * 60 assert p95 < 5 * 60 def test_confirmation_share_matches_seed_scale(self, event_dictionary, base_config): """Около четверти визитов доходят до /confirmation.""" generator = EventGenerator(event_dictionary, base_config) visits = _page_path_visits(generator, 1000) confirmation_share = ( sum("/confirmation" in visit for visit in visits) / len(visits) ) assert 0.20 <= confirmation_share <= 0.30 def test_visit_funnel_monotonically_fades(self, event_dictionary, base_config): """Воронка по визитам монотонно затухает от главной до подтверждения.""" generator = EventGenerator(event_dictionary, base_config) visits = _page_path_visits(generator, 2000) home_visits = sum("/home" in visit for visit in visits) product_visits = sum( "/product_a" in visit or "/product_b" in visit for visit in visits ) cart_visits = sum("/cart" in visit for visit in visits) payment_visits = sum("/payment" in visit for visit in visits) confirmation_visits = sum("/confirmation" in visit for visit in visits) assert home_visits >= product_visits >= cart_visits assert cart_visits >= payment_visits >= confirmation_visits def test_visit_can_continue_after_confirmation(self, event_dictionary, base_config): """/confirmation не обязан быть последним событием визита.""" generator = EventGenerator(event_dictionary, base_config) visits = _page_path_visits(generator, 1000) assert any( page_path == "/confirmation" and index < len(visit) - 1 for visit in visits for index, page_path in enumerate(visit) ) def test_links_consistency(self, event_dictionary, base_config): """Связи между событиями сохраняются.""" generator = EventGenerator(event_dictionary, base_config) batch = generator.generate_batch(5) for i, browser in enumerate(batch["browser_events"]): event_id = browser["event_id"] click_id = browser["click_id"] # Location должен иметь тот же event_id assert batch["location_events"][i]["event_id"] == event_id # Device и Geo должны иметь тот же click_id assert batch["device_events"][i]["click_id"] == click_id assert batch["geo_events"][i]["click_id"] == click_id def test_required_fields_present(self, event_dictionary, base_config): """Все обязательные поля присутствуют в событиях.""" generator = EventGenerator(event_dictionary, base_config) batch = generator.generate_batch(1) browser = batch["browser_events"][0] required_fields = [ "event_id", "event_timestamp", "event_type", "click_id", "browser_name", "browser_user_agent", "browser_language" ] for field in required_fields: assert field in browser, f"Missing field: {field}" class TestPoissonDistribution: """Тесты статистической модели.""" def test_event_budget_mean_follows_lambda_and_hour_factor( self, base_config, monkeypatch ): """Средний событийный бюджет следует λ и часовому коэффициенту.""" monkeypatch.setattr( "clickstream_generator.intensity.hour_factor", lambda: 1.2, ) config = replace( base_config, tick_seconds=60, lambda_base_per_min=30, jitter_pct=0, min_events_per_tick=1, max_events_per_tick=100, ) rng = random.Random(config.seed) samples = [calculate_events_count(config, rng) for _ in range(1000)] mean_budget = sum(samples) / len(samples) assert mean_budget == pytest.approx(30 * 1.2, rel=0.15) def test_default_tick_budget_floor_does_not_outgrow_target_lambda( self, base_config, monkeypatch ): """Дефолтная нижняя граница бюджета не разгоняет lambda=30 на тике 5 секунд.""" monkeypatch.setattr( "clickstream_generator.intensity.hour_factor", lambda: 1.0, ) config = replace( base_config, tick_seconds=5, lambda_base_per_min=30, jitter_pct=0, max_events_per_tick=50, ) rng = random.Random(config.seed) samples = [calculate_events_count(config, rng) for _ in range(1000)] events_per_minute = sum(samples) / len(samples) * 60 / config.tick_seconds assert events_per_minute == pytest.approx(config.lambda_base_per_min, rel=0.20) def test_calculate_events_respects_bounds(self, event_dictionary, base_config): """Расчет количества событий уважает границы.""" generator = EventGenerator(event_dictionary, base_config) samples = [generator._calculate_events_count() for _ in range(100)] assert all(s >= base_config.min_events_per_tick for s in samples) assert all(s <= base_config.max_events_per_tick for s in samples) def test_jitter_increases_variance(self, event_dictionary, base_config, config_no_jitter): """Jitter увеличивает дисперсию.""" gen_with = EventGenerator(event_dictionary, base_config) gen_without = EventGenerator(event_dictionary, config_no_jitter) samples_with = [gen_with._calculate_events_count() for _ in range(200)] samples_without = [gen_without._calculate_events_count() for _ in range(200)] mean_with = sum(samples_with) / len(samples_with) mean_without = sum(samples_without) / len(samples_without) var_with = sum((x - mean_with) ** 2 for x in samples_with) / len(samples_with) var_without = sum((x - mean_without) ** 2 for x in samples_without) / len(samples_without) assert var_with > var_without, \ f"Jitter should increase variance: {var_with} vs {var_without}" def test_mean_is_reasonable(self, event_dictionary, base_config): """Среднее значение в разумных пределах.""" config = replace( base_config, jitter_pct=0, min_events_per_tick=1, ) generator = EventGenerator(event_dictionary, config) samples = [generator._calculate_events_count() for _ in range(500)] mean = sum(samples) / len(samples) # Ожидаем: lambda_base * tick_seconds / 60 * hour_factor # hour_factor обычно 0.7-1.2 expected_base = config.lambda_base_per_min * config.tick_seconds / 60.0 # Допустимое отклонение до 50% assert mean > expected_base * 0.5, f"Mean {mean} too low (expected ~{expected_base})" assert mean < expected_base * 1.5, f"Mean {mean} too high (expected ~{expected_base})" class TestEmptyData: """Тесты обработки пустых данных.""" def test_empty_jsonl_raises_error(self, empty_temp_dir): """Пустые JSONL файлы вызывают ValueError при загрузке.""" with pytest.raises(ValueError, match="browser_events.jsonl is empty"): EventDictionary.load(empty_temp_dir)