Files
hlamingo/tests/test_all.py
T
alexandClaude Opus 5 01d7f17f89 qwen3:8b вместо qwen2.5, отмена правки и пересоздание тегов
Замер на наших задачах (разбор 8 фраз + теги для 6 предметов):
  qwen2.5:7b  разбор 8/8, но теги «электротool Makita», «вoltage meter»
              (первая буква кириллическая), «многофункциональный мера» —
              модель думает по-английски и переводит на ходу;
  qwen3:8b    разбор 8/8, теги на нормальном русском.
Плата — примерно на треть медленнее на тегах. Теги важнее: по ним вещь
и находится.

Поддержка моделей-ризонеров: ollama.chat получил think=False. Без него
qwen3 тратит весь бюджет ответа на внутренний монолог и возвращает пустой
content — на первом прогоне модель выглядела нерабочей.

Разбор ответа с тегами укреплён: qwen3 иногда присылает объект
{"name":..., "synonyms":[...]} вместо списка строк, и он попадал в теги
целиком как строка. Теперь достаётся вложенный список, отсеиваются дубликаты
в разном регистре, вложенные объекты, bool и слишком длинные строки.

По замечаниям Алекса в правке предмета:
- кнопка «Отмена» — раньше открытую форму можно было только сохранить или
  удалить вещь целиком;
- кнопка «🔄 Пересоздать теги» — после смены модели теги остаются старыми,
  и это способ обновить их, не перезаводя вещь. Новый список показывается,
  но не сохраняется молча: его ещё можно поправить руками.

В README добавлен раздел «Какую модель выбрать» с замерами и требованиями
к своей модели для тех, кто ставит приложение себе.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:51:13 +03:00

315 lines
18 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== Пересоздание тегов ==")
if HAS_AI:
j("PATCH", "/api/item/" + item, aliases=["мусорный тег"])
d = c.post("/api/item/" + item + "/retag").get_json()
r.check("теги пересозданы", d.get("ok") and d["item"]["aliases"], d)
r.check("мусорный тег заменён", "мусорный тег" not in d["item"]["aliases"],
d["item"]["aliases"])
d = c.post("/api/item/" + item + "/retag?keep=1").get_json()
r.check("режим дополнения работает", d.get("ok"), d)
r.check("retag для несуществующей — 404",
c.post("/api/item/нет-такой/retag").status_code == 404)
# Разбор кривых ответов модели: qwen3 присылала объект вместо списка строк,
# и он попадал в теги целиком, а ещё дублировала одно значение семь раз.
from hlamingo.brain import _clean_tags # noqa: E402
r.check("объект вместо списка разбирается",
_clean_tags({"name": "x", "synonyms": ["тестер", "дрель"]}, "x")
== ["тестер", "дрель"])
r.check("дубликаты схлопываются",
_clean_tags(["дрель", "ДРЕЛЬ", "бур"], "x") == ["дрель", "бур"])
r.check("вложенные объекты разбираются",
_clean_tags([{"name": "тестер"}, "мультиметр"], "x") == ["тестер", "мультиметр"])
r.check("мусор отсеивается", _clean_tags([None, True, {}, "годный"], "x") == ["годный"])
r.check("не список — пусто", _clean_tags("строка", "x") == [])
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())