J.A.R.V.I.S-py/tests/test_time_tracker.py
Bossiara13 5265cb0fbe
Some checks are pending
Python CI / pytest (ubuntu-latest) (push) Waiting to run
Python CI / pytest (windows-latest) (push) Waiting to run
Python CI / ruff (push) Waiting to run
feat: Wave 5 Python parity — outlook COM + time tracker
Outlook COM (`outlook_handlers.py`, agent):
- 4 handlers mirroring Rust pack: unread_count, latest, send_clipboard,
  summarize_inbox. Uses `win32com.client` with lazy import so the module
  loads even when pywin32 is missing — handlers degrade gracefully and
  speak a "Outlook недоступен" reply.
- `requirements.txt`: pywin32>=305 (optional).
- 7 unit tests mock `_outlook_session` via unittest.mock.

Time tracker (`time_tracker_handlers.py`, agent):
- 5 handlers: start, stop, today, week, reset. JSON store at
  `<here>/time_tracker.json`, atomic write-then-rename.
- Same shape as the Rust pack's `jarvis.state` blob: current_session_start
  + sessions[{start,end}]. Local-calendar today boundary.
- Full Russian pluralisation (час/часа/часов, минута/минуты/минут).
- 14 unit tests using the existing `isolated_state` fixture in conftest.py.

`extensions.py` + `main.py`: imports + 9 new proxy do_* functions + 9
new dispatch elifs. `commands.yaml`: 9 new entries.

Tests: 60 → 81 (+14 tracker + 7 outlook).
2026-05-16 14:34:44 +03:00

179 lines
6.8 KiB
Python

"""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()