"""Tests for time_tracker_handlers — parity with rust time_tracker pack.""" import json import os import time as _time import pytest def test_load_returns_empty_when_file_missing(isolated_state): tt = isolated_state('time_tracker_handlers', ['_PATH']) data = tt.load() assert data == {'current_session_start': None, 'sessions': []} def test_start_session_records_timestamp(isolated_state): tt = isolated_state('time_tracker_handlers', ['_PATH']) tt.start_session(now=1_700_000_000) data = tt.load() assert data['current_session_start'] == 1_700_000_000 assert data['sessions'] == [] def test_start_session_is_idempotent_when_already_running(isolated_state): tt = isolated_state('time_tracker_handlers', ['_PATH']) tt.start_session(now=1_700_000_000) tt.start_session(now=1_700_010_000) # second start should not overwrite data = tt.load() assert data['current_session_start'] == 1_700_000_000 def test_stop_session_pushes_completed(isolated_state): tt = isolated_state('time_tracker_handlers', ['_PATH']) tt.start_session(now=1_700_000_000) _, elapsed = tt.stop_session(now=1_700_003_600) # +1 hour assert elapsed == 3600 data = tt.load() assert data['current_session_start'] is None assert len(data['sessions']) == 1 assert data['sessions'][0]['start'] == 1_700_000_000 assert data['sessions'][0]['end'] == 1_700_003_600 def test_stop_session_without_open_returns_none(isolated_state): tt = isolated_state('time_tracker_handlers', ['_PATH']) _, elapsed = tt.stop_session(now=1_700_000_000) assert elapsed is None def test_today_total_sums_overlap_in_local_day(isolated_state): """Two sessions today + one yesterday — only today's pair is counted.""" tt = isolated_state('time_tracker_handlers', ['_PATH']) # Pick a deterministic "now" at 15:00 local time today. today_midnight = int(_time.mktime(_time.strptime('2026-05-16', '%Y-%m-%d'))) now = today_midnight + 15 * 3600 # 15:00 local today data = { 'current_session_start': None, 'sessions': [ # yesterday: 10:00 → 12:00 (2h, ignored) {'start': today_midnight - 14 * 3600, 'end': today_midnight - 12 * 3600}, # today: 09:00 → 10:30 (90 min) {'start': today_midnight + 9 * 3600, 'end': today_midnight + 10 * 3600 + 1800}, # today: 13:00 → 14:00 (60 min) {'start': today_midnight + 13 * 3600, 'end': today_midnight + 14 * 3600}, ], } tt.save(data) total = tt.today_total(now=now) assert total == 90 * 60 + 60 * 60 # 2h30m def test_today_total_includes_open_session(isolated_state): tt = isolated_state('time_tracker_handlers', ['_PATH']) today_midnight = int(_time.mktime(_time.strptime('2026-05-16', '%Y-%m-%d'))) now = today_midnight + 10 * 3600 # 10:00 data = { 'current_session_start': today_midnight + 9 * 3600, # opened at 09:00 'sessions': [], } tt.save(data) total = tt.today_total(now=now) assert total == 3600 # 1h of open session counts def test_week_total_covers_last_seven_days(isolated_state): tt = isolated_state('time_tracker_handlers', ['_PATH']) today_midnight = int(_time.mktime(_time.strptime('2026-05-16', '%Y-%m-%d'))) now = today_midnight + 12 * 3600 data = { 'current_session_start': None, 'sessions': [ # 8 days ago: outside window {'start': today_midnight - 8 * 86400, 'end': today_midnight - 8 * 86400 + 3600}, # 3 days ago: inside window (1h) {'start': today_midnight - 3 * 86400, 'end': today_midnight - 3 * 86400 + 3600}, # today: 2h {'start': today_midnight + 9 * 3600, 'end': today_midnight + 11 * 3600}, ], } tt.save(data) total = tt.week_total(now=now) assert total == 3 * 3600 # 3 hours def test_reset_today_drops_only_todays_sessions(isolated_state): tt = isolated_state('time_tracker_handlers', ['_PATH']) today_midnight = int(_time.mktime(_time.strptime('2026-05-16', '%Y-%m-%d'))) now = today_midnight + 14 * 3600 data = { 'current_session_start': today_midnight + 13 * 3600, 'sessions': [ {'start': today_midnight - 86400, 'end': today_midnight - 86400 + 3600}, # yesterday {'start': today_midnight + 9 * 3600, 'end': today_midnight + 10 * 3600}, # today {'start': today_midnight + 11 * 3600, 'end': today_midnight + 12 * 3600}, # today ], } tt.save(data) dropped, had_open = tt.reset_today(now=now) assert dropped == 2 assert had_open is True after = tt.load() assert after['current_session_start'] is None assert len(after['sessions']) == 1 assert after['sessions'][0]['start'] == today_midnight - 86400 def test_reset_today_when_empty_reports_zero(isolated_state): tt = isolated_state('time_tracker_handlers', ['_PATH']) dropped, had_open = tt.reset_today(now=1_700_000_000) assert dropped == 0 assert had_open is False def test_format_duration_ru_pluralisation(isolated_state): tt = isolated_state('time_tracker_handlers', ['_PATH']) assert tt.format_duration_ru(0) == "0 минут" assert tt.format_duration_ru(60) == "1 минута" assert tt.format_duration_ru(2 * 60) == "2 минуты" assert tt.format_duration_ru(5 * 60) == "5 минут" assert tt.format_duration_ru(11 * 60) == "11 минут" assert tt.format_duration_ru(3600) == "1 час" assert tt.format_duration_ru(2 * 3600) == "2 часа" assert tt.format_duration_ru(5 * 3600) == "5 часов" assert tt.format_duration_ru(3 * 3600 + 24 * 60) == "3 часа 24 минуты" def test_persists_atomic_write(isolated_state): tt = isolated_state('time_tracker_handlers', ['_PATH']) tt.start_session(now=1_700_000_000) assert os.path.isfile(tt._PATH) with open(tt._PATH, encoding='utf-8') as f: on_disk = json.load(f) assert on_disk['current_session_start'] == 1_700_000_000 def test_voice_entry_speaks_start_message(isolated_state, monkeypatch): tt = isolated_state('time_tracker_handlers', ['_PATH']) spoken = [] monkeypatch.setattr(tt, '_speak_fn', lambda s: spoken.append(s)) tt.do_tracker_start({}, '') assert spoken and 'Отсчёт' in spoken[0] # second call says already running tt.do_tracker_start({}, '') assert any('Уже считаю' in s for s in spoken) def test_voice_entry_today_reports_total(isolated_state, monkeypatch): tt = isolated_state('time_tracker_handlers', ['_PATH']) spoken = [] monkeypatch.setattr(tt, '_speak_fn', lambda s: spoken.append(s)) # No data → "ничего не отработано" tt.do_tracker_today({}, '') assert spoken and 'ничего не отработано' in spoken[-1].lower()