Initial commit: OmniSVG-Lite MVP before live-streaming work
- LM Studio client (httpx-based, OpenAI-compatible) - SVG validator (lxml, whitelist tags, no <script>/<foreignObject>/http refs) - PNG renderer (resvg-py primary, cairosvg fallback - no native cairo dep) - History (SQLite, tracks raw/validated/preview paths) - Gradio UI on 127.0.0.1:8788 with: * mode radio (icon/illustration) * n_candidates slider (default 1) * image upload for image-to-SVG * LM Studio URL/token inputs * model dropdown + refresh button - prompts/ with system_icon.txt, system_illustration.txt, few_shot_examples.txt - docs/spec.md, docs/design.md - 122 unit/integration tests passing
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
"""Юнит-тесты для history.py.
|
||||
|
||||
Покрывают:
|
||||
- add(): вставка записи возвращает id, JSON-поля сериализуются
|
||||
- list_recent(): сортировка DESC, лимит, новые сверху
|
||||
- get(): по id, None для несуществующего
|
||||
- persist: создать/закрыть/создать — данные сохранились на диске
|
||||
- count(): корректный счёт
|
||||
- Спецсимволы в prompt: юникод, кавычки, переносы строк, эмодзи
|
||||
- JSON-поля: list[str] с не-ASCII корректно декодируется обратно
|
||||
- WAL mode: PRAGMA journal_mode после открытия соединения
|
||||
- list_recent(limit=...) ограничивает выборку
|
||||
|
||||
Каждый тест использует tmp_path (pytest fixture) для изолированной БД.
|
||||
|
||||
Запуск: `python -m pytest tests/test_history.py -v`
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from history import History, Record # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Базовые CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_record(**overrides) -> Record:
|
||||
"""Минимальный валидный Record + overrides."""
|
||||
base = dict(
|
||||
prompt="a fox",
|
||||
mode="icon",
|
||||
model="test-model",
|
||||
n_requested=2,
|
||||
n_returned=2,
|
||||
temperature=0.4,
|
||||
status="ok",
|
||||
error_reason=None,
|
||||
raw_outputs=["raw1", "raw2"],
|
||||
validated_outputs=["<svg/>", "<svg/>"],
|
||||
previews=["/p/1.png", "/p/2.png"],
|
||||
best_index=0,
|
||||
)
|
||||
base.update(overrides)
|
||||
return Record(**base)
|
||||
|
||||
|
||||
def test_add_returns_increasing_ids(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
with History(db) as h:
|
||||
id1 = h.add(_make_record(prompt="a"))
|
||||
id2 = h.add(_make_record(prompt="b"))
|
||||
id3 = h.add(_make_record(prompt="c"))
|
||||
assert id1 == 1
|
||||
assert id2 == 2
|
||||
assert id3 == 3
|
||||
assert id1 < id2 < id3
|
||||
|
||||
|
||||
def test_get_returns_record_by_id(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
with History(db) as h:
|
||||
rid = h.add(_make_record(prompt="hello"))
|
||||
with History(db) as h:
|
||||
rec = h.get(rid)
|
||||
assert rec is not None
|
||||
assert rec["id"] == rid
|
||||
assert rec["prompt"] == "hello"
|
||||
assert rec["mode"] == "icon"
|
||||
assert rec["status"] == "ok"
|
||||
assert rec["n_requested"] == 2
|
||||
assert rec["n_returned"] == 2
|
||||
assert rec["temperature"] == 0.4
|
||||
assert rec["raw_outputs"] == ["raw1", "raw2"]
|
||||
assert rec["validated_outputs"] == ["<svg/>", "<svg/>"]
|
||||
assert rec["previews"] == ["/p/1.png", "/p/2.png"]
|
||||
assert rec["best_index"] == 0
|
||||
|
||||
|
||||
def test_get_returns_none_for_missing_id(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
with History(db) as h:
|
||||
assert h.get(99999) is None
|
||||
|
||||
|
||||
def test_count_returns_total(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
with History(db) as h:
|
||||
assert h.count() == 0
|
||||
h.add(_make_record(prompt="a"))
|
||||
h.add(_make_record(prompt="b"))
|
||||
h.add(_make_record(prompt="c"))
|
||||
assert h.count() == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_recent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_recent_returns_newest_first(tmp_path: Path):
|
||||
"""Новые записи — сверху, лимит работает."""
|
||||
db = tmp_path / "h.sqlite"
|
||||
with History(db) as h:
|
||||
for i in range(5):
|
||||
h.add(_make_record(prompt=f"prompt-{i}"))
|
||||
time.sleep(0.005) # гарантируем уникальный created_at
|
||||
with History(db) as h:
|
||||
rows = h.list_recent(limit=3)
|
||||
assert len(rows) == 3
|
||||
# Новейшие сверху → "prompt-4", "prompt-3", "prompt-2"
|
||||
assert [r["prompt"] for r in rows] == ["prompt-4", "prompt-3", "prompt-2"]
|
||||
|
||||
|
||||
def test_list_recent_default_limit_is_50(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
with History(db) as h:
|
||||
for i in range(60):
|
||||
h.add(_make_record(prompt=f"p-{i}"))
|
||||
with History(db) as h:
|
||||
rows = h.list_recent()
|
||||
assert len(rows) == 50
|
||||
|
||||
|
||||
def test_list_recent_handles_explicit_large_limit(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
with History(db) as h:
|
||||
for i in range(3):
|
||||
h.add(_make_record(prompt=f"p-{i}"))
|
||||
with History(db) as h:
|
||||
rows = h.list_recent(limit=1000)
|
||||
assert len(rows) == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Persist между открытиями
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_data_persists_across_open_close(tmp_path: Path):
|
||||
"""Создать → закрыть → снова открыть → данные на месте."""
|
||||
db = tmp_path / "h.sqlite"
|
||||
with History(db) as h:
|
||||
rid1 = h.add(_make_record(prompt="first"))
|
||||
rid2 = h.add(_make_record(prompt="second"))
|
||||
# БД закрыта; открываем заново
|
||||
assert db.exists()
|
||||
assert db.stat().st_size > 0
|
||||
with History(db) as h:
|
||||
assert h.count() == 2
|
||||
assert h.get(rid1)["prompt"] == "first"
|
||||
assert h.get(rid2)["prompt"] == "second"
|
||||
|
||||
|
||||
def test_data_survives_full_reopen_with_wal_files(tmp_path: Path):
|
||||
"""WAL-файлы могут остаться после закрытия — повторное открытие должно работать."""
|
||||
db = tmp_path / "h.sqlite"
|
||||
with History(db) as h:
|
||||
h.add(_make_record(prompt="x"))
|
||||
# Проверяем, что нет orphan'ов: всё читается.
|
||||
with History(db) as h:
|
||||
rec = h.get(1)
|
||||
assert rec["prompt"] == "x"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Спецсимволы в prompt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_prompt_with_unicode_cyrillic(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
cyrillic = "лиса в осеннем лесу, палитра оранжевая"
|
||||
with History(db) as h:
|
||||
rid = h.add(_make_record(prompt=cyrillic))
|
||||
with History(db) as h:
|
||||
rec = h.get(rid)
|
||||
assert rec["prompt"] == cyrillic
|
||||
|
||||
|
||||
def test_prompt_with_quotes_and_doublequotes(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
p = 'icon with "double" and \'single\' quotes'
|
||||
with History(db) as h:
|
||||
rid = h.add(_make_record(prompt=p))
|
||||
with History(db) as h:
|
||||
rec = h.get(rid)
|
||||
assert rec["prompt"] == p
|
||||
|
||||
|
||||
def test_prompt_with_newlines_and_tabs(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
p = "line 1\nline 2\n\tindented\n\nblank-line-above"
|
||||
with History(db) as h:
|
||||
rid = h.add(_make_record(prompt=p))
|
||||
with History(db) as h:
|
||||
rec = h.get(rid)
|
||||
assert rec["prompt"] == p
|
||||
|
||||
|
||||
def test_prompt_with_emoji(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
p = "fox 🦊 in forest 🌲🌲"
|
||||
with History(db) as h:
|
||||
rid = h.add(_make_record(prompt=p))
|
||||
with History(db) as h:
|
||||
rec = h.get(rid)
|
||||
assert rec["prompt"] == p
|
||||
|
||||
|
||||
def test_prompt_with_backslashes_and_sql_injection_attempt(tmp_path: Path):
|
||||
"""Промпт с SQL-инъекцией в виде текста — должен храниться как plain text."""
|
||||
db = tmp_path / "h.sqlite"
|
||||
evil = "'; DROP TABLE generations; --"
|
||||
with History(db) as h:
|
||||
rid = h.add(_make_record(prompt=evil))
|
||||
with History(db) as h2:
|
||||
rec = h2.get(rid)
|
||||
assert rec["prompt"] == evil
|
||||
# Таблица жива
|
||||
assert h2.count() == 1
|
||||
|
||||
|
||||
def test_prompt_with_very_long_string(tmp_path: Path):
|
||||
"""Длинный промпт (10K символов) — должен сохраниться без потерь."""
|
||||
db = tmp_path / "h.sqlite"
|
||||
p = "x" * 10000
|
||||
with History(db) as h:
|
||||
rid = h.add(_make_record(prompt=p))
|
||||
with History(db) as h:
|
||||
rec = h.get(rid)
|
||||
assert rec["prompt"] == p
|
||||
assert len(rec["prompt"]) == 10000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JSON-поля с не-ASCII
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_raw_outputs_with_unicode_preserved(tmp_path: Path):
|
||||
"""list[str] в raw_outputs хранится как JSON, ensure_ascii=False."""
|
||||
db = tmp_path / "h.sqlite"
|
||||
raw = [
|
||||
'<svg viewBox="0 0 64 64"><text>лиса 🦊</text></svg>',
|
||||
'<svg viewBox="0 0 64 64"><text>simple</text></svg>',
|
||||
]
|
||||
with History(db) as h:
|
||||
rid = h.add(_make_record(raw_outputs=raw))
|
||||
with History(db) as h:
|
||||
rec = h.get(rid)
|
||||
assert rec["raw_outputs"] == raw
|
||||
|
||||
|
||||
def test_validated_outputs_with_unicode_in_svg(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
svg = '<svg viewBox="0 0 512 512"><text>Привет</text></svg>'
|
||||
with History(db) as h:
|
||||
rid = h.add(_make_record(validated_outputs=[svg]))
|
||||
with History(db) as h:
|
||||
rec = h.get(rid)
|
||||
assert rec["validated_outputs"] == [svg]
|
||||
|
||||
|
||||
def test_previews_with_unicode_paths(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
paths = [
|
||||
"C:\\Users\\пользователь\\превью\\1_0.png",
|
||||
"D:\\AI\\Projects\\лиса\\2_1.png",
|
||||
]
|
||||
with History(db) as h:
|
||||
rid = h.add(_make_record(previews=paths))
|
||||
with History(db) as h:
|
||||
rec = h.get(rid)
|
||||
assert rec["previews"] == paths
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Контекст-менеджер: WAL и поведение при ошибках
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_wal_mode_is_set(tmp_path: Path):
|
||||
"""После открытия History journal_mode должен быть WAL."""
|
||||
db = tmp_path / "h.sqlite"
|
||||
with History(db) as h:
|
||||
# Спросим напрямую через raw-conn.
|
||||
cur = h.conn.execute("PRAGMA journal_mode")
|
||||
mode = cur.fetchone()[0]
|
||||
assert mode.lower() == "wal"
|
||||
|
||||
|
||||
def test_context_manager_closes_connection(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
h = History(db)
|
||||
with h as hist:
|
||||
hist.add(_make_record(prompt="x"))
|
||||
# После выхода conn=None
|
||||
assert h._conn is None
|
||||
|
||||
|
||||
def test_context_manager_commit_path_runs_in_exit(tmp_path: Path):
|
||||
"""Без exception — __exit__ коммитит оставшиеся незакоммиченные изменения.
|
||||
|
||||
Замечание: `add()` сам вызывает commit() внутри, поэтому запись из add()
|
||||
сохранится в любом случае. Этот тест проверяет, что exit/close не
|
||||
портит уже закоммиченное и не падает на нормальном пути.
|
||||
"""
|
||||
db = tmp_path / "h.sqlite"
|
||||
h = History(db)
|
||||
with h as hist:
|
||||
hist.add(_make_record(prompt="x"))
|
||||
# Контекст закрылся без exception
|
||||
assert h._conn is None
|
||||
with History(db) as hist2:
|
||||
assert hist2.count() == 1
|
||||
|
||||
|
||||
def test_conn_outside_context_raises(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
h = History(db)
|
||||
with pytest.raises(RuntimeError, match="контекст-менеджера"):
|
||||
_ = h.conn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Нишевые: пустые list'ы, edge-cases значений
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_record_with_empty_lists(tmp_path: Path):
|
||||
"""Record с пустыми raw/validated/previews — должен сохраниться."""
|
||||
db = tmp_path / "h.sqlite"
|
||||
with History(db) as h:
|
||||
rid = h.add(_make_record(
|
||||
status="failed",
|
||||
error_reason="lm studio down",
|
||||
raw_outputs=[],
|
||||
validated_outputs=[],
|
||||
previews=[],
|
||||
best_index=None,
|
||||
))
|
||||
with History(db) as h:
|
||||
rec = h.get(rid)
|
||||
assert rec["raw_outputs"] == []
|
||||
assert rec["validated_outputs"] == []
|
||||
assert rec["previews"] == []
|
||||
assert rec["best_index"] is None
|
||||
assert rec["error_reason"] == "lm studio down"
|
||||
|
||||
|
||||
def test_default_created_at_set_by_add(tmp_path: Path):
|
||||
"""Если created_at=None в Record — add() заполняет time.time()."""
|
||||
db = tmp_path / "h.sqlite"
|
||||
before = time.time()
|
||||
with History(db) as h:
|
||||
rec_in = _make_record(prompt="x")
|
||||
assert rec_in.created_at is None
|
||||
rid = h.add(rec_in)
|
||||
after = time.time()
|
||||
with History(db) as h:
|
||||
rec_out = h.get(rid)
|
||||
assert before - 1 <= rec_out["created_at"] <= after + 1
|
||||
|
||||
|
||||
def test_explicit_created_at_preserved(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
fixed_ts = 1700000000.0
|
||||
with History(db) as h:
|
||||
rid = h.add(_make_record(prompt="x", created_at=fixed_ts))
|
||||
with History(db) as h:
|
||||
rec = h.get(rid)
|
||||
assert rec["created_at"] == fixed_ts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Несколько записей: list_recent с лимитом < N
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_recent_limit_smaller_than_total(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
with History(db) as h:
|
||||
for i in range(10):
|
||||
h.add(_make_record(prompt=f"p-{i}"))
|
||||
time.sleep(0.003)
|
||||
with History(db) as h:
|
||||
rows = h.list_recent(limit=2)
|
||||
assert len(rows) == 2
|
||||
# Самые свежие
|
||||
assert rows[0]["prompt"] == "p-9"
|
||||
assert rows[1]["prompt"] == "p-8"
|
||||
Reference in New Issue
Block a user