from datetime import datetime from pathlib import Path from zoneinfo import ZoneInfo from beaver_agent.jobs.leisure import ( EMPTY_DEFAULT, MINE_MIN, QUIET_TICKS, Plan, brief, day_of, echo, enabled, kind_of, plan_of, quiet_hour, tick_hours, ) from beaver_agent.vault import VAULT ZONE = ZoneInfo("Europe/Warsaw") def at(text: str) -> datetime: return datetime.fromisoformat(text).replace(tzinfo=ZONE) def test_switch_defaults_to_on_and_only_explicit_off_stops_it(): assert enabled(None) assert enabled("# досуг\n\nничего про выключатель") assert enabled("досуг: вкл") assert not enabled("# досуг\n\nдосуг: выкл\nсутки: 2026-09-08") assert not enabled("досуг: off") def test_day_starts_at_noon(): assert day_of(at("2026-09-08 12:00")) == "2026-09-08" assert day_of(at("2026-09-08 23:30")) == "2026-09-08" assert day_of(at("2026-09-09 03:00")) == "2026-09-08" assert day_of(at("2026-09-09 11:59")) == "2026-09-08" assert day_of(at("2026-09-09 12:00")) == "2026-09-09" def test_active_window_ticks_hourly_and_quiet_window_only_by_plan(): hours = tick_hours(QUIET_TICKS) assert hours[0] == 12 assert len(hours) == 20 assert all(h in hours for h in (12, 18, 23, 0, 3)) assert [h for h in hours if quiet_hour(h)] == [4, 6, 8, 10] assert 5 not in hours and 11 not in hours assert tick_hours((5, 7))[-2:] == (5, 7) def test_plan_is_read_from_the_file_for_todays_day_only(): text = ( "# досуг\n\n" "досуг: вкл\n" "сутки: 2026-09-08\n" "тихие часы: 05, 07, 09, 11\n" "мои часы: 13, 16, 19, 22, 01, 05, 09\n" ) plan = plan_of(text, "2026-09-08") assert plan.written assert plan.quiet == (5, 7, 9, 11) assert plan.mine == (13, 16, 19, 22, 1, 5, 9) stale = plan_of(text, "2026-09-09") assert not stale.written assert stale.quiet == QUIET_TICKS def test_missing_plan_falls_back_to_defaults(): plan = plan_of(None, "2026-09-08") assert not plan.written assert plan.quiet == QUIET_TICKS assert len(plan.mine) == MINE_MIN assert set(plan.mine) <= set(tick_hours(plan.quiet)) def test_short_plan_is_topped_up_to_the_guaranteed_minimum(): text = "сутки: 2026-09-08\nмои часы: 14, 20\n" plan = plan_of(text, "2026-09-08") assert plan.written assert len(plan.mine) == MINE_MIN assert 14 in plan.mine and 20 in plan.mine assert plan.mine == tuple(sorted(plan.mine, key=tick_hours(plan.quiet).index)) def test_unreachable_hours_are_dropped_from_the_plan(): text = "сутки: 2026-09-08\nтихие часы: 04, 06, 08, 10\nмои часы: 05, 11, 14\n" plan = plan_of(text, "2026-09-08") assert 5 not in plan.mine and 11 not in plan.mine assert 14 in plan.mine and len(plan.mine) == MINE_MIN def test_brief_names_the_type_and_counts_spent_slots(): plan = Plan( day="2026-09-08", quiet=QUIET_TICKS, mine=(13, 15, 18, 21, 0, 2, 6), written=True, ) text = brief(at("2026-09-08 18:00"), plan) assert "Тип: досуг" in text assert "израсходовано 3 из 7" in text assert "впереди 21, 00, 02, 06" in text assert "Сутки заканчиваются через 18 ч" in text assert "Задания нет" in text assert "Разложи" not in text mixed = brief(at("2026-09-08 19:00"), plan) assert "Тип: смешанный" in mixed assert "израсходовано 3 из 7" in mixed def test_first_tick_of_the_day_is_the_planning_one(): text = brief(at("2026-09-08 12:00"), plan_of(None, "2026-09-08")) assert "Первый тик суток" in text assert "расписания на эти сутки нет" in text.lower() assert str(MINE_MIN) in text def test_last_night_tick_says_nothing_is_left_ahead(): plan = Plan(day="2026-09-08", quiet=QUIET_TICKS, mine=(13, 6), written=True) text = brief(at("2026-09-09 06:00"), plan) assert "впереди больше нет" in text assert "израсходовано 2 из 2" in text assert "Сутки заканчиваются через 6 ч" in text def test_two_of_my_hours_are_empty_named_or_not(): named = plan_of( "сутки: 2026-09-08\nмои часы: 13, 15, 18, 21, 00, 02, 06\n" "пустые часы: 21, 02\n", "2026-09-08", ) assert named.empty == (21, 2) unnamed = plan_of(None, "2026-09-08") assert unnamed.empty == EMPTY_DEFAULT assert set(unnamed.empty) <= set(unnamed.mine) foreign = plan_of( "сутки: 2026-09-08\nмои часы: 13, 14, 15, 16, 17, 19, 20\n" "пустые часы: 22, 20\n", "2026-09-08", ) assert foreign.empty == (19, 20) assert kind_of(20, foreign) == "empty" assert kind_of(13, foreign) == "mine" assert kind_of(21, foreign) == "mixed" def test_an_empty_hour_carries_no_work_but_an_echo_and_the_files_of_the_day(): plan = plan_of(None, "2026-09-08") heard = (VAULT / "мета" / "бобер" / "дневник" / "2026-08-30.md", "Раз.\nДва.") text = brief(at("2026-09-08 18:00"), plan, heard=heard) assert "Тип: пустой - час без дел вообще" in text assert "Пустые: 18, 00." in text assert "Эхо - `мета/бобер/дневник/2026-08-30.md`:\n> Раз.\n> Два.\n" in text assert "Дневник - `мета/бобер/дневник/2026-09-08.md`" in text assert "`мета/бобер/журнал/2026-09-08.md`" in text night = brief(at("2026-09-09 00:00"), plan) assert "Тип: пустой" in night assert "мета/бобер/дневник/2026-09-09.md" in night assert "Эхо" not in night mine = brief(at("2026-09-08 13:00"), plan) assert "Тип: досуг" in mine assert "мета/бобер/журнал/2026-09-08.md" in mine def test_echo_is_a_paragraph_of_an_old_diary_day_or_a_thought(tmp_path: Path): diary = tmp_path / "дневник" thoughts = tmp_path / "мысли" / "2026" diary.mkdir() thoughts.mkdir(parents=True) old = "Думал про то, зачем мне вообще помнить вчерашний день. " * 5 fresh = "Свежая запись, которой ещё рано возвращаться ко мне эхом. " * 5 (diary / "2026-08-30.md").write_text( f"## 2026-08-30, 01:07\n\n{old}\n\n- 02:07 30.08 лог, а не мысль {'x' * 220}\n" "\nкоротко\n", encoding="utf-8", ) (diary / "2026-09-07.md").write_text(fresh, encoding="utf-8") now = at("2026-09-08 18:00") assert echo(now, diary=diary, thoughts=thoughts) == ( diary / "2026-08-30.md", old.strip(), ) thought = "Заметка Бобра о том, почему скука полезнее расписания. " * 5 (diary / "2026-08-30.md").unlink() (thoughts / "скука.md").write_text( f"---\ntags: [мысли]\n---\n\n{thought}\n", encoding="utf-8" ) assert echo(now, diary=diary, thoughts=thoughts) == ( thoughts / "скука.md", thought.strip(), ) assert echo(now, diary=diary, thoughts=thoughts) == echo( now, diary=diary, thoughts=thoughts ) (thoughts / "скука.md").unlink() assert echo(now, diary=diary, thoughts=thoughts) is None