# -*- 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== Подсказка по фотографии ==") HAS_VISION = bool(config.VISION_MODEL) and HAS_AI and config.VISION_MODEL in ollama.models() if HAS_VISION: real = None for candidate in ("31ee37dada70.jpg", "afbaf962ad16.jpg"): path = config.PROJECT_DIR / "static" / "photos" / candidate if path.exists(): real = path break if real: d = c.post("/api/recognize", content_type="multipart/form-data", data={"photo": (io.BytesIO(real.read_bytes()), "p.jpg")}).get_json() r.check("модель что-то увидела", d.get("ok") and d["guess"]["name"], d) r.check("название не пустое и короткое", 0 < len(d["guess"]["name"]) <= 80, d["guess"]["name"]) print(" предложила: %s" % d["guess"]["name"]) else: print(" (реальных фото нет — пропускаю)") else: print(" (vision-модель недоступна — пропускаю)") r.check("без файла — 400", c.post("/api/recognize", content_type="multipart/form-data", data={}).status_code == 400) r.check("для записи без фото — 404", c.post("/api/item/нет-такой/recognize").status_code == 404) 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.get("item", {}).get("aliases"), d) r.check("мусорный тег заменён", "мусорный тег" not in d.get("item", {}).get("aliases", ["мусорный тег"]), d.get("item")) 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 == {}) finally: config.OLLAMA_HOST = _was_host ollama.reset_breaker() r.check("после возврата адреса связь есть", ollama.check() if HAS_AI else True) print("\n== Экспорт и импорт ==") import json as _json # noqa: E402 import zipfile # noqa: E402 # Кладём снимок, чтобы в архиве было чему проверяться: предыдущие блоки # свои фотографии за собой убирают. _some = c.get("/api/items").get_json()["items"][0]["id"] upload("item", _some) before = c.get("/api/items").get_json()["items"] resp = c.get("/api/export") r.check("архив отдаётся", resp.status_code == 200, resp.status_code) r.check("это zip", resp.data[:2] == b"PK", resp.data[:4]) archive = zipfile.ZipFile(io.BytesIO(resp.data)) names = archive.namelist() r.check("внутри есть база", "db.json" in names, names[:5]) r.check("внутри есть фотографии", any(n.startswith("photos/") for n in names), names[:5]) r.check("локальные копии в архив не попали", not any("backups" in n for n in names), [n for n in names if "backups" in n]) d = c.post("/api/import/check", content_type="multipart/form-data", data={"archive": (io.BytesIO(resp.data), "a.zip")}).get_json() r.check("проверка архива видит содержимое", d.get("ok") and d["archive"]["items"] == len(before), d) # Восстановление в ту же песочницу: данные должны остаться прежними, # а прежнее состояние — отложиться для отката. d = c.post("/api/import", content_type="multipart/form-data", data={"archive": (io.BytesIO(resp.data), "a.zip")}).get_json() r.check("импорт прошёл", d.get("ok"), d) after = c.get("/api/items").get_json()["items"] r.check("вещи на месте после импорта", len(after) == len(before), (len(before), len(after))) rollback = config.DATA_DIR / "backups" / d["result"]["rollback"] r.check("страховка перед импортом создана", rollback.exists(), rollback) # Архив с путём наружу: имя из архива не должно превращаться в путь. evil = io.BytesIO() with zipfile.ZipFile(evil, "w") as zf: zf.writestr("db.json", _json.dumps({"version": 1, "items": {}, "places": {}, "containers": {}})) zf.writestr("photos/../../evil.png", b"\x89PNG\r\n\x1a\n") evil.seek(0) outside = config.PROJECT_DIR.parent / "evil.png" d = c.post("/api/import", content_type="multipart/form-data", data={"archive": (evil, "evil.zip")}).get_json() r.check("злой архив не пишет за пределы папки", not outside.exists(), outside) if outside.exists(): outside.unlink() r.check("чужой файл отвергается", c.post("/api/import", content_type="multipart/form-data", data={"archive": (io.BytesIO(b"not a zip"), "x.zip")}).status_code == 400) r.check("импорт без файла — 400", c.post("/api/import", content_type="multipart/form-data", data={}).status_code == 400) # Возвращаем данные песочницы: злой архив заменил базу на пустую. c.post("/api/import", content_type="multipart/form-data", data={"archive": (io.BytesIO(resp.data), "a.zip")}) print("\n== Внешние копии ==") r.check("состояние отдаётся", "backup" in c.get("/api/config").get_json()) r.check("без пути — копировать нечего", c.post("/api/backup/now").status_code == 400 if not config.BACKUP_DIR else True) dest = SANDBOX / "external" config.save_settings({"HLAMINGO_BACKUP_DIR": str(dest)}) r.check("путь принят", config.BACKUP_DIR == str(dest), config.BACKUP_DIR) d = c.post("/api/backup/now").get_json() r.check("копия сделана", d.get("ok"), d) r.check("база скопирована", (dest / "db.json").exists()) r.check("фотографии скопированы", (dest / "photos").exists()) # Недоступный путь не должен мешать записи вещи — сетевой диск отваливается. config.save_settings({"HLAMINGO_BACKUP_DIR": "Z:\\нет-такого-диска\\backup"}) import time as _t2 # noqa: E402 t0 = _t2.time() d = j("POST", "/api/put", name="вещь при отвалившемся диске") r.check("запись прошла, несмотря на недоступный путь", d.get("ok") and _t2.time() - t0 < 10, "%.1f с" % (_t2.time() - t0)) config.save_settings({"HLAMINGO_BACKUP_DIR": ""}) r.check("путь можно очистить", config.BACKUP_DIR == "", config.BACKUP_DIR) 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) # ---- выбор мозгов: Ollama или веб-DeepSeek ---- # Проверяем не сам DeepSeek (для него нужен браузер с логином), а развилку: # куда уходит разбор фраз и куда — эмбеддинги. Перепутать их значит тихо # сломать поиск по смыслу, и это не видно, пока не начнёшь искать. was_engine = config.ENGINE DS = "http://127.0.0.1:11500" try: d = j("POST", "/api/config", engine="deepseek", deepseek_host=DS) r.check("движок переключился", d.get("ok") and config.ENGINE == "deepseek", d) r.check("думает DeepSeek", config.chat_host() == DS, config.chat_host()) r.check("здоровье докладывает движок", c.get("/api/health").get_json()["engine"] == "deepseek") r.check("ждём DeepSeek дольше", config.chat_timeout() > config.CHAT_TIMEOUT, config.chat_timeout()) # Куда реально уходят запросы. Предохранитель и сеть отключаем: важно не # то, ответил ли кто-то, а по какому адресу постучались. tried = [] def _spy(req, **kw): tried.append(req.full_url) raise OSError("в тесте наружу не ходим") real_urlopen = ollama.urllib.request.urlopen real_available = ollama.available ollama.urllib.request.urlopen = _spy ollama.available = lambda host=None: True try: ollama.embed("гвозди") ollama.chat(config.CHAT_MODEL, [{"role": "user", "content": "привет"}]) finally: ollama.urllib.request.urlopen = real_urlopen ollama.available = real_available ollama.reset_breaker() r.check("эмбеддинги идут в Ollama", any(u.startswith(config.OLLAMA_HOST) and "embed" in u for u in tried), tried) r.check("разбор фраз идёт в DeepSeek", any(u.startswith(DS) and u.endswith("/api/chat") for u in tried), tried) r.check("чужой движок — 400", c.post("/api/config", json={"engine": "chatgpt"}).status_code == 400) r.check("мусорный адрес DeepSeek — 400", c.post("/api/config", json={"deepseek_host": "не адрес"}).status_code == 400) r.check("проба берёт адрес из запроса, а не из настроек", c.post("/api/config/probe", json={"host": "http://192.0.2.1:11434"}).get_json()["ok"] is False and config.OLLAMA_HOST != "http://192.0.2.1:11434") finally: j("POST", "/api/config", engine=was_engine) ollama.reset_breaker() r.check("движок вернулся", config.ENGINE == was_engine, config.ENGINE) # Отказ должен быть полным: адрес из того же запроса применяться не должен, # иначе половина настроек принята, а половина отвергнута. 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())