import asyncio import zipfile from bs4 import BeautifulSoup from ebooklib import epub from lxml import etree import pytest 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, destination, chinese_number from src.assembly.restoration_engine import RestorationEngine from src.assembly.backfiller import BackfillEngine from src.assembly.builder import BilingualBuilder from src.qa.epub_audit import audit_epub, compare_audits def fixture(tmp_path): book = epub.EpubBook(); book.set_identifier('titles'); book.set_title('Title QA'); book.set_language('en') chapter = epub.EpubHtml(uid='ch', file_name='Text/ch.xhtml', title='Roots') chapter.content = '''
Opening prose.
A caption.
Quoted text.
Second paragraph.1
''' part = epub.EpubHtml(uid='part', file_name='Text/part.xhtml', title='Part 1') part.content = '1
The Bookseller
The Ren Family: 1937–1968
', 'A long unrelated opening paragraph.
' nav=next(r for r in structure.resources.values() if r.href=='nav.xhtml') nav.content=nav.content.replace('Text/ch.xhtml#ch1','Text/ch.xhtml').replace('Chapter 1: Roots','Chapter 1: The Bookseller | The Ren Family: 1937–1968') entries=FineGrainedExtractor().extract(structure);prepare_navigation(source,structure,entries) record=next(r for r in structure.navigation_titles if r['target']=='Text/ch.xhtml') assert len(record['entry_ids'])==3 for e in entries: if e.element_id in {'number','title','subtitle'}: e.translated_text=e.original_text.replace('CHAPTER 1','第1章:').replace('The Bookseller','书商').replace('The Ren Family: 1937–1968','任家:1937—1968') asyncio.run(RestorationEngine().restore_entries(entries)) labels=navigation_labels(structure,entries) assert labels['Text/ch.xhtml']=='第一章:书商 任家:1937—1968' asyncio.run(BackfillEngine().backfill(structure,entries,'target_only',allow_partial=True)) ch=BeautifulSoup(chapter.content,'html.parser') assert all(ch.find(id=id) for id in ['number','title','subtitle','prose']) assert ch.find(id='prose').get_text()=='A long unrelated opening paragraph.' def test_heading_number_with_spaces_and_colon_normalizes(): from src.preprocessing.navigation import normalize_heading_html,number_info assert normalize_heading_html({'role':'chapter','number':1},'第 1 章:根源')=='第一章根源' assert number_info('Chapter Eleven: Rebels')==('chapter',11) assert number_info('Part Two: Coffee')==('part',2) def test_split_styled_digit_and_separate_separator(): from src.preprocessing.navigation import normalize_heading_html assert normalize_heading_html({'role':'part','number':1},'第1部:天才少年')=='第一部天才少年' assert normalize_heading_html({'role':'chapter','number':1},'第1章:根源')=='第一章根源' def test_numbered_titles_starting_with_two_or_i_do_not_lose_title_words(): from src.preprocessing.navigation import title_identity assert title_identity('Chapter 22: Two Indians Are Better than One')==title_identity('22 TWO INDIANS ARE BETTER THAN ONE') assert title_identity('Chapter 26: I Hope You Go Bust')==title_identity('26 I HOPE YOU GO BUST') def test_manifest_reconciliation_updates_title_context(tmp_path): from src.common.data_model import ManifestEntry from src.translation.manifest_manager import ManifestManager manager=ManifestManager(tmp_path/'manifest.json') old=ManifestEntry(entry_id='x#h',file_path='x',element_id='h',original_text='Roots',context='section',translated_text='根源') manager.add_entries([old]) new=old.model_copy(update={'context':'navigation-heading: Chapter 1: Roots','translated_text':None}) manager.add_entries([new]) assert manager.entries[0].context==new.context assert manager.entries[0].translated_text=='根源' def test_ncx_declared_utf16_is_parsed_without_utf8_assumptions(tmp_path): source,work,structure,entries=fixture(tmp_path) with zipfile.ZipFile(source) as z: items=[(i,z.read(i.filename)) for i in z.infolist()] converted=tmp_path/'utf16.epub' with zipfile.ZipFile(converted,'w') as z: for info,data in items: if info.filename.endswith('.ncx'): data=etree.tostring(etree.fromstring(data),encoding='utf-16',xml_declaration=True) z.writestr(info,data) prepare_navigation(converted,structure,entries) assert any(r['source_label']=='Chapter 1: Roots' for r in structure.navigation_titles) def test_navigation_generated_wrappers_are_repaired_but_publisher_styles_remain(): from src.common.html import parse_html, translation_targets soup=parse_html('') translation_targets(soup) link=soup.find('a',href='part.xhtml') assert link.parent.name=='li' assert link.em.get_text()=='Part 1' assert link.parent.find('ol',recursive=False) translation_targets(soup) assert link.parent.name=='li' def test_prepare_navigation_upgrades_cached_wrapped_part_link(tmp_path): source,work,structure,entries=fixture(tmp_path) nav=next(r for r in structure.resources.values() if r.href=='nav.xhtml') soup=BeautifulSoup(nav.content,'html.parser') link=soup.find('a',href='Text/part.xhtml#part') wrapper=soup.new_tag('span',attrs={'data-translation-unit':'true','id':'tr-old'}) link.wrap(wrapper);nav.content=str(soup) prepare_navigation(source,structure,entries) assert BeautifulSoup(nav.content,'html.parser').find('a',href='Text/part.xhtml#part').parent.name=='li' @pytest.mark.parametrize('mode',['target_only','bilingual']) def test_calibre_resolves_parent_part_destination(tmp_path,mode): import json import shutil import subprocess calibre=shutil.which('calibre-debug') if not calibre: pytest.skip('Calibre integration check requires calibre-debug') source,work,structure,entries=fixture(tmp_path) asyncio.run(BackfillEngine().backfill(structure,entries,mode)) output=tmp_path/'reader.epub';BilingualBuilder(work,source).build(structure,output) script=('import json,sys; from calibre.ebooks.oeb.polish.container import get_container; ' 'from calibre.ebooks.oeb.polish.toc import get_toc; ' 'print(json.dumps(get_toc(get_container(sys.argv[-1])).as_dict))') result=subprocess.run([calibre,'-c',script,'--',str(output)],capture_output=True,text=True,check=True,timeout=30) toc=json.loads(result.stdout) part=next(n for n in toc['children'] if n['children']) assert part['dest'].endswith('Text/part.xhtml') assert part['frag']=='part' assert part['dest_exists'] is True assert part['children'][0]['dest_exists'] is True