# -*- 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: под 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 + ")")