Files
epub_bilingual_translator/tests/test_headless.py
T

202 lines
11 KiB
Python

import asyncio
import json
import zipfile
from pathlib import Path
from ebooklib import epub
from src.common.data_model import BookStructure
from src.preprocessing.epub_cleaner import EpubCleaner
from src.preprocessing.text_extractor import FineGrainedExtractor
from src.preprocessing.navigation import prepare_navigation
from src.assembly.restoration_engine import RestorationEngine
from src.assembly.backfiller import BackfillEngine
from src.assembly.builder import BilingualBuilder
from src.qa.headless import audit_pair, book_inventory, notes_report
def source_book(tmp_path, gateway=False):
book = epub.EpubBook(); book.set_identifier('headless'); book.set_title('QA'); book.set_language('en')
ch = epub.EpubHtml(uid='chapter', file_name='Text/ch.xhtml', title='Chapter 1')
href = 'notes.xhtml' if gateway else 'notes.xhtml#n1'
ch.content = '<h1 id="title">Chapter 1</h1>' + ''.join(
f'<p id="p{i}">Text {i}.<a role="doc-noteref" href="{href}">{i}</a></p>' for i in range(4))
notes = epub.EpubHtml(uid='notes', file_name='Text/notes.xhtml', title='Notes')
notes.content = ('<h1>Author note</h1><p>Endnotes at <a href="https://example.test/endnotes">the website</a>.</p>' if gateway else
'<aside epub:type="footnote" id="n1"><p id="note-text">A note. <a href="ch.xhtml#p0">Back</a></p></aside>')
for item in [ch, notes, epub.EpubNav(), epub.EpubNcx()]: book.add_item(item)
book.toc = [epub.Link('Text/ch.xhtml#title', 'Chapter 1', 'chapter')]; book.spine = ['nav', ch, notes]
source = tmp_path/'source.epub'; epub.write_epub(str(source), book)
return source
def translated_book(tmp_path):
source = source_book(tmp_path); work = tmp_path/'work'
structure = BookStructure.load(EpubCleaner(source, work).clean())
entries = FineGrainedExtractor(complete_book=True).extract(structure)
prepare_navigation(source, structure, entries); structure.save(work/'book_structure.json')
for e in entries: e.translated_text = e.original_text.replace('Text', '正文').replace('Chapter 1', '第一章').replace('A note.', '一条注释。')
assert asyncio.run(RestorationEngine().restore_entries(entries)) == len(entries)
manifest = work/'manifest.json'; manifest.write_text(json.dumps([e.model_dump() for e in entries]))
asyncio.run(BackfillEngine().backfill(structure, entries, 'target_only'))
output = tmp_path/'translated.epub'; BilingualBuilder(work, source).build(structure, output)
return source, output, manifest, work/'book_structure.json'
def test_external_gateway_is_visible_but_not_a_new_translation_fault(tmp_path):
source = source_book(tmp_path, gateway=True)
inventory = book_inventory(source); notes = notes_report(inventory)
assert notes['counts'] == {'external_notes_gateway': 4}
assert notes['findings'][0]['count'] == 4
assert 'example.test/endnotes' in notes['references'][0]['external_urls'][0]
report, _, _ = audit_pair(source, source)
assert not report['new_issues']
assert report['notes']['translated']['findings']
assert report['visual_review'] == 'pending'
def test_embedded_note_destination_is_distinguished(tmp_path):
source = source_book(tmp_path)
assert notes_report(book_inventory(source))['counts'] == {'embedded_note': 4}
def test_full_coverage_and_backfill_resolve_opf_relative_manifest_paths(tmp_path):
source, output, manifest, structure = translated_book(tmp_path)
report, _, _ = audit_pair(source, output, manifest, structure)
assert not report['new_issues']
assert report['coverage']['scope'] == 'complete-book extraction'
assert report['coverage']['total'] == report['coverage']['accepted']
# A valid manifest must not prove that an untouched original was backfilled.
bad, _, _ = audit_pair(source, source, manifest, structure)
assert any(e['kind'] == 'translation_not_backfilled' for e in bad['new_issues'])
def test_removed_manifest_unit_cannot_claim_complete_book(tmp_path):
source, output, manifest, structure = translated_book(tmp_path)
data = json.loads(manifest.read_text()); removed = data.pop()['entry_id']; manifest.write_text(json.dumps(data))
report, _, _ = audit_pair(source, output, manifest, structure)
assert removed in report['coverage']['missing_ids']
assert any(e['kind'] == 'incomplete_or_mismatched_manifest' for e in report['new_issues'])
def test_changed_note_destination_is_reported(tmp_path):
source = source_book(tmp_path); output = tmp_path/'bad.epub'
with zipfile.ZipFile(source) as before, zipfile.ZipFile(output, 'w') as after:
for item in before.infolist():
data = before.read(item.filename)
if item.filename.endswith('Text/ch.xhtml'):
data = data.replace(b'notes.xhtml#n1', b'ch.xhtml#title')
after.writestr(item, data)
report, _, _ = audit_pair(source, output)
assert any(e['kind'] == 'new_note_destination_problem' for e in report['new_issues'])
def test_stale_output_cannot_claim_new_manifest_translation(tmp_path):
source, output, manifest, structure = translated_book(tmp_path)
data = json.loads(manifest.read_text())
entry = next(e for e in data if '正文' in e['translated_text'])
entry['translated_text'] = entry['translated_text'].replace('正文', '修正后的正文')
manifest.write_text(json.dumps(data))
report, _, _ = audit_pair(source, output, manifest, structure)
assert any(e['kind'] == 'translation_content_mismatch' and e['entry_id'] == entry['entry_id'] for e in report['new_issues'])
def test_ncx_label_drift_is_detected_even_with_correct_nav(tmp_path):
source, output, manifest, structure = translated_book(tmp_path)
broken = tmp_path/'label-drift.epub'
with zipfile.ZipFile(output) as before, zipfile.ZipFile(broken, 'w') as after:
for item in before.infolist():
data = before.read(item.filename)
if item.filename.endswith('.ncx'):
data = data.replace('第一章'.encode(), '错误标题'.encode())
after.writestr(item, data)
report, _, _ = audit_pair(source, broken, manifest, structure)
assert any(e['kind'] == 'navigation_title_mismatch' for e in report['new_issues'])
def test_wrapped_nav_links_are_not_treated_as_valid_reader_navigation(tmp_path):
from lxml import etree
source = source_book(tmp_path); broken = tmp_path/'wrapped.epub'
with zipfile.ZipFile(source) as before, zipfile.ZipFile(broken, 'w') as after:
for item in before.infolist():
data = before.read(item.filename)
if item.filename.endswith('/nav.xhtml'):
doc = etree.fromstring(data)
a = doc.xpath('//*[local-name()="nav"]//*[local-name()="a"]')[0]
parent = a.getparent(); index = parent.index(a)
parent.remove(a); wrapper = etree.Element('{http://www.w3.org/1999/xhtml}span'); wrapper.append(a); parent.insert(index, wrapper)
data = etree.tostring(doc)
after.writestr(item, data)
report, _, _ = audit_pair(source, broken)
assert any(e['kind'] == 'nav_link_not_direct_li' for e in report['new_issues'])
def test_chinese_is_decoded_in_real_headless_browser(tmp_path):
import os
import pytest
if not os.environ.get('PLAYWRIGHT_BROWSERS_PATH'):
pytest.skip('Optional browser integration; set PLAYWRIGHT_BROWSERS_PATH')
playwright = pytest.importorskip('playwright.sync_api')
from src.qa.headless import archive_server
source, output, _, _ = translated_book(tmp_path)
name = next(n for n in book_inventory(output)['documents'] if n.endswith('/Text/ch.xhtml'))
with archive_server({'translated': output}) as base, playwright.sync_playwright() as p:
browser = p.chromium.launch(headless=True)
try:
page = browser.new_page(java_script_enabled=False)
page.goto(base+'/translated/'+name)
assert '正文' in page.locator('body').inner_text()
assert '第一章' in page.locator('h1').inner_text()
finally:
browser.close()
def agent_args(source, work, action='prepare', **overrides):
from types import SimpleNamespace
values = dict(action=action, source=source, work=work, model='agent/luna', batch_chars=1000,
result=None, glossary=None, output_dir=None)
values.update(overrides)
return SimpleNamespace(**values)
def test_agent_harness_rejects_stale_results_without_mutating_cache(tmp_path):
from scripts.agent_book import run
import pytest
source = source_book(tmp_path); work = tmp_path/'agent'
asyncio.run(run(agent_args(source, work)))
manifest = work/'manifest.json'; previous = manifest.read_bytes()
entry = json.loads(previous)[0]; result = work/'results.json'
result.write_text(json.dumps([{'entry_id': entry['entry_id'], 'source_sha256': 'wrong', 'translated_text': '中文。'}]))
with pytest.raises(ValueError, match='stale'):
asyncio.run(run(agent_args(source, work, 'apply', result=result)))
assert manifest.read_bytes() == previous
with pytest.raises(ValueError, match='Incomplete'):
asyncio.run(run(agent_args(source, work, 'build')))
assert not (work/'output').exists()
def test_agent_invalid_repair_is_visible_and_preserves_valid_translation(tmp_path):
from scripts.agent_book import run, unit_digest
from src.common.data_model import ManifestEntry
source = source_book(tmp_path); work = tmp_path/'agent'
asyncio.run(run(agent_args(source, work)))
manifest = work/'manifest.json'; data = json.loads(manifest.read_text())
entry = next(e for e in data if e['placeholders'].get('1'))
entry['translated_text'] = entry['original_text'].replace('Text', '正文')
previous = entry['translated_text']; manifest.write_text(json.dumps(data))
result = work/'results.json'; result.write_text(json.dumps([{'entry_id': entry['entry_id'],
'source_sha256': unit_digest(ManifestEntry.model_validate(entry)), 'translated_text': '丢失注号的译文。'}]))
assert asyncio.run(run(agent_args(source, work, 'apply', result=result))) == 1
saved = next(e for e in json.loads(manifest.read_text()) if e['entry_id'] == entry['entry_id'])
assert saved['translated_text'] == previous
assert json.loads((work/'apply-report.json').read_text())['rejected']
def test_agent_model_change_requires_isolated_work_directory(tmp_path):
import pytest
from scripts.agent_book import run
source = source_book(tmp_path); work = tmp_path/'agent'
asyncio.run(run(agent_args(source, work)))
previous = (work/'manifest.json').read_bytes()
with pytest.raises(ValueError, match='Model changed'):
asyncio.run(run(agent_args(source, work, model='agent/other')))
assert (work/'manifest.json').read_bytes() == previous