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 = '
Text {i}.{i}
' for i in range(4)) notes = epub.EpubHtml(uid='notes', file_name='Text/notes.xhtml', title='Notes') notes.content = ('Endnotes at the website.
' if gateway else '') 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