Files
hlamingo/tests/test_all.py
T
alexandClaude Opus 5 781690365e Без Ollama приложение больше не выглядит зависшим
Проверка работы с недоступной Ollama показала: если машина с ней просто
выключена, пакеты уходят в никуда и каждый вызов честно ждёт свой таймаут.
Замер: запись одной вещи — 92 секунды, разбор фразы — 154, правка — 30.
Формально всё работало, но пользоваться этим невозможно.

Предохранитель в ollama.py:
- перед дорогим запросом быстрая проверка TCP-соединения (1.5 с вместо 120);
- после неудачи 30 секунд к серверу вообще не обращаемся;
- при успехе результат кешируется на 5 секунд, чтобы не проверять на каждом
  шаге;
- смена адреса в настройках и проба адреса сбрасывают паузу, иначе новый
  сервер полминуты считался бы мёртвым.

Замер после: те же операции отвечают мгновенно, первая проверка — 1.5 с.
Проверено в двух режимах недоступности: закрытый порт и молчащий адрес.

Тесты закрепляют поведение: запись, разбор фразы и поиск при мёртвом адресе
обязаны укладываться в 3 секунды и работать по существу — вещь записывается
без тегов, фраза разбирается регулярками, поиск идёт по словам.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:56:46 +03:00

