"""Completion-manifest guard for research.sqlite breadth runs.""" from __future__ import annotations import sys from pathlib import Path import pytest from sqlalchemy import create_engine, text ROOT = Path(__file__).resolve().parents[2] SCRIPTS = ROOT / "scripts" if str(SCRIPTS) not in sys.path: sys.path.insert(0, str(SCRIPTS)) from research_snapshot_manifest import ( # noqa: E402 assert_research_snapshot_complete, clear_manifest, load_manifest, manifest_path_for, write_completion_manifest, ) def _tiny_research_db(path: Path, *, tickers: int = 3, bars_each: int = 5) -> None: engine = create_engine(f"sqlite:///{path.resolve().as_posix()}", future=True) with engine.begin() as conn: conn.execute( text( "CREATE TABLE tickers (" "id INTEGER PRIMARY KEY, symbol TEXT NOT NULL UNIQUE, " "name TEXT, created_at TEXT)" ) ) conn.execute( text( "CREATE TABLE ohlcv_records (" "id INTEGER PRIMARY KEY, ticker_id INTEGER, date TEXT, " "open REAL, high REAL, low REAL, close REAL, volume INTEGER, " "created_at TEXT)" ) ) conn.execute( text( "CREATE TABLE research_rank_only (" "ticker_id INTEGER PRIMARY KEY, symbol TEXT NOT NULL UNIQUE)" ) ) for i in range(tickers): sym = f"T{i}" conn.execute( text( "INSERT INTO tickers (id, symbol, name, created_at) " "VALUES (:id, :sym, NULL, '2026-01-01')" ), {"id": i + 1, "sym": sym}, ) if i > 0: conn.execute( text( "INSERT INTO research_rank_only (ticker_id, symbol) " "VALUES (:id, :sym)" ), {"id": i + 1, "sym": sym}, ) for d in range(bars_each): conn.execute( text( "INSERT INTO ohlcv_records " "(ticker_id, date, open, high, low, close, volume, created_at) " "VALUES (:tid, :date, 1,1,1,1,100, '2026-01-01')" ), {"tid": i + 1, "date": f"2026-01-{d+1:02d}"}, ) engine.dispose() def test_write_and_assert_complete(tmp_path: Path) -> None: snap = tmp_path / "research.sqlite" _tiny_research_db(snap) path = write_completion_manifest(snap, complete=True, sources={"t": "unit"}) assert path == manifest_path_for(snap) assert path.exists() m = assert_research_snapshot_complete(snap) assert m["complete"] is True assert m["ticker_count"] == 3 assert m["ohlcv_row_count"] == 15 assert m["rank_only_count"] == 2 assert m["live_counts"]["ticker_count"] == 3 def test_refuse_missing_manifest(tmp_path: Path) -> None: snap = tmp_path / "research.sqlite" _tiny_research_db(snap) with pytest.raises(SystemExit, match="manifest missing"): assert_research_snapshot_complete(snap) def test_refuse_incomplete_flag(tmp_path: Path) -> None: snap = tmp_path / "research.sqlite" _tiny_research_db(snap) write_completion_manifest(snap, complete=False, limit=50) with pytest.raises(SystemExit, match="marked incomplete"): assert_research_snapshot_complete(snap) def test_refuse_count_mismatch(tmp_path: Path) -> None: snap = tmp_path / "research.sqlite" _tiny_research_db(snap) write_completion_manifest(snap, complete=True) # Tamper: change live DB after manifest written engine = create_engine(f"sqlite:///{snap.resolve().as_posix()}", future=True) with engine.begin() as conn: conn.execute( text( "INSERT INTO tickers (id, symbol, name, created_at) " "VALUES (99, 'EXTRA', NULL, '2026-01-01')" ) ) engine.dispose() with pytest.raises(SystemExit, match="does not match"): assert_research_snapshot_complete(snap) def test_clear_manifest(tmp_path: Path) -> None: snap = tmp_path / "research.sqlite" _tiny_research_db(snap) write_completion_manifest(snap, complete=True) assert load_manifest(snap) is not None clear_manifest(snap) assert load_manifest(snap) is None