"""Юнит-тесты для 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=["", ""], 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"] == ["", ""] 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 = [ 'лиса 🦊', 'simple', ] 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 = 'Привет' 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"