import json import pytest from src.output import alignment as a def review(text,targets,decision='mapped',**extra): return dict(text_sha256=a.digest(text),mapped_targets=targets,decision=decision,basis='fixture',reviewer='fixture',**extra) def test_lossless_routes(): assert a.route_record('b:1:1','abc',None)==[('b:1:1','abc')] assert a.route_record('b:1:1','abc',review('abc',[],'same'))==[('b:1:1','abc')] assert a.route_record('b:1:1','abc',review('abc',['b:1:2']))==[('b:1:2','abc')] assert a.route_record('b:1:1','abc',review('abc',['b:1:2','b:1:3']))==[] assert a.route_record('b:1:1','abc',review('abc',[],'mixed'))==[] for record,message in [(review('abd',[]),'text changed'),(review('abc',[],'guess'),'Unknown review')]: with pytest.raises(ValueError,match=message):a.route_record('b:1:1','abc',record) routes=[dict(target='b:1:1',start=0,end=1),dict(target='b:1:2',start=1,end=3)] assert a.route_record('b:1:1','abc',review('abc',[],routes=routes))==[('b:1:1','a'),('b:1:2','bc')] with pytest.raises(ValueError,match='Unassigned'):a.route_record('b:1:1','abc',review('abc',[],routes=routes[:1])) with pytest.raises(ValueError,match='Character loss'):a.route_record('b:1:1','abc',review('abc',[],routes=routes[1:])) def test_conflicts_missing_and_held_extras(): records={'b:1:1':'first','b:1:2':'other','b:1:3':'unknown'} r={'records':{'b:1:2':review('other',['b:1:1']),'b:1:3':review('unknown',[],'unclear')}} result=a.align(records,{'b:1:1','b:1:2'},r) assert not result['numbering_ready'] and result['missing_passages']==['b:1:2'] assert len(result['conflicts']['b:1:1'])==2 assert result['extra_references']['b:1:3']['status']=='held' r['records']['b:1:3']=review('unknown',['b:1:1']) assert a.align(records,{'b:1:1','b:1:2'},r)['extra_references']['b:1:3']['status']=='conflict' def test_selections_joins_headings_and_identical_text(): records={'b:1:1':'title','b:1:2':'same','b:1:3':'same','b:1:4':'end','b:1:5':'wrong'} r={'records':{k:review(v,[target]) for (k,v),target in zip(records.items(),['b:1:0','b:1:1','b:1:1','b:1:2','b:1:2'])},'resolutions':{'b:1:2':dict(expected_candidates=['b:1:4','b:1:5'],sources=['b:1:4'],reason='Accurate closing')}} result=a.align(records,{'b:1:1','b:1:2'},r) assert result['numbering_ready'] assert result['aligned_changes']['b:1:0']['text']=='title' assert result['aligned_changes']['b:1:1']['text']=='same' assert result['aligned_changes']['b:1:2']['text']=='end' assert len(result['resolutions']['b:1:2']['all_candidates'])==2 assert result['extra_references']['b:1:5']['destinations']['b:1:2']['selected']['sources']==['b:1:4'] r['resolutions']['b:1:2']['sources']=['b:1:4','b:1:5'] assert a.align(records,{'b:1:1','b:1:2'},r)['aligned_changes']['b:1:2']['text']=='endwrong' def test_invalid_review_rejected(): with pytest.raises(ValueError,match='unknown source'):a.align({},set(),{'records':{'b:1:1':{}}}) with pytest.raises(ValueError,match='outside site'):a.align({'b:1:1':'a'},set(),{'records':{}}) with pytest.raises(ValueError,match='no candidates'):a.align({},set(),{'records':{},'resolutions':{'b:1:1':{}}}) r={'records':{},'resolutions':{'b:1:1':dict(expected_candidates=['b:1:2'],sources=['b:1:2'],reason='fixture')}} with pytest.raises(ValueError,match='candidates changed'):a.align({'b:1:1':'a'},{'b:1:1'},r) r['resolutions']['b:1:1']['expected_candidates']=['b:1:1'] with pytest.raises(ValueError,match='Invalid selection'):a.align({'b:1:1':'a'},{'b:1:1'},r) def test_snapshot_and_input_protection(tmp_path,monkeypatch): source=tmp_path/'source';chapter=source/'b/chapter_01.json';chapter.parent.mkdir(parents=True) chapter.write_text(json.dumps({'chapter':1,'verses':{'1':{'text':'a'}}})) records,files=a.read_output(source);assert records=={'b:1:1':'a'} rp=tmp_path/'review.json';out=tmp_path/'result.json' monkeypatch.setattr(a,'load_site',lambda:({('b',1,1):'reference'},{'b':'b'})) r=dict(records={},input_files=files,site_sha256=a.digest(json.dumps({'b:1:1':'reference'},ensure_ascii=False,sort_keys=True)),usage={}) rp.write_text(json.dumps(r));assert a.build_audit(source,rp,out)['numbering_ready'] assert json.loads(out.read_text())['summary']['resolved_site_references']==1 for dest in [source/'audit.json',rp]: with pytest.raises(ValueError,match='overwrite'):a.build_audit(source,rp,dest) r['site_sha256']='changed';rp.write_text(json.dumps(r)) with pytest.raises(ValueError,match='reference text changed'):a.build_audit(source,rp,out) chapter.write_text(json.dumps({'chapter':1,'verses':{'1':{'text':'b'}}})) with pytest.raises(ValueError,match='snapshot changed'):a.build_audit(source,rp,out) chapter.write_text(json.dumps({'chapter':1,'verses':{'1':{'text':''}}})) with pytest.raises(ValueError,match='empty translation'):a.read_output(source) chapter.unlink() with pytest.raises(ValueError,match='No translations'):a.read_output(source) def repair(text, candidates): return dict(text=text, expected_candidates=a.candidate_hashes(candidates), reason='Reviewed against mapped source', evidence_sha256='a'*64, verified_by='independent review') def test_reviewed_repairs_fill_gaps_and_replace_incomplete_candidates(): records={'b:1:1':'partial'} r={'records':{},'target_repairs':{'b:1:1':repair('complete',[{'source':'b:1:1','text':'partial'}]),'b:1:2':repair('restored',[])}} result=a.align(records,{'b:1:1','b:1:2'},r) assert result['numbering_ready'] and result['summary']['resolved_site_references']==2 assert result['aligned_changes']['b:1:2']['restored'] assert result['aligned_changes']['b:1:1']['text']=='complete' assert result['resolutions']['b:1:1']['all_candidates']==[{'source':'b:1:1','text':'partial'}] r['target_repairs']['b:1:3']=repair('outside',[]) with pytest.raises(ValueError,match='Repair outside'):a.align(records,{'b:1:1','b:1:2'},r) def test_repair_requires_unchanged_candidates_clean_text_and_review_evidence(): item=repair('fixed',[]) with pytest.raises(ValueError,match='candidates changed'):a.validate_repair('b:1:1',item,[{'source':'b:1:1','text':'different'}]) for text in ['', ' ', 'bad\ntext', '', '```', None]: with pytest.raises(ValueError,match='Invalid repaired text'):a.validate_repair('b:1:1',{**item,'text':text},[]) for field in ['reason','evidence_sha256','verified_by']: with pytest.raises(ValueError,match='lacks review evidence'):a.validate_repair('b:1:1',{**item,field:''},[]) def export_fixture(tmp_path,monkeypatch): source=tmp_path/'data/output';chapter=source/'b/chapter_01.json';chapter.parent.mkdir(parents=True) chapter.write_text(json.dumps({'chapter':1,'verses':{'1':{'text':'original'},'2':{'text':'unchanged'}}})) monkeypatch.setattr(a,'load_site',lambda:({('b',1,1):'first',('b',1,2):'second'},{'b':'bb'})) _,files=a.read_output(source) rp=tmp_path/'review.json';audit=tmp_path/'audit.json' r=dict(records={},input_files=files,site_sha256=a.digest(json.dumps({'b:1:1':'first','b:1:2':'second'},ensure_ascii=False,sort_keys=True)),usage={}) rp.write_text(json.dumps(r));a.build_audit(source,rp,audit) return source,rp,audit,r def test_repair_source_files_are_bound_to_audit(tmp_path,monkeypatch): source,rp,audit,r=export_fixture(tmp_path,monkeypatch) text=source.parent/'sources/hebrew/b.json';text.parent.mkdir(parents=True);text.write_text('source') r['repair_source_files']={'sources/hebrew/b.json':a.digest('source')};rp.write_text(json.dumps(r)) assert a.build_audit(source,rp,audit)['numbering_ready'] text.write_text('changed') with pytest.raises(ValueError,match='Repair source text changed'):a.build_audit(source,rp,audit) r['repair_source_files']={'../other':'hash'};rp.write_text(json.dumps(r)) with pytest.raises(ValueError,match='path outside'):a.build_audit(source,rp,audit) def test_export_complete_release_and_heading(tmp_path,monkeypatch): source,rp,audit,r=export_fixture(tmp_path,monkeypatch) result=json.loads(audit.read_text());result['aligned_changes']={'b:1:1':{'text':'fixed'},'b:1:0':{'text':'heading'}};audit.write_text(json.dumps(result)) out=tmp_path/'release';manifest=a.export_aligned(source,audit,out) assert manifest['verses']==2 and manifest['chapters']==1 and manifest['books']==1 book=json.loads((out/'bb.json').read_text()) assert book['translation']=='日常訳第1版' assert book['chapters']['1']=={'heading':'heading','verses':{'1':'fixed','2':'unchanged'}} assert manifest['files']['bb.json']==a.digest((out/'bb.json').read_text()) for dest in [source/'release',source.parent]: with pytest.raises(ValueError,match='separate'):a.export_aligned(source,audit,dest) with pytest.raises(ValueError,match='empty'):a.export_aligned(source,audit,out) result['numbering_ready']=False;audit.write_text(json.dumps(result)) with pytest.raises(ValueError,match='incomplete'):a.export_aligned(source,audit,tmp_path/'bad') result['numbering_ready']=True;result['site_sha256']='changed';audit.write_text(json.dumps(result)) with pytest.raises(ValueError,match='reference text changed'):a.export_aligned(source,audit,tmp_path/'bad') result['site_sha256']=r['site_sha256'];result['aligned_changes']['b:1:1']['text']='';audit.write_text(json.dumps(result)) with pytest.raises(ValueError,match='Missing release verse'):a.export_aligned(source,audit,tmp_path/'bad')