Files
transcription/tests/ui/conftest.py
T
zoltan57andCopilot App 66e2dce465 V4.6 Phase 7: drive ty check to zero and add a blocking quality gate [HIGH-06]
Baseline was 207 diagnostics. Two real bugs were hiding in the noise:

- tools/run_destructive_tests.py imported ctypes.wintypes at module scope,
  which raises on non-Windows, and called fcntl unconditionally. The Windows
  and POSIX implementations now live under a module-level sys.platform split.
- tests/ui/test_sources_page.py constructed Source(...) without document_id.

Structural fixes, not suppressions:

- New src/transcription/db/loading.py owns the SQLModel-field to
  QueryableAttribute reinterpretation via orm_attribute()/selectinload()/
  defer(). This removed 42 "# pyright: ignore[reportArgumentType]" comments
  across documents/jobs/people/sources. Its docstring records that
  selectinload(A.b, B.c) is NOT equivalent to the chained form: varargs
  applies the selectin strategy only to the last path element, which under
  lazy="raise" raises InvalidRequestError at render time.
- db/session.py transaction_scope no longer accepts or yields
  AsyncSessionTransaction. No caller ever passed one, sessionmaker.begin()
  yields an AsyncSession, and the dead branch was latently buggy because
  services call .exec(). Cleared 7 workflows.py diagnostics.
- services/registry.py RegistryService is bound by a new RegistryEntry
  Protocol instead of bare SQLModel, so the shared implementation can read
  id/label/normalized_label/is_active. Cleared 9 diagnostics.
- Column expressions in sources.py/jobs.py/test_store.py wrap in sqlmodel
  col(), the idiom already used in registry.py.
- read_source_navigation wraps its literal tuple bounds in literal().
- normalization.py narrows with isinstance(image, TiffImageFile) rather than
  comparing image.format, since tag_v2 is TIFF-only.
- linked_people.render uses @ui.refreshable_method, the NiceGUI API for bound
  methods.
- The OpenRouter capturing client re-raises ResponseNotRead when the response
  stream is not async rather than mis-wrapping it.

Tooling gate:

- New .pre-commit-config.yaml runs ruff check and ty check as blocking hooks.
  No pre-commit config previously existed. Negative-tested: injecting a type
  error fails both hooks.
- The last two "# pyright: ignore" comments (config.py) are removed; ty does
  not honor pyright directives. One "# ty: ignore" remains, in
  tests/test_prompts.py, where the test deliberately assigns to a frozen
  field to assert ValidationError.
- asyncio_default_fixture_loop_scope is pinned to "function" so
  pytest-asyncio behavior does not shift on upgrade.

Verification: ruff check clean, ty check reports 0 diagnostics, 292 passed
and 4 skipped, pre-commit passes and demonstrably fails on a regression, and
tools/run_destructive_tests.py runs on Windows.

Co-authored-by: Copilot App <[email protected]>
2026-08-17 19:57:23 -05:00

157 lines
5.6 KiB
Python

"""Shared fixtures for UI integration tests."""
from __future__ import annotations
from collections.abc import Awaitable
from collections.abc import Callable
from collections.abc import Generator
from datetime import UTC
from datetime import datetime
from pathlib import Path
from uuid import UUID
import pytest
import pytest_asyncio
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlmodel import delete
from transcription.app import create_app
from transcription.config import Settings
from transcription.config import SqliteSettings
from transcription.db import session as db_session_module
from transcription.db import session_scope
from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from transcription.db.models import Job
from transcription.db.models import JobSource
from transcription.db.models import JobSourceStatus
from transcription.db.models import JobStatus
from transcription.db.models import Person
from transcription.db.models import Source
@pytest.fixture(scope="session")
def app_client(tmp_path_factory: pytest.TempPathFactory) -> Generator[tuple[FastAPI, TestClient]]:
"""Provide a real application and test client backed by in-memory SQLite."""
tmp_path = tmp_path_factory.mktemp("ui")
database = SqliteSettings(path=str(tmp_path / "ui-tests.db"))
settings = Settings(
openrouter_api_key="test-key",
database=database,
environment="test",
bootstrap_schema_on_startup=True,
upload_dir=tmp_path / "uploads",
prompt_dir=tmp_path / "prompts",
)
app = create_app(settings=settings)
with TestClient(app) as client:
runtime_path = Path(str(app.state.runtime.engine.url.database)).resolve()
expected_path = Path(database.path).resolve()
if runtime_path != expected_path:
raise RuntimeError(
"Refusing to initialize destructive UI fixtures against "
f"{runtime_path}; expected {expected_path}"
)
yield app, client
@pytest_asyncio.fixture(autouse=True)
async def clear_ui_database(
app_client: tuple[FastAPI, TestClient],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Reset UI-facing tables asynchronously before each test for isolation."""
app, _ = app_client
monkeypatch.setattr(
db_session_module,
"resolve_session_factory",
lambda *_args, **_kwargs: app.state.runtime.session_factory,
)
async with session_scope(session_factory=app.state.runtime.session_factory) as session:
await session.exec(delete(JobSource))
await session.exec(delete(DocumentPerson))
await session.exec(delete(Source))
await session.exec(delete(Job))
await session.exec(delete(Document))
await session.exec(delete(Person))
await session.commit()
@pytest_asyncio.fixture
async def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., Awaitable[UUID]]:
"""Return an async helper for seeding a Document -> Job -> Source tuple."""
app, _ = app_client
fixtures_dir = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid"
async def _seed(
*,
filename: str = "sample.pdf",
status: JobStatus = JobStatus.TRANSCRIBED,
transcription_text: str | None = "Sample transcript text",
error_detail: str | None = None,
revision_text: str | None = None,
source_file: Path | None = None,
ai_metadata: dict | None = None,
raw_api_response: dict | None = None,
) -> UUID:
async with session_scope() as session:
stored_path = app.state.settings.upload_dir / filename
stored_path.parent.mkdir(parents=True, exist_ok=True)
source_path = source_file or fixtures_dir / "small_png.png"
stored_path.write_bytes(source_path.read_bytes())
document = Document(name=filename)
session.add(document)
await session.flush()
job = Job(
document_id=document.id,
status=status,
retry_count=0,
provider="openrouter",
model="google/gemini-2.5-flash",
prompt_name="transcribe_document.md",
)
session.add(job)
await session.flush()
source = Source(
document_id=document.id,
page_number=1,
upload_name=filename,
filename=filename,
file_path=str(stored_path),
file_hash="b" * 64,
file_size_bytes=len(stored_path.read_bytes()),
)
session.add(source)
await session.flush()
if transcription_text is not None or error_detail is not None:
session.add(
JobSource(
job_id=job.id,
source_id=source.id,
status=(
JobSourceStatus.TRANSCRIBED if transcription_text is not None else JobSourceStatus.FAILED
),
raw_transcription=transcription_text,
error_detail=error_detail,
ai_metadata=ai_metadata,
raw_api_response=raw_api_response,
)
)
if revision_text is not None:
source.revised_text = revision_text
source.date_revised = datetime.now(UTC)
session.add(source)
await session.commit()
return job.id
return _seed