generated from john/python-template
Step 5 implemented
This commit is contained in:
@@ -12,6 +12,8 @@ description = "Historical document transcription system"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.138.0",
|
||||
"nicegui>=3.13.0",
|
||||
"openrouter>=0.7.0",
|
||||
"pydantic>=2.13.4",
|
||||
"pydantic-settings>=2.9.1",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""API route modules for the transcription app."""
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Health endpoint routes."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def healthz() -> dict[str, str]:
|
||||
"""Return a simple health status payload."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/healthz")
|
||||
def healthz_route() -> dict[str, str]:
|
||||
"""Route wrapper for health status payload."""
|
||||
return healthz()
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Application factory and lifespan wiring for the transcription app."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from threading import Event, Thread
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from transcription.api.health import router as health_router
|
||||
from transcription.config import get_settings, setup_logging
|
||||
from transcription.db import create_all
|
||||
from transcription.ui import register_pages
|
||||
from transcription.worker import run_worker_loop
|
||||
|
||||
|
||||
def _start_worker(app: FastAPI) -> None:
|
||||
stop_event = Event()
|
||||
worker_thread = Thread(
|
||||
target=run_worker_loop,
|
||||
kwargs={"stop_event": stop_event, "poll_interval_seconds": 1.0},
|
||||
daemon=True,
|
||||
)
|
||||
worker_thread.start()
|
||||
app.state.worker_stop_event = stop_event
|
||||
app.state.worker_thread = worker_thread
|
||||
|
||||
|
||||
def _stop_worker(app: FastAPI) -> None:
|
||||
stop_event = getattr(app.state, "worker_stop_event", None)
|
||||
worker_thread = getattr(app.state, "worker_thread", None)
|
||||
|
||||
if stop_event is not None:
|
||||
stop_event.set()
|
||||
if worker_thread is not None:
|
||||
worker_thread.join(timeout=2.0)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(app: FastAPI):
|
||||
setup_logging()
|
||||
create_all()
|
||||
|
||||
settings = get_settings()
|
||||
settings.upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
settings.prompt_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_start_worker(app)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_stop_worker(app)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Create and configure the FastAPI application."""
|
||||
app = FastAPI(title="Transcription", lifespan=_lifespan)
|
||||
register_pages(app)
|
||||
app.include_router(health_router)
|
||||
return app
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""UI page registration exports."""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from nicegui import ui
|
||||
|
||||
from transcription.ui.jobs_page import register_page as register_jobs_page
|
||||
from transcription.ui.upload_page import register_page as register_upload_page
|
||||
|
||||
|
||||
def register_pages(app: FastAPI) -> None:
|
||||
"""Register all NiceGUI pages and mount them onto the FastAPI app."""
|
||||
register_upload_page()
|
||||
register_jobs_page()
|
||||
ui.run_with(app, mount_path="/ui", show_welcome_message=False)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Jobs list and detail page registration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
from nicegui import ui
|
||||
from sqlmodel import select
|
||||
|
||||
from transcription.db import get_session
|
||||
from transcription.models import Document, Job, Transcript
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JobView:
|
||||
"""Read model for rendering job rows in the UI."""
|
||||
|
||||
id: UUID
|
||||
status: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
def fetch_jobs() -> list[JobView]:
|
||||
"""Return jobs for display in most-recent-first order."""
|
||||
with get_session() as session:
|
||||
jobs = session.exec(select(Job).order_by(Job.created_at.desc())).all()
|
||||
return [
|
||||
JobView(
|
||||
id=job.id,
|
||||
status=job.status.value,
|
||||
created_at=job.created_at.isoformat(),
|
||||
updated_at=job.updated_at.isoformat(),
|
||||
)
|
||||
for job in jobs
|
||||
]
|
||||
|
||||
|
||||
def fetch_job_detail(job_id: UUID) -> tuple[Job | None, Document | None, Transcript | None]:
|
||||
"""Return job, document, and transcript for detail view."""
|
||||
with get_session() as session:
|
||||
job = session.get(Job, job_id)
|
||||
if job is None:
|
||||
return None, None, None
|
||||
document = session.get(Document, job.document_id)
|
||||
transcript = session.exec(select(Transcript).where(Transcript.job_id == job.id)).first()
|
||||
return job, document, transcript
|
||||
|
||||
|
||||
def register_page() -> None:
|
||||
"""Register jobs list and detail routes."""
|
||||
|
||||
@ui.page("/jobs")
|
||||
def jobs_page() -> None:
|
||||
ui.label("Transcription Jobs")
|
||||
status = ui.label("Ready")
|
||||
table_container = ui.column()
|
||||
|
||||
def render_table() -> None:
|
||||
table_container.clear()
|
||||
jobs = fetch_jobs()
|
||||
with table_container:
|
||||
if not jobs:
|
||||
ui.label("No jobs yet.")
|
||||
return
|
||||
rows = [
|
||||
{
|
||||
"id": str(job.id),
|
||||
"status": job.status,
|
||||
"created_at": job.created_at,
|
||||
"updated_at": job.updated_at,
|
||||
}
|
||||
for job in jobs
|
||||
]
|
||||
ui.table(
|
||||
columns=[
|
||||
{"name": "id", "label": "Job ID", "field": "id"},
|
||||
{"name": "status", "label": "Status", "field": "status"},
|
||||
{"name": "created_at", "label": "Created", "field": "created_at"},
|
||||
{"name": "updated_at", "label": "Updated", "field": "updated_at"},
|
||||
],
|
||||
rows=rows,
|
||||
row_key="id",
|
||||
)
|
||||
for row in rows:
|
||||
ui.link(f"Open {row['id']}", f"/jobs/{row['id']}")
|
||||
|
||||
def refresh() -> None:
|
||||
status.text = "Refreshing..."
|
||||
try:
|
||||
render_table()
|
||||
status.text = "Refreshed"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
status.text = f"Refresh failed: {exc}"
|
||||
|
||||
ui.button("Refresh", on_click=refresh)
|
||||
render_table()
|
||||
ui.link("Back to upload", "/")
|
||||
|
||||
@ui.page("/jobs/{job_id}")
|
||||
def job_detail_page(job_id: str) -> None:
|
||||
ui.label("Job Detail")
|
||||
try:
|
||||
parsed_id = UUID(job_id)
|
||||
except ValueError:
|
||||
ui.label("Invalid job id")
|
||||
ui.link("Back to jobs", "/jobs")
|
||||
return
|
||||
|
||||
job, document, transcript = fetch_job_detail(parsed_id)
|
||||
if job is None:
|
||||
ui.label("Job not found")
|
||||
ui.link("Back to jobs", "/jobs")
|
||||
return
|
||||
|
||||
ui.label(f"Job ID: {job.id}")
|
||||
ui.label(f"Status: {job.status.value}")
|
||||
ui.label(f"Created: {job.created_at.isoformat()}")
|
||||
ui.label(f"Updated: {job.updated_at.isoformat()}")
|
||||
|
||||
if document is not None:
|
||||
ui.label(f"Filename: {document.filename}")
|
||||
ui.label(f"File path: {document.file_path}")
|
||||
|
||||
if transcript is None:
|
||||
ui.label("Transcript not available yet.")
|
||||
elif transcript.text:
|
||||
ui.label("Transcript:")
|
||||
ui.markdown(transcript.text)
|
||||
elif transcript.error_detail:
|
||||
ui.label("Failure detail:")
|
||||
ui.label(transcript.error_detail)
|
||||
|
||||
ui.link("Back to jobs", "/jobs")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Upload page registration and handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from nicegui import ui
|
||||
from nicegui.events import UploadEventArguments
|
||||
|
||||
from transcription.services.upload import UploadError, UploadJobResult, create_upload_job
|
||||
|
||||
|
||||
@dataclass
|
||||
class UploadPageState:
|
||||
"""Simple state container for upload page feedback."""
|
||||
|
||||
loading: bool = False
|
||||
message: str = ""
|
||||
|
||||
|
||||
def accepted_upload_types() -> str:
|
||||
"""Return accepted file type string for upload input."""
|
||||
return ".jpg,.jpeg,.png,.tif,.tiff,.pdf"
|
||||
|
||||
|
||||
def submit_upload(*, filename: str, file_bytes: bytes) -> UploadJobResult:
|
||||
"""Create an upload job from incoming file data."""
|
||||
return create_upload_job(filename=filename, file_bytes=file_bytes)
|
||||
|
||||
|
||||
def register_page() -> None:
|
||||
"""Register the upload page route."""
|
||||
|
||||
@ui.page("/")
|
||||
def upload_page() -> None:
|
||||
state = UploadPageState()
|
||||
status_label = ui.label("Upload a document to start transcription.")
|
||||
|
||||
def on_upload(event: UploadEventArguments) -> None:
|
||||
state.loading = True
|
||||
status_label.text = "Uploading..."
|
||||
try:
|
||||
payload = event.content.read()
|
||||
result = submit_upload(filename=event.name, file_bytes=payload)
|
||||
state.message = f"Created job {result.job_id}"
|
||||
status_label.text = state.message
|
||||
ui.notify(state.message, type="positive")
|
||||
except UploadError as exc:
|
||||
state.message = str(exc)
|
||||
status_label.text = f"Upload failed: {state.message}"
|
||||
ui.notify(f"Upload failed: {state.message}", type="negative")
|
||||
finally:
|
||||
state.loading = False
|
||||
|
||||
ui.upload(
|
||||
on_upload=on_upload,
|
||||
auto_upload=True,
|
||||
label="Select document file",
|
||||
).props(f"accept={accepted_upload_types()}")
|
||||
|
||||
with ui.row():
|
||||
ui.link("View jobs", "/ui/jobs")
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Tests for transcription.api.health."""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from transcription.api.health import router
|
||||
|
||||
|
||||
class TestHealthEndpoint:
|
||||
"""Verify /healthz endpoint behavior."""
|
||||
|
||||
def test_healthz_returns_ok_status(self):
|
||||
"""GET /healthz returns a healthy status payload."""
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/healthz")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
@@ -13,11 +13,8 @@ original envelope with its 2 cent stamp. The letter has a number of references t
|
||||
children. Peter was Louis; Polly was Edith; and the 'little black rascal' referred to Maurice.
|
||||
Miss Saville was the nurse at the Nome Hospital that was mentioned in the article by Inez in
|
||||
the family newsletter two years ago.
|
||||
|
||||
Nome Alaska August 26, 1923
|
||||
|
||||
My Dear Ethel et al.
|
||||
|
||||
I don't know when I did write or when you did
|
||||
but I am going to write now however and never
|
||||
the less. But I wish I could talk (I can yet but I
|
||||
@@ -26,7 +23,6 @@ and Polly sit up and listen and that little black
|
||||
rascal of yours would fairly sparkle with
|
||||
listening. Can't I see him listening now to all the
|
||||
yarns we told last summer?
|
||||
[photo of people on a frozen body of water with icebergs and a boat]
|
||||
You see, we-Miss Saville and I, took a trip north
|
||||
on the Buford and it was very interesting. We
|
||||
went north thru the Bering Strait into the Arctic and as far as the Ice Pack. There the captain
|
||||
@@ -37,13 +33,13 @@ along the icebergs and the walrus were 'heisted' on board by the use of the cran
|
||||
tons of freight and the beasts were so huge that they made the pulleys just creak. They were
|
||||
over 12 feet long, as big around as three cows, had no feet but toenails on their flippers or
|
||||
flappers, no head but their body just suddenly ended with a hole for a mouth and big bristles
|
||||
|
||||
all around it similar to a currycomb in coarseness; no ears but huge tusks of ivory. They are
|
||||
the most repulsive looking animals imaginable and tho I have always read about them I never
|
||||
expect such disagreeable looking creatures. They had a rough brown hairy skin and some of
|
||||
them looked warty. They must have weighed two ton at least. Ere we got them back to Nome
|
||||
to the natives they were getting extremely odiferous—in fact, you could scarcely stay on the
|
||||
ship with any degree of comfort unless you had per chance lost your sense of smell.
|
||||
|
||||
Then we went north to a few minutes beyond the 70th degree of latitude and thot for awhile
|
||||
we would go to Wrangell Island where some men from Steffonsons ship were supposed to be
|
||||
stranded but we didn't get there and instead stopped at a small native village at Cape Serdz in
|
||||
@@ -58,7 +54,6 @@ comfortable. The babies were in fur skins with the fur inside and they looked li
|
||||
bears with faces. I guess they had never seen white women, not so many at one time anyway.
|
||||
We went ashore in 2 life boats and a launch pulling them. On the launch was a six piece band
|
||||
playing and the rear of the last life boat was the movie man. 'Twas very thrilling.
|
||||
|
||||
The other place we stopped was at Whalen, a trading post in Siberia. There these 'towerists'
|
||||
went wild. They rushed helter-skelter, hither and thither, here and there, trying to find
|
||||
something to buy. Prices raised right before your eyes. One would but something for $1.00
|
||||
@@ -72,21 +67,19 @@ come down and the natives capture them. There was more junk brot aboard than bag
|
||||
do believe. And they say that at the first stop it was worse than here. The red flag was flying
|
||||
over Whalen and the Russian soldiers were there—a few, one or two or three, I forget the
|
||||
number.
|
||||
|
||||
We got home yesterday morning at 5 a.m. but missed the first lighter in so had to stay out
|
||||
until 2:30. The girls had prepared a big meal for us and invited up the Hartfords and then let
|
||||
us talk. Miss Saville talked quite a bit. Any how if you folks don't like this I don't care, it is
|
||||
all I had to write about and I know Buster'ud listen anyway and I'd soak ole Peter's head if he
|
||||
didn't and Polly would in my lap and I don't know much about the youngest one of yours so
|
||||
likely he would be squawling. But we did surely enjoy our trip and were gone just long enuf.
|
||||
|
||||
I expect there were 150 passengers on board and almost or more of the crew and helpers. We
|
||||
had a stateroom down next to the kitchen and 'twas pretty fierce for odor at times.
|
||||
|
||||
I have had jobs nearly all summer but not very much in them. Next week, September 4,
|
||||
school opens. I wish they would wait for a week but you know these school men. Wouldn't
|
||||
make any special difference I suppose for I would just fritter away the time but still one likes
|
||||
to postpone the inevitable.
|
||||
|
||||
I just now stopped and re-read your letter and it sure was a bird. I don't especially blame Ray
|
||||
for reading over your shoulder. It would seem, then that you have bright children. Maybe
|
||||
they do know something about Geography. But it is ridiculous to speak of Louis finishing the
|
||||
@@ -95,19 +88,13 @@ am rather afraid he doesn't know much. I quite remember your little timid Mauric
|
||||
he shifted his affection from his Aunt Om and yelled and howled and screamed steadily for a
|
||||
week while his mother went to S.S. Ask him is he recalls this little interview. Do you suppose
|
||||
he does?
|
||||
|
||||
Ever hear from Zen? or Inez? They don't seem to be very writing inclined tho Zen has done
|
||||
well this year. Even sent me a telegram a few weeks ago. Well, if anything else ever happens,
|
||||
I'll write again. Don't suppose it ever will, tho.
|
||||
|
||||
Lots of love to all,
|
||||
|
||||
Ome
|
||||
|
||||
Reprinted from Cochran Chronicles, Volume 9, Number 1, November 1986
|
||||
|
||||
© JECFA 1986
|
||||
|
||||
Up
|
||||
|
||||
jecochranclan.org ~ Contact webmaster
|
||||
|
||||
@@ -2,29 +2,29 @@ source: Rod Moser Letter - p1.jpg
|
||||
provider: openrouter
|
||||
model: google/gemini-2.5-flash
|
||||
---
|
||||
JOHN ISBILL R. T. MOSER
|
||||
|
||||
JOHN ISBILL R.T. MOSER
|
||||
ISBILL & MOSER
|
||||
DEALERS IN
|
||||
GENERAL MERCHANDISE
|
||||
|
||||
Vonore, Tenn. [Janu]ary 27 – 1913
|
||||
Dear [Much Au][illegible]
|
||||
Has at hour a
|
||||
few nigh [ago I] said a
|
||||
letter f[rom] your folks, so
|
||||
I [decide]d to [write] you
|
||||
a few lines myself &
|
||||
I am continuously a
|
||||
trip out just next summer
|
||||
& I [want] [lo]t [of figures?] to go
|
||||
Vonore, Tenn. January 27- 1913-
|
||||
|
||||
Dear Much Aunt Aduian
|
||||
How are hour [sic] a
|
||||
few nights ago r [and] said a
|
||||
letter from your folks, So
|
||||
I decided to write you
|
||||
a few lines myself ok
|
||||
I am contemplate [sic] a
|
||||
trip out west next summer
|
||||
r [and] want one of Aldons to go
|
||||
where I am.
|
||||
We are getting
|
||||
up in years & unmarried
|
||||
so you see the object o[f]
|
||||
my trip is to get a wife
|
||||
If th[ere] is any old maids
|
||||
or widows out there I
|
||||
up in years r [and] remarrie[d]
|
||||
So you are the object of
|
||||
my trip, is to get a wife
|
||||
to put in any old maid
|
||||
or widows out there, I
|
||||
want you to kiss them
|
||||
at my [hand] and my at them
|
||||
at my [illegible] or at them
|
||||
as soon as I get them
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Tests for transcription.app."""
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from transcription.app import create_app
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestAppFactory:
|
||||
"""Verify FastAPI app factory wiring."""
|
||||
|
||||
def test_create_app_returns_fastapi_instance(self):
|
||||
"""create_app returns a FastAPI application instance."""
|
||||
app = create_app()
|
||||
assert isinstance(app, FastAPI)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestAppLifespan:
|
||||
"""Verify startup and shutdown lifecycle behavior."""
|
||||
|
||||
def test_startup_initializes_runtime_dependencies(self, monkeypatch):
|
||||
"""Startup initializes logging, schema, directories, and worker resources."""
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr("transcription.app.setup_logging", lambda: calls.append("logging"))
|
||||
monkeypatch.setattr("transcription.app.create_all", lambda: calls.append("schema"))
|
||||
monkeypatch.setattr("transcription.app._start_worker", lambda _app: calls.append("start_worker"))
|
||||
monkeypatch.setattr("transcription.app._stop_worker", lambda _app: calls.append("stop_worker"))
|
||||
|
||||
class _Dir:
|
||||
def mkdir(self, parents: bool, exist_ok: bool):
|
||||
calls.append("mkdir")
|
||||
|
||||
class _Settings:
|
||||
upload_dir = _Dir()
|
||||
prompt_dir = _Dir()
|
||||
|
||||
monkeypatch.setattr("transcription.app.get_settings", lambda: _Settings())
|
||||
|
||||
app = create_app()
|
||||
with TestClient(app):
|
||||
pass
|
||||
|
||||
assert "logging" in calls
|
||||
assert "schema" in calls
|
||||
assert "mkdir" in calls
|
||||
assert "start_worker" in calls
|
||||
|
||||
def test_shutdown_stops_worker_resources(self, monkeypatch):
|
||||
"""Shutdown signals and stops worker resources cleanly."""
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr("transcription.app.setup_logging", lambda: None)
|
||||
monkeypatch.setattr("transcription.app.create_all", lambda: None)
|
||||
monkeypatch.setattr("transcription.app._start_worker", lambda _app: calls.append("start_worker"))
|
||||
monkeypatch.setattr("transcription.app._stop_worker", lambda _app: calls.append("stop_worker"))
|
||||
|
||||
class _Dir:
|
||||
def mkdir(self, parents: bool, exist_ok: bool):
|
||||
return None
|
||||
|
||||
class _Settings:
|
||||
upload_dir = _Dir()
|
||||
prompt_dir = _Dir()
|
||||
|
||||
monkeypatch.setattr("transcription.app.get_settings", lambda: _Settings())
|
||||
|
||||
app = create_app()
|
||||
with TestClient(app):
|
||||
pass
|
||||
|
||||
assert calls == ["start_worker", "stop_worker"]
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Tests for transcription.ui.jobs_page."""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from transcription.models import Document, Job, Transcript
|
||||
from transcription.ui.jobs_page import fetch_job_detail, fetch_jobs
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestJobsListBehavior:
|
||||
"""Verify job list data and rendering helpers."""
|
||||
|
||||
def test_fetch_jobs_returns_job_view_rows(self, session, monkeypatch):
|
||||
"""fetch_jobs returns normalized JobView rows for UI consumption."""
|
||||
document = Document(filename="letter.jpg", file_path="uploads/letter.jpg")
|
||||
session.add(document)
|
||||
session.commit()
|
||||
session.refresh(document)
|
||||
|
||||
job = Job(document_id=document.id)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
|
||||
class _SessionContext:
|
||||
def __enter__(self):
|
||||
return session
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("transcription.ui.jobs_page.get_session", lambda: _SessionContext())
|
||||
|
||||
rows = fetch_jobs()
|
||||
|
||||
assert len(rows) == 1
|
||||
assert rows[0].id == job.id
|
||||
assert rows[0].status == "queued"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestJobDetailBehavior:
|
||||
"""Verify job detail retrieval behavior."""
|
||||
|
||||
def test_fetch_job_detail_returns_related_records_when_present(self, session, monkeypatch):
|
||||
"""fetch_job_detail returns job, document, and transcript when available."""
|
||||
document = Document(filename="typed.jpg", file_path="uploads/typed.jpg")
|
||||
session.add(document)
|
||||
session.commit()
|
||||
session.refresh(document)
|
||||
|
||||
job = Job(document_id=document.id)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
session.refresh(job)
|
||||
|
||||
transcript = Transcript(job_id=job.id, text="Transcript text")
|
||||
session.add(transcript)
|
||||
session.commit()
|
||||
|
||||
class _SessionContext:
|
||||
def __enter__(self):
|
||||
return session
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("transcription.ui.jobs_page.get_session", lambda: _SessionContext())
|
||||
|
||||
fetched_job, fetched_document, fetched_transcript = fetch_job_detail(job.id)
|
||||
|
||||
assert fetched_job is not None
|
||||
assert fetched_document is not None
|
||||
assert fetched_transcript is not None
|
||||
assert fetched_job.id == job.id
|
||||
assert fetched_document.id == document.id
|
||||
assert fetched_transcript.job_id == job.id
|
||||
|
||||
def test_fetch_job_detail_returns_nones_for_missing_job(self, session, monkeypatch):
|
||||
"""fetch_job_detail returns triple None when job does not exist."""
|
||||
class _SessionContext:
|
||||
def __enter__(self):
|
||||
return session
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("transcription.ui.jobs_page.get_session", lambda: _SessionContext())
|
||||
|
||||
fetched_job, fetched_document, fetched_transcript = fetch_job_detail(uuid4())
|
||||
|
||||
assert fetched_job is None
|
||||
assert fetched_document is None
|
||||
assert fetched_transcript is None
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Tests for UI page registration wiring."""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
|
||||
from transcription.ui import register_pages
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestPageRegistration:
|
||||
"""Verify page registration and route wiring."""
|
||||
|
||||
def test_register_pages_adds_expected_routes(self):
|
||||
"""register_pages wires upload and jobs routes into the app."""
|
||||
app = FastAPI()
|
||||
register_pages(app)
|
||||
app.add_api_route("/healthz", lambda: {"status": "ok"}, methods=["GET"])
|
||||
|
||||
client = TestClient(app)
|
||||
ui_response = client.get("/ui")
|
||||
health_response = client.get("/healthz")
|
||||
|
||||
assert ui_response.status_code == 200
|
||||
assert health_response.status_code == 200
|
||||
assert health_response.json() == {"status": "ok"}
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Tests for transcription.ui.upload_page."""
|
||||
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from transcription.services.upload import UploadError, UploadJobResult
|
||||
from transcription.ui import upload_page
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUploadPageBehavior:
|
||||
"""Verify upload page helper and submission behavior."""
|
||||
|
||||
def test_accepted_upload_types_contains_supported_extensions(self):
|
||||
"""accepted_upload_types includes all MVP-supported upload extensions."""
|
||||
accepted = upload_page.accepted_upload_types()
|
||||
assert ".jpg" in accepted
|
||||
assert ".jpeg" in accepted
|
||||
assert ".png" in accepted
|
||||
assert ".tif" in accepted
|
||||
assert ".tiff" in accepted
|
||||
assert ".pdf" in accepted
|
||||
|
||||
def test_submit_upload_calls_upload_service(self, monkeypatch):
|
||||
"""submit_upload delegates file persistence and job creation to upload service."""
|
||||
expected = UploadJobResult(
|
||||
document_id=uuid4(),
|
||||
job_id=uuid4(),
|
||||
stored_path=Path("uploads/mock.jpg"),
|
||||
original_filename="mock.jpg",
|
||||
)
|
||||
|
||||
def fake_create_upload_job(*, filename: str, file_bytes: bytes):
|
||||
assert filename == "mock.jpg"
|
||||
assert file_bytes == b"bytes"
|
||||
return expected
|
||||
|
||||
monkeypatch.setattr(upload_page, "create_upload_job", fake_create_upload_job)
|
||||
|
||||
result = upload_page.submit_upload(filename="mock.jpg", file_bytes=b"bytes")
|
||||
assert result == expected
|
||||
|
||||
def test_submit_upload_surfaces_upload_error(self, monkeypatch):
|
||||
"""submit_upload raises UploadError for invalid upload payloads."""
|
||||
def fake_create_upload_job(*, filename: str, file_bytes: bytes):
|
||||
raise UploadError("invalid payload")
|
||||
|
||||
monkeypatch.setattr(upload_page, "create_upload_job", fake_create_upload_job)
|
||||
|
||||
with pytest.raises(UploadError):
|
||||
upload_page.submit_upload(filename="bad.jpg", file_bytes=b"")
|
||||
Reference in New Issue
Block a user