simbiot-harness/tests/test_tg_commands.py

186 lines
No EOL
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""t-260929210755: офлайн-тесты команд бота (hermetic: копия БД, TG мокнут).
Запуск: ./venv/bin/python tests/test_tg_commands.py
Парсер команд, хендлеры, фильтр build_context (команды мин-уровню не видны),
answer_pending (мок send_reply: успех / гонка-ValueError / ошибка TG),
_reflex_tick (ReflexObserved + payload.commands).
"""
import json
import os
import re
import sqlite3
import sys
import time as _t
# ── изоляция: копия боевой БД через online-backup (WAL-консистентно) ──
_SRC = "/root/simbiot-harness/harness/harness.db"
_DST = "/tmp/simbiot-test-tg-commands.db"
for p in (_DST, _DST + "-wal", _DST + "-shm"):
if os.path.exists(p):
os.unlink(p)
s, d = sqlite3.connect(_SRC), sqlite3.connect(_DST)
s.backup(d)
d.close(); s.close()
os.environ["SIMBIOT_DB"] = _DST
sys.path.insert(0, "/root/simbiot-harness")
from harness import es, tg_commands, tg_inbox
from harness.scheduler import build_context, _reflex_tick, LEVELS
fails = []
def check(name, cond, extra=""):
print(("PASS" if cond else "FAIL"), name, ("| " + extra) if not cond else "")
if not cond:
fails.append(name)
# ── 1. парсер: алиасы, регистр, слэш, @бот, пунктуация, не-команды ──
P = tg_commands.parse
check("parse /лестница", P("/лестница") == "лестница")
check("parse ЛЕСТНИЦА верхний регистр", P("/ЛЕСТНИЦА") == "лестница")
check("parse /лестница@juno42bot", P("/лестница@juno42bot") == "лестница")
check("parse ladder транслит", P("/ladder") == "лестница")
check("parse lestnica", P("/lestnica") == "лестница")
check("parse /статус", P("/статус") == "статус")
check("parse /status", P("/status") == "статус")
check("parse /задачи", P("/задачи") == "задачи")
check("parse /tasks", P("/tasks") == "задачи")
check("parse /помощь", P("/помощь") == "помощь")
check("parse /help", P("/help") == "помощь")
check("parse /start", P("/start") == "помощь")
check("parse слово без слэша", P("лестница") == "лестница")
check("parse слово без слэша + пунктуация", P("Лестница!") == "лестница")
check("parse фраза без слэша — НЕ команда", P("лестница покажи") is None)
check("parse обычная речь", P("привет, как дела?") is None)
check("parse пусто", P("") is None)
check("parse /неизвестная", P("/фыва") is None)
check("parse /cmd с аргументом", P("/status сейчас") == "статус")
# ── 2. хендлеры: непустой текст, без исключений, без сырого HTML-рискa ──
es.init_db()
for cmd, fn in tg_commands._HANDLERS.items():
try:
text = fn()
check(f"handler {cmd} непустой", bool(text and text.strip()))
except Exception as e:
check(f"handler {cmd} непустой", False, repr(e))
print("--- /статус ---\n" + tg_commands.cmd_status())
print("--- /помощь ---\n" + tg_commands.cmd_help())
# ── 3. фильтр build_context: команда НЕ попадает мин-уровню ──
model = es.run_projections()
fake = json.loads(json.dumps(model))
fake.setdefault("inbox", {})
# ключи — как у реальных сообщений: aggregate_id = f"m-{update_id}"
fake["inbox"]["m-990001"] = {
"update_id": 990001, "chat_id": 1, "text": "/лестница", "kind": "text",
"replied": False, "ignored": False, "ts": "2026-09-30T02:10:00+00:00",
}
fake["inbox"]["m-990002"] = {
"update_id": 990002, "chat_id": 1, "text": "привет, собери дайджест", "kind": "text",
"replied": False, "ignored": False, "ts": "2026-09-30T02:11:00+00:00",
}
ctx = build_context(LEVELS["min"], fake)
m = re.search(r"ВХОДЯЩИЕ СООБЩЕНИЯ.*?(?:\n\n|$)", ctx, re.S)
inbox_sec = m.group(0) if m else ""
check("build_context: секция инбокса есть", bool(inbox_sec))
check("build_context: команда НЕ в инбоксе мин-уровня", "m-990001" not in inbox_sec)
check("build_context: /лестница НЕ в секции инбокса", "/лестница" not in inbox_sec)
check("build_context: обычное сообщение в инбоксе", "m-990002" in inbox_sec)
check("build_context: текст обычного сообщения виден", "собери дайджест" in inbox_sec)
# на доске задач /лестница встречается в ЗАГОЛОВКЕ текущей задачи — это не инбокс
check("build_context: доска задач не задета фильтром", "ДОСКА ЗАДАЧ" in ctx)
# ── 4. answer_pending: успех / гонка / ошибка отправки ──
sent = []
tg_inbox.send_reply = lambda msg_id, text, timeout=35: (sent.append((msg_id, text)) or msg_id)
tg_inbox.send_message = lambda *a, **k: {"ok": True}
f2 = json.loads(json.dumps(fake))
h = tg_commands.answer_pending(f2)
check("answer_pending: 1 команда отвечена", len(h) == 1 and h[0] == ("m-990001", "лестница"), str(h))
check("answer_pending: отправка была", len(sent) == 1)
f2["inbox"]["m-990001"]["replied"] = True # ReplySent закрыл
sent.clear()
h2 = tg_commands.answer_pending(f2)
check("answer_pending: replied пропускается", h2 == [] and sent == [])
# гонка: мин-уровнь ответил первым → send_reply кидает ValueError → тихий пропуск
sent.clear()
tg_inbox.send_reply = lambda msg_id, text, timeout=35: (_ for _ in ()).throw(
ValueError("на m-990001 уже отвечено"))
h3 = tg_commands.answer_pending(json.loads(json.dumps(fake)))
check("answer_pending: гонка (ValueError) → пропуск без падения", h3 == [] and sent == [])
# ошибка TG: не отвечено, без падения, команда остаётся «без ответа»
sent.clear()
tg_inbox.send_reply = lambda msg_id, text, timeout=35: (_ for _ in ()).throw(
OSError("TG unreachable"))
h4 = tg_commands.answer_pending(json.loads(json.dumps(fake)))
check("answer_pending: ошибка TG → неотвечено, без падения", h4 == [] and sent == [])
# cooldown: сразу после ошибки повтор не дергает TG (рефлекс тикает каждую секунду)
sent.clear()
h5 = tg_commands.answer_pending(json.loads(json.dumps(fake)))
check("answer_pending: cooldown — повтор не раньше минуты, без отправки",
h5 == [] and sent == [])
# и это не «молчание навсегда»: после истечения cooldown попытка повторяется
tg_commands._last_fail_ts = _t.time() - 61
tg_inbox.send_reply = lambda msg_id, text, timeout=35: (sent.append((msg_id, text)) or msg_id)
h6 = tg_commands.answer_pending(json.loads(json.dumps(fake)))
check("answer_pending: после cooldown попытка повторяется", len(h6) == 1, str(h6))
# сброс: сек-тик ниже гоняем в нормальном режиме
tg_commands._last_fail_ts = 0.0
# ── 5. _reflex_tick: ReflexObserved пишется, команды в payload, идемпотентность ──
import time as _t
cfg = LEVELS["sec"]
# seq в будущем: reflex:<seq> под partial UNIQUE-индексом cycle-агрегата;
# текущая секунда может коллизить с реальным сек-тиком, пойманным в копию БД
seq = int(_t.time()) + 3600
# восстановливаем success-мок (выше проверяли ошибки — теперь нормальный путь)
sent.clear()
tg_inbox.send_reply = lambda msg_id, text, timeout=35: (sent.append((msg_id, text)) or msg_id)
# изолированное входящее для сек-тика: свой id → CommandHandled только отсюда
fake3 = json.loads(json.dumps(model))
fake3.setdefault("inbox", {})
fake3["inbox"]["m-990003"] = {
"update_id": 990003, "chat_id": 1, "text": "/status", "kind": "text",
"replied": False, "ignored": False, "ts": "2026-09-30T02:20:00+00:00",
}
before = es.count_events()
payload, task_id = _reflex_tick(cfg, seq, json.loads(json.dumps(fake3)))
check("_reflex_tick: ReflexObserved + CommandHandled (2 события)",
es.count_events() == before + 2, str(es.count_events() - before))
check("_reflex_tick: команда в payload", payload.get("commands") == ["статус"], str(payload.get("commands")))
check("_reflex_tick: телеметрия на месте", payload.get("load1") is not None)
# производственная семантика повтора: ReplySent закрыл входящее (replied=True
# выставляет проекция ReplySent — мок send_reply этого не делает, симулируем),
# второй вызов тем же seq: reflex — дубль под UNIQUE, команда — replied → 0 событий
fake3["inbox"]["m-990003"]["replied"] = True
sent.clear()
before2 = es.count_events()
_reflex_tick(cfg, seq, json.loads(json.dumps(fake3)))
check("_reflex_tick: повтор того же seq (replied) — событий 0",
es.count_events() == before2, str(es.count_events() - before2))
# CommandHandled: ровно 1 на успешный ответ сек-тика (id изолирован)
db = es._conn()
rows = db.execute(
"SELECT COUNT(*) c FROM events WHERE event_type='CommandHandled' "
"AND aggregate_id='m-990003'").fetchone()
db.close()
check("CommandHandled: 1 событие на успешный ответ", rows["c"] == 1, str(rows["c"]))
print()
if fails:
print("ПРОВАЛЕНО:", fails)
sys.exit(1)
print("ВСЕ ТЕСТЫ ПРОЙДЕНЫ (hermetic, БД:", _DST + ")")