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).
179 lines
6.8 KiB
Python
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()
|