Files
hlamingo/tests/test_all.py
T
alexandClaude Opus 5 1ae98b0b6f Экспорт, импорт и копии вне компьютера
Все 40 резервных копий лежали на том же диске, что и оригинал, а фотографии
не дублировались нигде: смерть диска уносила и 59 записей, и 69 снимков.

Что появилось:
- «📦 Скачать архив» — zip с базой и всеми фотографиями (13 МБ на текущих
  данных). Работает и с телефона, годится для переноса на другой компьютер.
- «📥 Загрузить архив» — восстановление. Сначала показывает, что внутри
  (сколько вещей в архиве против текущих), и только после подтверждения
  заменяет данные. Прежнее состояние откладывается в data/backups —
  и база, и папка со старыми фотографиями.
- HLAMINGO_BACKUP_DIR — путь, куда сама уходит копия базы и новых фотографий.
  Не чаще раза в час и только если путь доступен: сетевой диск отваливается
  регулярно, а терять запись о вещи из-за этого недопустимо. Состояние
  и кнопка «Скопировать сейчас» — на вкладке настроек.

Имена файлов из архива как пути не используются: только базовое имя
и расширение из белого списка. Иначе архив со строкой «../../» писал бы
куда угодно — тест это проверяет отдельно.

Попутно исправлены два дефекта:
- предел загрузки был 8 МБ, и архив в 13 МБ не прошёл бы. Разделён на общий
  предел запроса и отдельный предел для одного снимка;
- настройки нельзя было менять по одной: непереданные поля приходили как null
  и валились на проверке. Теперь null означает «не трогать», а пустая строка —
  осознанное «очистить».

Про защиту копий на сервере. Задумывался .htaccess, но сайты обслуживает
nginx, который его игнорирует, — файл создавал бы ложное чувство защиты.
Настоящая защита в расположении: папка лежит рядом с корнем сайта, а не внутри,
и nginx за пределы своего root не выпускает. Проверено запросами извне:
и сама папка, и db.json, и фотографии дают 404, включая попытку выйти
через «/../». .htaccess положен рядом как подстраховка на случай переезда
на Apache, о чём честно написано внутри самого файла.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 11:17:08 +03:00

425 lines
24 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== Подсказка по фотографии ==")
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 == 0)
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)
# Отказ должен быть полным: адрес из того же запроса применяться не должен,
# иначе половина настроек принята, а половина отвергнута.
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())