290 lines
16 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 -*-
"""Проверки hlamingo. Работают ТОЛЬКО в песочнице — см. tests/guard.py.
Запуск: tests\run.cmd (или venv\Scripts\python.exe tests\test_all.py)
Часть проверок требует Ollama; без него они пропускаются, а не падают.
"""
import io
import os
import sys
import struct
import zlib
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from guard import sandbox, Report # noqa: E402
SANDBOX = sandbox() # обязательно ДО импорта hlamingo
from hlamingo import store, search, brain, ollama, config # noqa: E402
from hlamingo.app import app # noqa: E402
if sys.version_info >= (3, 7):
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
r = Report()
c = app.test_client()
HAS_AI = ollama.check()
def j(method, url, **body):
return c.open(url, method=method, json=body or None).get_json()
def png():
def ch(t, d):
raw = t + d
return struct.pack(">I", len(d)) + raw + struct.pack(">I", zlib.crc32(raw) & 0xffffffff)
return (b"\x89PNG\r\n\x1a\n" + ch(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0))
+ ch(b"IDAT", zlib.compress(b"\x00\x00\x00\x00")) + ch(b"IEND", b""))
def upload(target, ident):
field = "id" if target == "item" else "code"
return c.post("/api/photo", content_type="multipart/form-data",
data={"target": target, field: ident,
"photo": (io.BytesIO(png()), "x.png")}).get_json()
print("Песочница: %s" % SANDBOX)
print("Ollama: %s\n" % ("доступен" if HAS_AI else "нет — часть проверок пропущу"))
# ============================================================
print("== Места: иерархия ==")
garage = j("POST", "/api/places", name="Гараж")["id"]
rack = j("POST", "/api/places", name="Стеллаж А", parent=garage)["id"]
shelf = j("POST", "/api/places", name="Полка 2", parent=rack)["id"]
pantry = j("POST", "/api/places", name="Кладовка")["id"]
d = c.get("/api/places").get_json()
r.check("путь собирается", any(p["path"] == "Гараж → Стеллаж А → Полка 2" for p in d["places"]), d)
r.check("повтор не плодит дубли",
j("POST", "/api/places", name="Стеллаж А", parent=garage)["id"] == rack)
r.check("чужой родитель — 404",
c.post("/api/places", json={"name": "X", "parent": "нет"}).status_code == 404)
print("\n== Ёмкости ==")
d = j("POST", "/api/containers", code="a3", place=shelf)
r.check("код к верхнему регистру", d["code"] == "A3", d)
r.check("адрес коробки", d["address"] == "Гараж → Стеллаж А → Полка 2 → коробка A3", d)
r.check("кириллическая А3 = латинская A3", store.norm_code("А3") == "A3")
print("\n== Положить ==")
d = j("POST", "/api/put", name="Стабилизатор 7805", qty="12 шт", place=shelf, container="A3")
item = d["item"]["id"]
r.check("записалось", d.get("ok"), d)
r.check("адрес", d["item"]["address"].endswith("коробка A3"), d["item"]["address"])
if HAS_AI:
r.check("синонимы от модели", len(d["item"]["aliases"]) > 0, d["item"]["aliases"])
d = j("POST", "/api/put", names=["отвёртка", "плоскогубцы"], place=shelf, container="A3")
r.check("две вещи разом", len(d["items"]) == 2, d)
d = j("POST", "/api/put", name="лопата", place=garage)
r.check("вещь без коробки", d["item"]["address"] == "Гараж", d["item"]["address"])
print("\n== Поиск ==")
r.check("по названию", c.get("/api/find?q=7805").get_json()["items"][0]["id"] == item)
r.check("по числу словоформ", c.get("/api/find?q=отвёртки").get_json()["items"], "отвёртки")
r.check("чужого не находит", c.get("/api/find?q=картошка").get_json()["items"] == [])
if HAS_AI:
hits = c.get("/api/find?q=стабилизатор на 5 вольт").get_json()["items"]
r.check("по смыслу через синонимы", hits and hits[0]["id"] == item, hits)
print("\n== Забрать и переместить ==")
d = j("POST", "/api/take", id=item)
r.check("на руках", d["item"]["state"] == "taken", d["item"])
r.check("из коробки исчез",
all(i["id"] != item for i in c.get("/api/container/A3").get_json()["items"]))
r.check("но находится", "на руках" in c.get("/api/find?q=7805").get_json()["items"][0]["address"])
d = j("POST", "/api/move", id=item, container="A3", place=shelf)
r.check("вернулся в коробку", d["item"]["state"] == "stored", d["item"])
r.check("без назначения — 400",
c.post("/api/move", json={"id": item}).status_code == 400)
print("\n== Правка предмета ==")
d = j("PATCH", "/api/item/" + item, name="Стабилизатор 7805 (TO-220)", qty="10 шт", note="в ленте")
r.check("поля обновились",
d["item"]["name"].endswith("(TO-220)") and d["item"]["qty"] == "10 шт", d["item"])
d = j("PATCH", "/api/item/" + item, aliases=["7805", "LM7805"])
r.check("теги заменяются", d["item"]["aliases"] == ["7805", "LM7805"], d["item"]["aliases"])
d = j("PATCH", "/api/item/" + item, aliases="строкой, через, запятые")
r.check("теги строкой", len(d["item"]["aliases"]) == 3, d["item"]["aliases"])
r.check("пустое имя — 400",
c.open("/api/item/" + item, method="PATCH", json={"name": " "}).status_code == 400)
r.check("занятое имя — 400",
c.open("/api/item/" + item, method="PATCH", json={"name": "лопата"}).status_code == 400)
print("\n== Фото ==")
d = upload("item", item)
fname = d["photo"]
r.check("файл на диске", (config.PHOTOS_DIR / fname).exists(), config.PHOTOS_DIR)
r.check("имя своё, не клиентское", "x" != fname[0], fname)
r.check("видно в карточке", c.get("/api/find?q=7805").get_json()["items"][0]["photo"] == fname)
d = j("DELETE", "/api/photo", target="item", id=item)
r.check("фото убрано", d.get("ok"), d)
r.check("файл удалён", not (config.PHOTOS_DIR / fname).exists())
r.check("повторно — не ошибка", j("DELETE", "/api/photo", target="item", id=item).get("ok"))
d = upload("container", "A3")
r.check("фото коробки", c.get("/api/container/A3").get_json()["photo"] == d["photo"], d)
r.check("битый формат — 400",
c.post("/api/photo", content_type="multipart/form-data",
data={"target": "item", "id": item,
"photo": (io.BytesIO(b"MZ"), "../evil.exe")}).status_code == 400)
print("\n== Правка ёмкости ==")
d = j("PATCH", "/api/containers/A3", kind="ящик", place=pantry, note="синий")
r.check("тип и место", d["address"] == "Кладовка → ящик A3", d)
d = j("PATCH", "/api/containers/A3", code="C5")
r.check("код изменён", d["code"] == "C5", d)
r.check("вещи переехали с кодом", len(c.get("/api/container/C5").get_json()["items"]) == 3,
c.get("/api/container/C5").get_json())
r.check("старого кода нет", c.get("/api/container/A3").get_json()["items"] == [])
j("POST", "/api/containers", code="D1", place=garage)
r.check("занятый код — 400",
c.open("/api/containers/C5", method="PATCH", json={"code": "D1"}).status_code == 400)
print("\n== Удаление ёмкости не теряет вещи ==")
d = j("DELETE", "/api/containers/C5")
r.check("вещи переехали в её место", len(d["moved"]) == 3, d)
r.check("вещь теперь в месте коробки",
c.get("/api/find?q=7805").get_json()["items"][0]["address"] == "Кладовка")
print("\n== Правка места ==")
r.check("переименование", j("PATCH", "/api/places/" + shelf, name="Полка 5")["path"]
== "Гараж → Стеллаж А → Полка 5")
r.check("сам в себя — 400",
c.open("/api/places/" + garage, method="PATCH",
json={"parent": garage}).status_code == 400)
r.check("внутрь потомка — 400",
c.open("/api/places/" + garage, method="PATCH",
json={"parent": shelf}).status_code == 400)
r.check("непустое не удалить",
c.open("/api/places/" + garage, method="DELETE").status_code == 409)
empty = j("POST", "/api/places", name="Балкон")["id"]
r.check("пустое удаляется", j("DELETE", "/api/places/" + empty).get("ok"))
print("\n== Удаление предмета ==")
d = upload("item", item)
fname = d["photo"]
r.check("удалён", c.delete("/api/item/" + item).get_json().get("ok"))
r.check("фото тоже убрано", not (config.PHOTOS_DIR / fname).exists())
r.check("404 на несуществующем", c.delete("/api/item/нет").status_code == 404)
print("\n== Устойчивость ==")
config.DB_FILE.write_text("{ битый json", encoding="utf-8")
r.check("битая база не роняет", c.get("/api/tree").status_code == 200)
r.check("пустой ввод не роняет", "reply" in j("POST", "/api/say", text=" "))
print("\n== Работа без Ollama: не ждём таймаутов ==")
# Машина с Ollama бывает выключена, и тогда пакеты уходят в никуда. Без
# предохранителя запись одной вещи ждала 92 секунды, разбор фразы — 154:
# пользователь решит, что приложение зависло. 192.0.2.1 — из блока TEST-NET-1
# (RFC 5737), он гарантированно не отвечает.
import time as _time # noqa: E402
_was_host = config.OLLAMA_HOST
try:
config.OLLAMA_HOST = "http://192.0.2.1:11434"
ollama.reset_breaker()
t0 = _time.time()
alive = ollama.check()
first = _time.time() - t0
r.check("недоступность видна быстро", not alive and first < 5, "%.1f с" % first)
t0 = _time.time()
d = j("POST", "/api/put", name="вещь без ии")
put_time = _time.time() - t0
r.check("запись вещи не ждёт таймаут", d.get("ok") and put_time < 3,
"%.1f с, ответ: %s" % (put_time, d))
r.check("тегов нет, но вещь записана",
d.get("item", {}).get("aliases") == [], d.get("item"))
t0 = _time.time()
d = j("POST", "/api/say", text="положил кусачки в коробку D1")
say_time = _time.time() - t0
r.check("свободная фраза не ждёт таймаут", say_time < 3, "%.1f с" % say_time)
r.check("фраза разобрана регулярками", d.get("kind") == "put", d)
t0 = _time.time()
d = c.get("/api/find?q=вещь без ии").get_json()
r.check("поиск работает без ИИ", d["items"] and _time.time() - t0 < 3, d)
r.check("смена адреса сбрасывает паузу",
(ollama.reset_breaker() or True) and ollama._down_until == 0)
finally:
config.OLLAMA_HOST = _was_host
ollama.reset_breaker()
r.check("после возврата адреса связь есть", ollama.check() if HAS_AI else True)
print("\n== Батники: только ASCII ==")
# cmd.exe читает .cmd в системной кодировке. Кириллица внутри — даже
# в комментарии rem — превращается в мусор, и cmd пытается выполнить обрывки
# как команды. На этом уже дважды ломался инсталлятор.
import pathlib # noqa: E402
root = pathlib.Path(__file__).resolve().parent.parent
for cmd_file in sorted(root.rglob("*.cmd")):
if "venv" in cmd_file.parts:
continue
lines = cmd_file.read_text(encoding="utf-8").splitlines()
bad = [(n, l) for n, l in enumerate(lines, 1) if any(ord(ch) > 127 for ch in l)]
r.check("%s без не-ASCII" % cmd_file.relative_to(root), not bad,
bad[0] if bad else "")
print("\n== Настройки из браузера ==")
d = c.get("/api/config").get_json()
r.check("отдаёт текущие", d.get("ollama_host") and d.get("chat_model"), d)
r.check("говорит, где файл", str(d.get("env_file", "")).endswith(".env"), d.get("env_file"))
was_host = config.OLLAMA_HOST
# Самое важное: файл настроек тоже в песочнице. Иначе тест перезаписывает
# рабочий .env пользователя — так однажды и случилось.
r.check("файл .env внутри песочницы", str(SANDBOX) in str(config.ENV_FILE),
config.ENV_FILE)
d = j("POST", "/api/config", ollama_host="http://10.11.12.13:11434")
r.check("сохранилось", d.get("ok"), d)
r.check("применилось без перезапуска", config.OLLAMA_HOST == "http://10.11.12.13:11434",
config.OLLAMA_HOST)
r.check("ollama.py видит новый адрес",
c.get("/api/health").get_json()["host"] == "http://10.11.12.13:11434")
r.check("записалось в файл", config.ENV_FILE.exists()
and "10.11.12.13" in config.ENV_FILE.read_text(encoding="utf-8"))
r.check("мусорный адрес — 400",
c.post("/api/config", json={"ollama_host": "не адрес"}).status_code == 400)
r.check("инъекция в имя модели — 400",
c.post("/api/config",
json={"ollama_host": "http://127.0.0.1:11434",
"chat_model": "модель\nOLLAMA_HOST=http://зло"}).status_code == 400)
# Отказ должен быть полным: адрес из того же запроса применяться не должен,
# иначе половина настроек принята, а половина отвергнута.
r.check("при отказе НИЧЕГО не применилось",
config.OLLAMA_HOST == "http://10.11.12.13:11434", config.OLLAMA_HOST)
r.check("посторонние ключи игнорируются", config.save_settings({"PATH": "/зло"}) == [])
d = j("POST", "/api/config/probe", ollama_host="http://127.0.0.1:1")
r.check("проба мёртвого адреса не роняет", d.get("ok") is False, d)
r.check("проба не меняет настройку", config.OLLAMA_HOST == "http://10.11.12.13:11434",
config.OLLAMA_HOST)
j("POST", "/api/config", ollama_host=was_host) # возвращаем как было
r.check("адрес восстановлен", config.OLLAMA_HOST == was_host, config.OLLAMA_HOST)
print("\n== Разбор фразы ==")
p = brain.fallback_parse("положил изоленту в коробку B2 на полке 1 в гараже")
r.check("регулярки: put", p["action"] == "put" and store.norm_code(p["container"]) == "B2", p)
p = brain.fallback_parse("где мультиметр")
r.check("регулярки: find", p["action"] == "find" and p["item"] == "мультиметр", p)
p = brain.fallback_parse("переложил дрель из коробки A1 в коробку C2")
r.check("«из» не попадает в название", "из" not in (p["item"] or ""), p["item"])
if HAS_AI:
p = brain.parse("положил стабилизаторы 7805, 12 штук, в коробку A3 на второй полке в гараже")
r.check("модель: put + коробка", p["action"] == "put" and store.norm_code(p["container"]) == "A3", p)
p = brain.parse("куда я положил зарядку от шуруповёрта")
r.check("вопрос не пишется как факт", p["action"] == "find", p)
p = brain.parse("переложил дрель из коробки A1 в коробку C2")
r.check("берётся место назначения", store.norm_code(p["container"] or "") == "C2", p)
sys.exit(r.finish())