#!/usr/bin/env python3 """ Regression test for the "lone w:ins" bug: a CR paragraph containing a standalone with no adjacent (a pure insertion, nothing marked deleted) used to be silently dropped by cr_parser.py, so a CR with real content parsed to "0 changes" and the TS was never updated. Fixtures are real CRs that exhibit this exact shape: SETTEC(26)000050r1.docx — 1 lone (", 3.8") SETTEC(26)000048r1.docx — 2 lone occurrences (same text, two clauses) Run: python3 -m unittest scripts/tests/test_lone_insertion.py -v """ import sys import tempfile import unittest from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent.parent sys.path.insert(0, str(SCRIPT_DIR)) import docx from docx.oxml.ns import qn from cr_parser import parse_cr, _extract_inline_replacements from ts_applicator import apply_manifest from verify_applied import scan_revision_marks, verify_manifest_applied FIXTURES = Path(__file__).parent / 'fixtures' CR_050 = FIXTURES / 'SETTEC(26)000050r1.docx' CR_048 = FIXTURES / 'SETTEC(26)000048r1.docx' class LoneInsertionParseTests(unittest.TestCase): def test_cr050_yields_one_text_insert_change(self): changes = parse_cr(CR_050) self.assertEqual(len(changes), 1) change = changes[0] self.assertEqual(change['type'], 'text_insert') self.assertEqual(change['text'], ', 3.8') self.assertTrue(change['before']) self.assertTrue(change['after']) # The anchors must not themselves contain the inserted text — otherwise # the splice point would be ambiguous. self.assertNotIn(', 3.8', change['before'] + change['after']) def test_cr048_yields_two_text_insert_changes(self): changes = parse_cr(CR_048) self.assertEqual(len(changes), 2) self.assertTrue(all(c['type'] == 'text_insert' for c in changes)) def test_scan_revision_marks_reports_one_handled_no_unhandled(self): scan = scan_revision_marks(CR_050) self.assertEqual(scan['handled'], 1) self.assertEqual(scan['unhandled'], {}) def test_old_extractor_saw_zero_changes_here(self): """Pins the exact mechanism of the original bug: a lone w:ins is invisible to _extract_inline_replacements (it only pairs w:del with an adjacent w:ins), which is why the parser used to report 0 changes for this CR. If this assertion ever starts failing, _extract_lone_ insertions may have become redundant — but it should not be removed without re-verifying this class of CR is still handled.""" doc = docx.Document(str(CR_050)) found_target_para = False for elem in doc.element.body: if elem.tag != qn('w:p'): continue if any(c.tag == qn('w:ins') for c in elem): found_target_para = True self.assertEqual(_extract_inline_replacements(elem), []) self.assertTrue(found_target_para, 'fixture no longer contains the expected w:ins paragraph') class LoneInsertionApplyTests(unittest.TestCase): def _make_ts_doc(self, paragraph_text): doc = docx.Document() doc.add_paragraph(paragraph_text) tmp = tempfile.NamedTemporaryFile(suffix='.docx', delete=False) doc.save(tmp.name) return Path(tmp.name) def test_end_to_end_apply_produces_ins_with_no_del(self): changes = parse_cr(CR_050) change = changes[0] ts_text = change['before'] + change['after'] ts_path = self._make_ts_doc(ts_text) out_path = ts_path.with_name('out.docx') try: n_ok, n_skip, log_lines, n_parsed, n_merged = apply_manifest( ts_path, changes, out_path) self.assertEqual(n_ok, 1) self.assertEqual(n_skip, 0) # python-docx's Paragraph.text only reads direct-child runs — # it does not descend into /, so the inserted text # must be located via the raw element tree, not p.text. out_doc = docx.Document(str(out_path)) target = next( p for p in out_doc.paragraphs if change['text'] in ''.join( t.text or '' for t in p._element.findall('.//' + qn('w:t'))) ) p_el = target._element ins_elems = p_el.findall(qn('w:ins')) del_elems = p_el.findall(qn('w:del')) self.assertEqual(len(ins_elems), 1) self.assertEqual(len(del_elems), 0) inserted_text = ''.join(t.text or '' for t in ins_elems[0].findall('.//' + qn('w:t'))) self.assertEqual(inserted_text, change['text']) verify_errors = verify_manifest_applied(out_doc, changes) self.assertEqual(verify_errors, []) finally: ts_path.unlink(missing_ok=True) out_path.unlink(missing_ok=True) def test_nbsp_variant_between_cr_and_ts_still_applies(self): """CR 48r1's second change anchors on '... and Annex A do NOT ...' (plain space), but the real TS 102267 stores 'Annex\xa0A' with a non- breaking space — discovered live when re-running this exact CR against the real TS. The splice point must still be found via normalized matching, and the TS's real NBSP must be preserved (not silently overwritten with a plain space).""" changes = parse_cr(CR_048) change = next(c for c in changes if 'SCP82' in c['before']) ts_text = change['before'] + change['after'].replace('Annex A', 'Annex\xa0A') self.assertIn('\xa0A', ts_text) ts_path = self._make_ts_doc(ts_text) out_path = ts_path.with_name('out_nbsp.docx') try: n_ok, n_skip, log_lines, n_parsed, n_merged = apply_manifest( ts_path, [change], out_path) self.assertEqual(n_ok, 1) self.assertEqual(n_skip, 0) out_doc = docx.Document(str(out_path)) full = ''.join( t.text or '' for t in out_doc.paragraphs[0]._element.findall('.//' + qn('w:t'))) self.assertIn(', 3.8', full) self.assertIn('Annex\xa0A', full) # original NBSP preserved, not overwritten finally: ts_path.unlink(missing_ok=True) out_path.unlink(missing_ok=True) if __name__ == '__main__': unittest.main()