V4.1 Mostly UI adjustments by GC

This commit is contained in:
Jim Lancaster
2026-08-12 13:11:50 -05:00
parent 1e8d8572d4
commit 89cf69f8a2
29 changed files with 1261 additions and 404 deletions
+60
View File
@@ -10,9 +10,11 @@ from transcription.db.models import JobSource
from transcription.db.models import JobSourceStatus
from transcription.db.models import Person
from transcription.db.models import Source
from transcription.errors import ErrorCategory
from transcription.services.documents import DocumentDeleteBlockedError
from transcription.services.documents import DocumentService
from transcription.services.jobs import JobService
from transcription.services.people import PeopleError
from transcription.services.people import PeopleService
from transcription.services.sources import SourceDeleteBlockedError
from transcription.services.sources import SourceService
@@ -53,6 +55,28 @@ async def test_people_service_handles_person_and_document_person_crud(default_se
assert len(await people_service.list_document_people(document_id=document.id)) == 0
@pytest.mark.asyncio
async def test_people_service_normalizes_and_rejects_duplicate_family_search_ids(default_session_factory):
people_service = PeopleService(session_factory=default_session_factory)
created = await people_service.create_person(
Person(full_name="Hig Higgins", family_search_id=" g8t4-mdq ")
)
assert created.family_search_id == "G8T4-MDQ"
with pytest.raises(PeopleError) as duplicate:
await people_service.create_person(
Person(full_name="Duplicate Hig", family_search_id="G8T4-MDQ")
)
assert duplicate.value.category == ErrorCategory.CONFLICT
with pytest.raises(PeopleError) as malformed:
await people_service.create_person(
Person(full_name="Malformed", family_search_id="not-an-id")
)
assert malformed.value.category == ErrorCategory.VALIDATION
@pytest.mark.asyncio
async def test_transcription_service_manages_source_crud(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
@@ -88,6 +112,42 @@ async def test_transcription_service_manages_source_crud(default_session_factory
assert len(await transcriptions.list_sources(document_id=document.id)) == 0
@pytest.mark.asyncio
async def test_source_navigation_is_bounded_to_ordered_document(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
sources = SourceService(session_factory=default_session_factory)
document = await documents.create_document(Document(id=uuid4(), name="ordered"))
other = await documents.create_document(Document(id=uuid4(), name="other"))
first, second, third, _foreign = [
await sources.create_source(
Source(
document_id=document_id,
page_number=page_number,
upload_name=f"page-{page_number}.jpg",
filename=f"page-{page_number}.jpg",
file_path=f"uploads/page-{page_number}.jpg",
file_hash=str(page_number) * 64,
file_size_bytes=1,
)
)
for document_id, page_number in [
(document.id, 1),
(document.id, 2),
(document.id, 3),
(other.id, 2),
]
]
first_navigation = await sources.read_source_navigation(first.id)
middle_navigation = await sources.read_source_navigation(second.id)
last_navigation = await sources.read_source_navigation(third.id)
assert (first_navigation.previous_id, first_navigation.next_id) == (None, second.id)
assert (middle_navigation.previous_id, middle_navigation.next_id) == (first.id, third.id)
assert (last_navigation.previous_id, last_navigation.next_id) == (second.id, None)
@pytest.mark.asyncio
async def test_transcription_service_job_source_crud_uses_caller_session(default_session_factory):
transcriptions = SourceService(session_factory=default_session_factory)
+33
View File
@@ -2,6 +2,7 @@
import pytest
from sqlalchemy import inspect
from sqlalchemy import text
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -79,6 +80,38 @@ async def test_create_all_seeds_default_registry_rows(tmp_path):
await dispose_database_runtime()
@pytest.mark.asyncio
async def test_create_all_upgrades_existing_person_table_for_family_search(tmp_path):
settings = Settings(
openrouter_api_key="test-key",
database=SqliteSettings(path=str(tmp_path / "upgrade.db")),
environment="test",
)
runtime = initialize_database_runtime(settings=settings)
try:
async with runtime.engine.begin() as connection:
await connection.execute(
text("CREATE TABLE person (id CHAR(32) PRIMARY KEY NOT NULL, full_name VARCHAR NOT NULL)")
)
await create_all(engine=runtime.engine)
async with runtime.engine.connect() as connection:
columns, indexes = await connection.run_sync(
lambda sync_connection: (
{column["name"] for column in inspect(sync_connection).get_columns("person")},
inspect(sync_connection).get_indexes("person"),
)
)
assert "family_search_id" in columns
assert any(
index["column_names"] == ["family_search_id"] and index["unique"] for index in indexes
)
finally:
await dispose_database_runtime()
def test_bootstrap_policy_production_defaults_false():
settings = Settings(openrouter_api_key="test-key", environment="production")
assert settings.should_bootstrap_schema is False
+9 -3
View File
@@ -1,16 +1,14 @@
"""Tests for the V2 SQLModel persistence layer and relationships."""
from datetime import UTC
from datetime import datetime
from uuid import UUID
import pytest
from sqlalchemy.exc import IntegrityError
from transcription.db.models import Document
from transcription.db.models import DocumentType
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.db.models import DocumentType
from transcription.db.models import Job
from transcription.db.models import JobSource
from transcription.db.models import JobSourceStatus
@@ -181,6 +179,14 @@ class TestSourceModel:
class TestPersonAndDocumentPersonModel:
def test_family_search_id_is_unique_when_present(self, session):
session.add(Person(full_name="First Person", family_search_id="G8T4-MDQ"))
session.commit()
session.add(Person(full_name="Second Person", family_search_id="G8T4-MDQ"))
with pytest.raises(IntegrityError):
session.commit()
def test_document_person_role_is_unique_per_document_person(self, session):
document = _persist_document(session)
person = _persist_person(session)
+31 -4
View File
@@ -1,12 +1,17 @@
"""Tests for the documents page routes and action handlers."""
from datetime import date
import pytest
import pytest_asyncio
from sqlmodel import select
from transcription.db import session_scope
from transcription.db.models import Document, DocumentPerson, DocumentPersonRole, Job, Person, Source
from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.db.models import Job
from transcription.db.models import Person
from transcription.db.models import Source
# --- Helper Fixtures ---
@@ -68,6 +73,8 @@ class TestDocumentsPageRendering:
assert "1924 Postcard" in response.text
assert "postcard" in response.text
assert "PC-001" in response.text
assert "Document Date" in response.text
assert "Author" in response.text
def test_document_create_page_renders_form(self, app_client):
_, client = app_client
@@ -80,6 +87,24 @@ class TestDocumentsPageRendering:
assert "Linked People by Role" in response.text
assert "Document type" in response.text
@pytest.mark.asyncio
async def test_document_create_page_preselects_person_with_disambiguating_label(self, app_client):
_, client = app_client
async with session_scope() as session:
person = Person(
full_name="Albert Edward Higgins",
display_name="Hig",
birth_date=date(1885, 1, 2),
)
session.add(person)
await session.commit()
person_id = str(person.id)
response = client.get(f"/ui/documents/new?person_id={person_id}")
assert response.status_code == 200
assert "Hig - Albert Edward Higgins (1885)" in response.text
@pytest.mark.asyncio
async def test_document_detail_page_renders_bento_grid_and_metadata(
self, app_client, seed_person_and_document
@@ -93,6 +118,8 @@ class TestDocumentsPageRendering:
assert "Letter from Hig" in response.text
assert "ZC-1924-001" in response.text
assert "Zenna Cochran" in response.text
assert f"/ui/people/{seed_person_and_document[1]}" in response.text
assert "PIPELINE JOBS" in response.text.upper()
assert "Edit Document" in response.text
@pytest.mark.asyncio
@@ -176,4 +203,4 @@ class TestDocumentsPageRendering:
assert response.status_code == 200
assert "Delete Document" in response.text
assert "Delete document permanently" in response.text
assert "Delete is blocked" not in response.text
assert "Delete is blocked" not in response.text
+34
View File
@@ -0,0 +1,34 @@
from datetime import date
from transcription.db.models import Person
from transcription.ui.components.formatters import compact_date
from transcription.ui.components.formatters import family_search_url
from transcription.ui.components.formatters import person_selector_label
def test_compact_date_prefers_exact_then_approximate_then_unknown():
assert compact_date(date(1924, 3, 2), "about 1924") == "1924-03-02"
assert compact_date(None, "about 1924") == "about 1924"
assert compact_date(None, " ") == "Unknown"
def test_person_selector_label_disambiguates_without_changing_identity():
person = Person(
full_name="Albert Edward Higgins",
display_name="Hig",
birth_date=date(1885, 1, 2),
)
assert person_selector_label(person) == "Hig - Albert Edward Higgins (1885)"
approximate = Person(
full_name="Albert Edward Higgins",
display_name="Hig",
birth_date_raw="about 1912",
)
assert person_selector_label(approximate) == "Hig - Albert Edward Higgins (1912)"
def test_family_search_url_uses_fixed_person_details_route():
assert family_search_url("G8T4-MDQ") == (
"https://www.familysearch.org/tree/person/details/G8T4-MDQ"
)
+5 -4
View File
@@ -2,11 +2,11 @@
import pytest
import pytest_asyncio
from sqlmodel import select
from transcription.db import session_scope
from transcription.db.models import Document, Job, JobSourceStatus, JobStatus
from transcription.db.models import Document
from transcription.db.models import Job
from transcription.db.models import JobStatus
# --- Helper Fixtures ---
@@ -99,6 +99,7 @@ class TestJobsPageRendering:
assert "gpt-4o" in response.text
assert "View Linked Document" in response.text
assert "View Linked Sources" in response.text
assert "updates automatically while the job is active" in response.text
@pytest.mark.asyncio
async def test_job_cancel_page_renders_confirmation(
@@ -164,4 +165,4 @@ class TestJobsPageRendering:
assert response.status_code == 200
assert "Delete Processing Job" in response.text
assert "Delete job permanently" in response.text
assert "Delete is blocked" not in response.text
assert "Delete is blocked" not in response.text
+10 -2
View File
@@ -6,7 +6,10 @@ from uuid import uuid4
import pytest
from transcription.db import session_scope
from transcription.db.models import Document, DocumentPerson, DocumentPersonRole, Person
from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.db.models import Person
@pytest.mark.integration
@@ -47,6 +50,7 @@ class TestPeoplePageRendering:
assert "Full name is required." in response.text
assert "Birth date (YYYY-MM-DD)" in response.text
assert "Death date (YYYY-MM-DD)" in response.text
assert "FamilySearch ID" in response.text
assert "Biography" in response.text
assert "Save person" in response.text
@@ -67,6 +71,7 @@ class TestPeoplePageRendering:
death_place="Arlington",
biography="Computer pioneer",
portrait_path="/images/grace.jpg",
family_search_id="G8T4-MDQ",
)
session.add(person)
await session.commit()
@@ -87,6 +92,9 @@ class TestPeoplePageRendering:
assert "Computer pioneer" in response.text
assert "Created:" in response.text
assert "Updated:" in response.text
assert "Open in FamilySearch" in response.text
assert "familysearch.org/tree/person/details/G8T4-MDQ" in response.text
assert "New Document" in response.text
assert "No linked documents yet." in response.text
@pytest.mark.asyncio
@@ -182,4 +190,4 @@ class TestPeoplePageRendering:
assert response.status_code == 200
assert "Delete Person Record" in response.text
assert "This action permanently deletes the person record." in response.text
assert "Delete person permanently" in response.text
assert "Delete person permanently" in response.text
+8 -3
View File
@@ -4,11 +4,13 @@ from pathlib import Path
import pytest
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from transcription.db import session_scope
from transcription.db.models import Document, Job, JobSourceStatus, JobStatus, Source
from transcription.db.models import Document
from transcription.db.models import Job
from transcription.db.models import JobSourceStatus
from transcription.db.models import JobStatus
from transcription.db.models import Source
# --- Unit Tests for Model @property Definitions ---
@@ -99,6 +101,7 @@ class TestSourcesPageRendering:
assert response.status_code == 200
assert "page_one.png" in response.text
assert "Source Document" in response.text
assert "Stored Filename" not in response.text
@pytest.mark.asyncio
async def test_sources_page_filters_to_document_context(self, app_client):
@@ -211,6 +214,8 @@ class TestSourcesPageRendering:
assert "original transcription text" in response.text
assert "human revision text" in response.text
assert "Save revision" in response.text
assert "Previous Page" in response.text
assert "Next Page" in response.text
@pytest.mark.asyncio
async def test_source_delete_page_blocks_when_source_is_job_linked(