"""Testes do parser de corpus USFX.
O corpus é XML estruturado: não há OCR, nem página, nem reconstrução de
capítulo/versículo. O que pode dar errado é o mapeamento estrutural (texto no
`tail` de ``, capítulo corrente, nome do livro em ``) e a normalização do
texto. Os testes usam um USFX sintético mínimo para isolar cada regra, mais uma
verificação de integridade contra o corpus real versionado.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
BACKEND = Path(__file__).resolve().parents[1]
REPO_ROOT = BACKEND.parent
sys.path.insert(0, str(BACKEND))
from app.corpus import ( # noqa: E402
BOOKS,
CorpusParseError,
VerseChunk,
canonical_book,
parse_usfx,
)
REAL_CORPUS = REPO_ROOT / "corpus" / "por-almeida.usfx.xml"
def write_usfx(tmp_path: Path, body: str) -> Path:
path = tmp_path / "corpus.usfx.xml"
path.write_text(
"\n"
"\n"
f"{body}\n\n",
encoding="utf-8",
)
return path
# --------------------------------------------------------------------------
# Mapeamento estrutural
# --------------------------------------------------------------------------
def test_verse_text_comes_from_the_tail_of_the_v_element(tmp_path):
"""`` é auto-fechado: o texto é o tail, não filho."""
path = write_usfx(
tmp_path,
"""
Gênesis
No princípio criou Deus os céus e a terra.
E a terra era sem forma e vazia.
""",
)
chunks = list(parse_usfx(path))
assert [chunk.verse for chunk in chunks] == [1, 2]
assert chunks[0].text == "No princípio criou Deus os céus e a terra."
assert chunks[1].text == "E a terra era sem forma e vazia."
def test_verse_numbers_restart_at_each_chapter(tmp_path):
"""O capítulo corrente vem do último ``; o verso reinicia em cada um."""
path = write_usfx(
tmp_path,
"""
Gênesis
Um.
Dois.
Três.
""",
)
chunks = list(parse_usfx(path))
assert [(c.chapter, c.verse, c.text) for c in chunks] == [
(1, 1, "Um."),
(1, 2, "Dois."),
(2, 1, "Três."),
]
def test_book_name_comes_from_the_h_element(tmp_path):
"""A grafia canônica é a que o corpus declara em ``, não uma lista fixa."""
path = write_usfx(
tmp_path,
"""
1 Samuel
Texto.
""",
)
chunks = list(parse_usfx(path))
assert chunks[0].book == "1 Samuel"
def test_file_name_is_recorded_for_provenance(tmp_path):
path = write_usfx(
tmp_path,
"""
GênesisTexto.
""",
)
chunks = list(parse_usfx(path))
assert chunks[0].file == "corpus.usfx.xml"
def test_explicit_file_name_overrides_the_path_basename(tmp_path):
path = write_usfx(
tmp_path,
"""
GênesisTexto.
""",
)
chunks = list(parse_usfx(path, file_name="almeida"))
assert chunks[0].file == "almeida"
# --------------------------------------------------------------------------
# Normalização de texto
# --------------------------------------------------------------------------
def test_non_breaking_space_and_runs_of_whitespace_are_normalized(tmp_path):
"""O corpus real usa \\xa0; espaços múltiplos viram um só."""
path = write_usfx(
tmp_path,
"""
Gênesis
Primeira\u00a0parte segunda parte.\u00a0\u00a0
""",
)
chunks = list(parse_usfx(path))
assert chunks[0].text == "Primeira parte segunda parte."
assert "\xa0" not in chunks[0].text
def test_placeholder_verses_are_skipped(tmp_path):
"""Versículo sem letra nem dígito é ruído: não pode virar chunk."""
path = write_usfx(
tmp_path,
"""
Mateus
Versículo real.
—
Outro versículo real.
""",
)
chunks = list(parse_usfx(path))
assert [chunk.verse for chunk in chunks] == [20, 22]
def test_verse_without_closing_ve_still_yields_its_text(tmp_path):
"""`` é marcador de fim; sua ausência não pode descartar o versículo."""
path = write_usfx(
tmp_path,
"""
Gênesis
Texto sem marcador de fim.
""",
)
chunks = list(parse_usfx(path))
assert len(chunks) == 1
assert chunks[0].text == "Texto sem marcador de fim."
# --------------------------------------------------------------------------
# Erros
# --------------------------------------------------------------------------
def test_malformed_xml_raises_corpus_parse_error(tmp_path):
path = tmp_path / "quebrado.usfx.xml"
path.write_text("Gênesis", encoding="utf-8")
with pytest.raises(CorpusParseError):
list(parse_usfx(path))
def test_missing_file_raises_corpus_parse_error(tmp_path):
with pytest.raises(CorpusParseError):
list(parse_usfx(tmp_path / "nao-existe.usfx.xml"))
def test_document_without_books_raises_corpus_parse_error(tmp_path):
"""Um XML válido mas sem nenhum livro é corpus inútil: falha explícita."""
path = write_usfx(tmp_path, "")
with pytest.raises(CorpusParseError):
list(parse_usfx(path))
# --------------------------------------------------------------------------
# Identidade do chunk
# --------------------------------------------------------------------------
def test_reference_uses_livro_capitulo_versiculo():
chunk = VerseChunk(
book="João", chapter=3, verse=16, file="almeida", text="Porque Deus amou"
)
assert chunk.reference == "João 3:16"
def test_chunk_id_is_deterministic_and_unique():
first = VerseChunk(book="João", chapter=3, verse=16, file="a", text="x")
same = VerseChunk(book="João", chapter=3, verse=16, file="a", text="x")
other_verse = VerseChunk(book="João", chapter=3, verse=17, file="a", text="x")
other_file = VerseChunk(book="João", chapter=3, verse=16, file="b", text="x")
assert first.chunk_id == same.chunk_id
assert len({first.chunk_id, other_verse.chunk_id, other_file.chunk_id}) == 3
def test_chunk_id_does_not_depend_on_the_text():
"""Reindexar com OCR/edição diferente não pode trocar a identidade do verso."""
a = VerseChunk(book="João", chapter=3, verse=16, file="a", text="versão 1")
b = VerseChunk(book="João", chapter=3, verse=16, file="a", text="versão 2")
assert a.chunk_id == b.chunk_id
# --------------------------------------------------------------------------
# Nomes canônicos e aliases
# --------------------------------------------------------------------------
@pytest.mark.parametrize(
"name,expected",
[
("João", "João"),
("1 Samuel", "1 Samuel"),
("I Samuel", "1 Samuel"),
("II Reis", "2 Reis"),
("III João", "3 João"),
("Gn", "Gênesis"),
("Sl", "Salmos"),
("1Co", "1 Coríntios"),
("Ap", "Apocalipse"),
("Cântico dos Cânticos", "Cânticos"),
("Cantares", "Cânticos"),
("Lamentações de Jeremias", "Lamentações"),
("Atos dos Apóstolos", "Atos"),
("Salmo", "Salmos"),
],
)
def test_canonical_book_resolves_variants(name, expected):
assert canonical_book(name) == expected
@pytest.mark.parametrize("name", ["joao", "JOÃO", " João ", "Isaias", "Miqueias"])
def test_canonical_book_is_case_and_accent_insensitive(name):
assert canonical_book(name) is not None
def test_canonical_book_rejects_unknown_names():
assert canonical_book("Evangelho de Tomé") is None
assert canonical_book("") is None
def test_books_covers_the_full_protestant_canon():
"""66 livros, incluindo Neemias e Ester — ausentes na edição em PDF."""
assert len(BOOKS) == 66
assert "Neemias" in BOOKS
assert "Ester" in BOOKS
assert BOOKS[0] == "Gênesis"
assert BOOKS[-1] == "Apocalipse"
# --------------------------------------------------------------------------
# Integridade contra o corpus real versionado
# --------------------------------------------------------------------------
@pytest.mark.skipif(not REAL_CORPUS.exists(), reason="corpus real não versionado")
def test_real_corpus_parses_completely():
chunks = list(parse_usfx(REAL_CORPUS))
assert len(chunks) == 31098
assert len({chunk.book for chunk in chunks}) == 66
assert len({chunk.chunk_id for chunk in chunks}) == len(chunks), "ids duplicados"
assert all(chunk.text for chunk in chunks)
assert all(chunk.chapter >= 1 and chunk.verse >= 1 for chunk in chunks)
@pytest.mark.skipif(not REAL_CORPUS.exists(), reason="corpus real não versionado")
def test_real_corpus_keeps_known_verses_intact():
chunks = {(c.book, c.chapter, c.verse): c.text for c in parse_usfx(REAL_CORPUS)}
assert chunks[("Gênesis", 1, 1)] == "No princípio criou Deus os céus e a terra."
assert chunks[("Salmos", 23, 1)].startswith("O Senhor é o meu pastor")
assert chunks[("João", 3, 16)].startswith("Porque Deus amou o mundo")
@pytest.mark.skipif(not REAL_CORPUS.exists(), reason="corpus real não versionado")
def test_real_corpus_declares_every_book_as_canonical():
"""Nenhum `` do corpus pode ficar de fora de BOOKS/aliases."""
books = {chunk.book for chunk in parse_usfx(REAL_CORPUS)}
assert books <= set(BOOKS)
# --------------------------------------------------------------------------
# Janela de contexto
# --------------------------------------------------------------------------
CONTEXT_BOOK = book_xml = """
Salmos
O Senhor é o meu pastor; nada me faltará.
Deitar-me faz em verdes pastos.
Guia-me mansamente a águas tranquilas.
Refrigera a minha alma.
Do Senhor é a terra e a sua plenitude.
"""
def test_text_stays_the_single_verse_and_context_holds_the_window(tmp_path):
"""A citação continua apontando para um versículo; o contexto é só para busca."""
path = write_usfx(tmp_path, CONTEXT_BOOK)
chunks = list(parse_usfx(path, context_window=1))
first = chunks[0]
assert first.text == "O Senhor é o meu pastor; nada me faltará."
assert first.reference == "Salmos 23:1"
assert first.context.startswith("O Senhor é o meu pastor")
assert "Deitar-me faz em verdes pastos." in first.context
def test_context_is_clipped_at_the_start_of_the_book(tmp_path):
path = write_usfx(tmp_path, CONTEXT_BOOK)
chunks = list(parse_usfx(path, context_window=2))
assert chunks[0].context.startswith("O Senhor é o meu pastor")
def test_context_does_not_cross_books(tmp_path):
"""Contexto de outro livro misturaria evidência de obras diferentes."""
path = write_usfx(
tmp_path,
"""
Salmos
Bem-aventurado o varão.
Antes tem o seu prazer na lei.
Provérbios
Provérbios de Salomão.
Para se conhecer a sabedoria.
""",
)
chunks = list(parse_usfx(path, context_window=2))
salmos = [chunk for chunk in chunks if chunk.book == "Salmos"]
assert salmos, "esperava chunks de Salmos"
assert all("Provérbios de Salomão" not in chunk.context for chunk in salmos)
def test_context_window_zero_falls_back_to_the_verse(tmp_path):
path = write_usfx(tmp_path, CONTEXT_BOOK)
chunks = list(parse_usfx(path, context_window=0))
assert chunks[0].context == chunks[0].text
def test_embedding_text_prefers_the_context_window(tmp_path):
path = write_usfx(tmp_path, CONTEXT_BOOK)
chunk = parse_usfx(path, context_window=1).__next__()
assert chunk.embedding_text == chunk.context
assert chunk.embedding_text != chunk.text
def test_context_window_crosses_chapters_inside_the_same_book(tmp_path):
"""A narrativa continua entre capítulos; cortar ali perderia sentido."""
path = write_usfx(tmp_path, CONTEXT_BOOK)
chunks = {chunk.reference: chunk for chunk in parse_usfx(path, context_window=1)}
assert "Do Senhor é a terra" in chunks["Salmos 23:4"].context
assert "Refrigera a minha alma" in chunks["Salmos 24:1"].context
@pytest.mark.skipif(not REAL_CORPUS.exists(), reason="corpus real não versionado")
def test_real_corpus_chunks_keep_verse_and_context_consistent():
chunks = list(parse_usfx(REAL_CORPUS, context_window=2))
assert len(chunks) == 31098
sample = next(c for c in chunks if c.reference == "Salmos 23:1")
assert sample.text == "O Senhor é o meu pastor; nada me faltará."
assert "Deitar-me faz em pastos verdejantes" in sample.context
assert all(chunk.text in chunk.context for chunk in chunks[:500])