202 lines
11 KiB
Python
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
|