186 lines
No EOL
10 KiB
Python
186 lines
No EOL
10 KiB
Python
# -*- 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 + ")") |