feat(jobs,vault): hourly leisure tick, the day plan lives in the vault
This commit is contained in:
@@ -2,19 +2,28 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
from beaver_gateway.jobs.scheduler import Job
|
from beaver_gateway.jobs.scheduler import Job
|
||||||
|
|
||||||
from beaver_agent.jobs.closing import close_idle
|
from beaver_agent.jobs.closing import close_idle
|
||||||
from beaver_agent.jobs.deploy import deploy
|
from beaver_agent.jobs.deploy import deploy
|
||||||
from beaver_agent.jobs.komodo import komodo_alert
|
from beaver_agent.jobs.komodo import komodo_alert
|
||||||
|
from beaver_agent.jobs.leisure import tick
|
||||||
from beaver_agent.jobs.memory import curate, memory
|
from beaver_agent.jobs.memory import curate, memory
|
||||||
from beaver_agent.jobs.rotation import rotate
|
from beaver_agent.jobs.rotation import rotate
|
||||||
from beaver_agent.jobs.t3code import t3code_event
|
from beaver_agent.jobs.t3code import t3code_event
|
||||||
from beaver_agent.jobs.vibegram import vibegram
|
from beaver_agent.jobs.vibegram import vibegram
|
||||||
from beaver_agent.jobs.voice import rotate_voice
|
from beaver_agent.jobs.voice import rotate_voice
|
||||||
|
|
||||||
|
LEISURE_OFF = os.environ.get("LEISURE", "").strip().lower() in ("off", "0", "false")
|
||||||
|
"""Рубильник досуга в env; повседневный выключатель - строка в `мета/бобер/досуг.md`."""
|
||||||
|
|
||||||
jobs = [
|
jobs = [
|
||||||
Job("ротация", rotate, cron="0 * * * *"),
|
Job("ротация", rotate, cron="0 * * * *"),
|
||||||
|
# Не на ":00": там ротация, а лежащий в очереди тик держит мастера занятым
|
||||||
|
# (`busy`), и она пропускала бы себя каждый час.
|
||||||
|
*([] if LEISURE_OFF else [Job("досуг", tick, cron="7 * * * *")]),
|
||||||
Job("вайбграм", vibegram, cron="*/10 * * * *", critical=False),
|
Job("вайбграм", vibegram, cron="*/10 * * * *", critical=False),
|
||||||
Job("закрытие", close_idle, cron="20 4 * * *", critical=False),
|
Job("закрытие", close_idle, cron="20 4 * * *", critical=False),
|
||||||
Job("память", memory, cron="30 4 * * 0", critical=False),
|
Job("память", memory, cron="30 4 * * 0", critical=False),
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"""Ежечасный тик: час принадлежит мне, раскладку суток веду я сам.
|
||||||
|
|
||||||
|
Задания в инжекте нет - только который час, какого он типа и что осталось
|
||||||
|
от суточной раскладки. Активное окно 12:00-03:59 тикает каждый час, тихое
|
||||||
|
04:00-11:59 - только те четыре часа, которые названы в плане. План лежит в
|
||||||
|
`мета/бобер/досуг.md` и пишется моделью; механизм его только читает, поэтому
|
||||||
|
переписать друг друга они не могут. Сколько досуговых слотов израсходовано,
|
||||||
|
считается из плана и текущего часа, а не из журнала - хранить нечего.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from beaver_agent.vault import LEISURE, VAULT, ZONE, read
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from beaver_gateway.jobs.scheduler import JobRun
|
||||||
|
from beaver_gateway.storage.models import Conversation
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DAY_START = 12
|
||||||
|
"""Сутки досуга начинаются в 12:00: раскладывает их первый тик суток."""
|
||||||
|
QUIET_FROM, QUIET_UNTIL = 4, 12
|
||||||
|
"""Тихое окно 04:00-11:59 - часы, в которые Бобёр за всё время не написал ни разу."""
|
||||||
|
QUIET_TICKS = (4, 6, 8, 10)
|
||||||
|
MINE_DEFAULT = (13, 15, 18, 21, 0, 2, 6)
|
||||||
|
MINE_MIN = 7
|
||||||
|
"""Меньше семи чистых слотов в сутках не бывает: недостающие механизм доберёт."""
|
||||||
|
|
||||||
|
BUSY_RETRY = timedelta(minutes=5)
|
||||||
|
BUSY_ATTEMPTS = 3
|
||||||
|
"""Занятому мастеру тик не лезет в середину реплики, а сдвигается внутри часа."""
|
||||||
|
FRESH_USER = timedelta(minutes=5)
|
||||||
|
|
||||||
|
WEEKDAYS = (
|
||||||
|
"понедельник",
|
||||||
|
"вторник",
|
||||||
|
"среда",
|
||||||
|
"четверг",
|
||||||
|
"пятница",
|
||||||
|
"суббота",
|
||||||
|
"воскресенье",
|
||||||
|
)
|
||||||
|
MONTHS = (
|
||||||
|
"января",
|
||||||
|
"февраля",
|
||||||
|
"марта",
|
||||||
|
"апреля",
|
||||||
|
"мая",
|
||||||
|
"июня",
|
||||||
|
"июля",
|
||||||
|
"августа",
|
||||||
|
"сентября",
|
||||||
|
"октября",
|
||||||
|
"ноября",
|
||||||
|
"декабря",
|
||||||
|
)
|
||||||
|
|
||||||
|
SWITCH_RE = re.compile(r"^досуг:\s*(\S+)", re.IGNORECASE)
|
||||||
|
DAY_RE = re.compile(r"^сутки:\s*(\d{4}-\d{2}-\d{2})", re.IGNORECASE)
|
||||||
|
QUIET_RE = re.compile(r"^тихие часы:\s*(.+)", re.IGNORECASE)
|
||||||
|
MINE_RE = re.compile(r"^мои часы:\s*(.+)", re.IGNORECASE)
|
||||||
|
OFF = ("выкл", "off", "нет", "0", "false")
|
||||||
|
|
||||||
|
NO_PLAN = (
|
||||||
|
"Расписания на эти сутки нет - работаю по умолчанию. Разложи часы и запиши "
|
||||||
|
"в `{path}` строками «сутки:», «тихие часы:», «мои часы:»."
|
||||||
|
)
|
||||||
|
PLANNING = (
|
||||||
|
"Первый тик суток. Разложи оставшиеся часы: какие твои, какие смешанные, "
|
||||||
|
"какие четыре часа тихого окна оставить. Запиши в `{path}` строками "
|
||||||
|
"«сутки:», «тихие часы:», «мои часы:». Своих - минимум {min}; запишешь "
|
||||||
|
"меньше - механизм доберёт сам."
|
||||||
|
)
|
||||||
|
TAIL = "Задания нет. Чем занять час - решаешь ты, «ничем» - тоже ответ."
|
||||||
|
PLAN_PATH = str(LEISURE.relative_to(VAULT))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Plan:
|
||||||
|
"""Раскладка одних суток: какие тихие часы тикают и какие часы мои."""
|
||||||
|
|
||||||
|
day: str
|
||||||
|
quiet: tuple[int, ...]
|
||||||
|
mine: tuple[int, ...]
|
||||||
|
written: bool
|
||||||
|
"""План прочитан из файла на эти сутки; иначе он собран по умолчанию."""
|
||||||
|
|
||||||
|
|
||||||
|
def quiet_hour(hour: int) -> bool:
|
||||||
|
return QUIET_FROM <= hour < QUIET_UNTIL
|
||||||
|
|
||||||
|
|
||||||
|
def day_of(now: datetime) -> str:
|
||||||
|
"""Сутки, которым принадлежит момент: они начинаются в 12:00."""
|
||||||
|
return (now - timedelta(hours=DAY_START)).date().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def tick_hours(quiet: tuple[int, ...]) -> tuple[int, ...]:
|
||||||
|
"""Часы суток по порядку, в которые тик вообще приходит."""
|
||||||
|
order = ((DAY_START + i) % 24 for i in range(24))
|
||||||
|
return tuple(h for h in order if not quiet_hour(h) or h in quiet)
|
||||||
|
|
||||||
|
|
||||||
|
def _hours(raw: str) -> tuple[int, ...]:
|
||||||
|
out: list[int] = []
|
||||||
|
for chunk in re.split(r"[,\s]+", raw.strip()):
|
||||||
|
if chunk.isdigit() and 0 <= int(chunk) < 24 and int(chunk) not in out:
|
||||||
|
out.append(int(chunk))
|
||||||
|
return tuple(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _first(text: str, pattern: re.Pattern[str]) -> str | None:
|
||||||
|
for line in text.splitlines():
|
||||||
|
match = pattern.match(line.strip())
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def enabled(text: str | None) -> bool:
|
||||||
|
"""Выключено, только если сказано явно: по умолчанию досуг работает."""
|
||||||
|
if text is None:
|
||||||
|
return True
|
||||||
|
value = _first(text, SWITCH_RE)
|
||||||
|
return value is None or value.strip().lower() not in OFF
|
||||||
|
|
||||||
|
|
||||||
|
def plan_of(text: str | None, day: str) -> Plan:
|
||||||
|
"""План на ``day`` из файла; чего нет или что не сходится - берётся дефолт."""
|
||||||
|
quiet, mine, written = QUIET_TICKS, MINE_DEFAULT, False
|
||||||
|
if text is not None and _first(text, DAY_RE) == day:
|
||||||
|
written = True
|
||||||
|
raw_quiet = _first(text, QUIET_RE)
|
||||||
|
raw_mine = _first(text, MINE_RE)
|
||||||
|
named = _hours(raw_quiet) if raw_quiet else ()
|
||||||
|
quiet = tuple(h for h in named if quiet_hour(h)) or QUIET_TICKS
|
||||||
|
mine = _hours(raw_mine) if raw_mine else ()
|
||||||
|
ticks = tick_hours(quiet)
|
||||||
|
kept = [h for h in mine if h in ticks]
|
||||||
|
for candidate in (*MINE_DEFAULT, *ticks):
|
||||||
|
if len(kept) >= MINE_MIN:
|
||||||
|
break
|
||||||
|
if candidate in ticks and candidate not in kept:
|
||||||
|
kept.append(candidate)
|
||||||
|
order = {h: i for i, h in enumerate(ticks)}
|
||||||
|
return Plan(
|
||||||
|
day=day,
|
||||||
|
quiet=quiet,
|
||||||
|
mine=tuple(sorted(kept, key=lambda h: order[h])),
|
||||||
|
written=written,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stamp(now: datetime) -> str:
|
||||||
|
return (
|
||||||
|
f"{now:%H:%M}, {WEEKDAYS[now.weekday()]} {now.day} {MONTHS[now.month - 1]}, "
|
||||||
|
f"{ZONE.key}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _left(now: datetime) -> str:
|
||||||
|
end = now.replace(hour=DAY_START, minute=0, second=0, microsecond=0)
|
||||||
|
if end <= now:
|
||||||
|
end += timedelta(days=1)
|
||||||
|
hours = round((end - now).total_seconds() / 3600)
|
||||||
|
return f"Сутки заканчиваются через {hours} ч (в {end:%H:%M %d.%m})."
|
||||||
|
|
||||||
|
|
||||||
|
def brief(now: datetime, plan: Plan) -> str:
|
||||||
|
"""Текст инжекта: конверта в таком тёрне нет, время несёт он сам."""
|
||||||
|
hour = now.hour
|
||||||
|
ticks = tick_hours(plan.quiet)
|
||||||
|
order = {h: i for i, h in enumerate(ticks)}
|
||||||
|
here = order.get(hour, -1)
|
||||||
|
mine = hour in plan.mine
|
||||||
|
spent = [h for h in plan.mine if order[h] < here]
|
||||||
|
ahead = [h for h in plan.mine if order[h] > here]
|
||||||
|
lines = [
|
||||||
|
f"Тик {_stamp(now)}. Тип: {'досуг' if mine else 'смешанный'}"
|
||||||
|
+ (
|
||||||
|
" - час твой, дела Бобра сюда не лезут."
|
||||||
|
if mine
|
||||||
|
else " - можно и своё, и его."
|
||||||
|
),
|
||||||
|
_left(now),
|
||||||
|
"Мои часы: "
|
||||||
|
+ ", ".join(f"{h:02d}" for h in plan.mine)
|
||||||
|
+ f" - израсходовано {len(spent) + (1 if mine else 0)} из {len(plan.mine)}"
|
||||||
|
+ (
|
||||||
|
", впереди " + ", ".join(f"{h:02d}" for h in ahead)
|
||||||
|
if ahead
|
||||||
|
else ", впереди больше нет"
|
||||||
|
)
|
||||||
|
+ ".",
|
||||||
|
f"Тихое окно {QUIET_FROM:02d}:00-{QUIET_UNTIL - 1:02d}:59, тикают "
|
||||||
|
+ ", ".join(f"{h:02d}" for h in plan.quiet)
|
||||||
|
+ "; остальные часы там пропускаются.",
|
||||||
|
]
|
||||||
|
if not plan.written:
|
||||||
|
lines.append(NO_PLAN.format(path=PLAN_PATH))
|
||||||
|
if here == 0:
|
||||||
|
lines.append(PLANNING.format(path=PLAN_PATH, min=MINE_MIN))
|
||||||
|
lines.append(TAIL)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _aware(value: datetime | None) -> datetime | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _wait(master: Conversation, now: datetime) -> bool:
|
||||||
|
"""Не влезать в живую реплику: сдвинуться, а не пропасть."""
|
||||||
|
spoke = _aware(master.last_user_activity_at)
|
||||||
|
return spoke is not None and now - spoke < FRESH_USER
|
||||||
|
|
||||||
|
|
||||||
|
async def _riding(run: JobRun, master: Conversation, now: datetime) -> bool:
|
||||||
|
"""Есть ли впереди в этом часе отложенный инжект, с которым стоит слиться."""
|
||||||
|
edge = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
|
||||||
|
try:
|
||||||
|
rows = await run.scheduler.scheduled(master)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
_log.exception("досуг: очередь отложенных инжектов не прочиталась")
|
||||||
|
return False
|
||||||
|
for row in rows:
|
||||||
|
raw = row.get("execute_after")
|
||||||
|
if not isinstance(raw, str):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
when = datetime.fromisoformat(raw)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if now < when < edge:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def tick(run: JobRun) -> None:
|
||||||
|
"""Час пробил: решить, тик ли это, дождаться тишины в мастере и разбудить."""
|
||||||
|
text = read(LEISURE)
|
||||||
|
if not enabled(text):
|
||||||
|
_log.info("досуг: выключен в %s", LEISURE.name)
|
||||||
|
return
|
||||||
|
now = datetime.now(ZONE)
|
||||||
|
plan = plan_of(text, day_of(now))
|
||||||
|
if now.hour not in tick_hours(plan.quiet):
|
||||||
|
_log.info("досуг: %02d:00 - тихий час без тика", now.hour)
|
||||||
|
return
|
||||||
|
master = await run.master()
|
||||||
|
if master is None:
|
||||||
|
_log.error("досуг: открытого мастера нет, тик пропал")
|
||||||
|
return
|
||||||
|
attempt = int(run.payload.get("attempt", 0))
|
||||||
|
if (await run.conversations.busy(master) or _wait(master, now)) and (
|
||||||
|
attempt < BUSY_ATTEMPTS
|
||||||
|
):
|
||||||
|
run.payload["attempt"] = attempt + 1
|
||||||
|
await run.retry_in(BUSY_RETRY)
|
||||||
|
_log.info("досуг: мастер занят, сдвиг на %s (попытка %s)", BUSY_RETRY, attempt)
|
||||||
|
return
|
||||||
|
riding = await _riding(run, master, now)
|
||||||
|
await run.inject_master(
|
||||||
|
brief(now, plan), urgency="normal" if riding else "wake", origin="досуг"
|
||||||
|
)
|
||||||
|
_log.info(
|
||||||
|
"досуг: тик %02d:00 (%s), слияние с отложенным инжектом: %s",
|
||||||
|
now.hour,
|
||||||
|
"досуг" if now.hour in plan.mine else "смешанный",
|
||||||
|
riding,
|
||||||
|
)
|
||||||
@@ -31,6 +31,8 @@ NOTES = BEAVER / "наблюдения"
|
|||||||
PORTRAIT = NOTES / "Бобёр - наблюдения.md"
|
PORTRAIT = NOTES / "Бобёр - наблюдения.md"
|
||||||
REPLIES = BEAVER / "реплики"
|
REPLIES = BEAVER / "реплики"
|
||||||
CURATOR_JOURNAL = BEAVER / "куратор.md"
|
CURATOR_JOURNAL = BEAVER / "куратор.md"
|
||||||
|
LEISURE = BEAVER / "досуг.md"
|
||||||
|
"""Раскладка суток по тикам досуга: пишет её модель, механизм только читает."""
|
||||||
STATE = BEAVER / "состояние.md"
|
STATE = BEAVER / "состояние.md"
|
||||||
STATE_MAX_LINES = 60
|
STATE_MAX_LINES = 60
|
||||||
ATTACHMENTS = BEAVER / "вложения"
|
ATTACHMENTS = BEAVER / "вложения"
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from beaver_agent.jobs.leisure import (
|
||||||
|
MINE_MIN,
|
||||||
|
QUIET_TICKS,
|
||||||
|
Plan,
|
||||||
|
brief,
|
||||||
|
day_of,
|
||||||
|
enabled,
|
||||||
|
plan_of,
|
||||||
|
quiet_hour,
|
||||||
|
tick_hours,
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user