Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| """ | |
| Regression test for the "lone w:ins" bug: a CR paragraph containing a | |
| standalone <w:ins> with no adjacent <w:del> (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 <w:ins> (", 3.8") | |
| SETTEC(26)000048r1.docx — 2 lone <w:ins> 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 <w:r> runs — | |
| # it does not descend into <w:ins>/<w:del>, 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() | |