diff --git a/confluence-mdx/bin/reverse_sync/xhtml_patcher.py b/confluence-mdx/bin/reverse_sync/xhtml_patcher.py index 58d3f3587..3a77fc201 100644 --- a/confluence-mdx/bin/reverse_sync/xhtml_patcher.py +++ b/confluence-mdx/bin/reverse_sync/xhtml_patcher.py @@ -299,6 +299,40 @@ def _apply_text_changes(element: Tag, old_text: str, new_text: str): last_nonempty_idx = j break + # 블록 경계 감지: 서로 다른 블록 부모(p, li 등)에 속하는 인접 텍스트 노드 사이에서 + # insert가 잘못된 노드에 할당되는 것을 방지한다. + # block_boundary_pairs: (left_idx, right_idx) 쌍 - 블록 경계를 이루는 노드 인덱스 + block_boundary_pairs = [] + prev_nonempty_idx = -1 + for j, (ns, ne, nd) in enumerate(node_ranges): + if ns == ne: + continue + if prev_nonempty_idx >= 0: + prev_node = node_ranges[prev_nonempty_idx][2] + if _find_block_ancestor(prev_node) is not _find_block_ancestor(nd): + block_boundary_pairs.append((prev_nonempty_idx, j)) + prev_nonempty_idx = j + + # 블록 경계에서 insert가 right 노드에 기본 할당되는 것을 수정한다. + # right 노드 내부에 자체 변경(replace/delete/strictly-inside-insert)이 있으면 + # boundary insert는 right의 동반 변경이므로 기본 동작을 유지한다. + # right 노드에 변경이 없으면 insert는 left 노드에 귀속시킨다. + # 예: "이모지 깨지는 이슈" + insert(" 해결") + unchanged → left에 할당 + # 예: unchanged + insert("[") + "Authentication]..." → right에 할당 + claim_end_set = set() + exclude_start_set = set() + for left_idx, right_idx in block_boundary_pairs: + right_start, right_end, _ = node_ranges[right_idx] + has_changes_in_right = any( + (tag in ('replace', 'delete') + and max(i1, right_start) < min(i2, right_end)) + or (tag == 'insert' and right_start < i1 < right_end) + for tag, i1, i2, j1, j2 in opcodes + ) + if not has_changes_in_right: + claim_end_set.add(left_idx) + exclude_start_set.add(right_idx) + for i, (node_start, node_end, node) in enumerate(node_ranges): if node_start == node_end: continue @@ -306,8 +340,14 @@ def _apply_text_changes(element: Tag, old_text: str, new_text: str): # _map_text_range는 half-open [start, end)를 사용하므로, # 마지막 non-empty 노드에서는 end를 확장하여 trailing insert를 포함한다. effective_end = node_end + 1 if i == last_nonempty_idx else node_end + # 블록 경계에서는 include_insert_at_end/exclude_insert_at_start로 + # insert를 올바른 노드에 할당한다. + include_at_end = i in claim_end_set and i != last_nonempty_idx + exclude_at_start = i in exclude_start_set new_node_text = _map_text_range( - old_stripped, new_stripped, opcodes, node_start, effective_end + old_stripped, new_stripped, opcodes, node_start, effective_end, + include_insert_at_end=include_at_end, + exclude_insert_at_start=exclude_at_start, ) node_str = str(node) @@ -330,8 +370,31 @@ def _apply_text_changes(element: Tag, old_text: str, new_text: str): node.replace_with(NavigableString(leading + new_node_text + trailing)) -def _map_text_range(old_text: str, new_text: str, opcodes, start: int, end: int) -> str: - """old_text[start:end] 범위에 대응하는 new_text 부분을 추출한다.""" +def _find_block_ancestor(node): + """텍스트 노드의 가장 가까운 블록 레벨 부모 요소를 찾는다.""" + _BLOCK_TAGS = { + 'p', 'li', 'td', 'th', 'div', 'blockquote', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + } + parent = node.parent + while parent: + if isinstance(parent, Tag) and parent.name in _BLOCK_TAGS: + return parent + parent = parent.parent + return None + + +def _map_text_range(old_text: str, new_text: str, opcodes, start: int, end: int, + include_insert_at_end: bool = False, + exclude_insert_at_start: bool = False) -> str: + """old_text[start:end] 범위에 대응하는 new_text 부분을 추출한다. + + Args: + include_insert_at_end: True이면 i1 == end 위치의 insert도 포함한다. + 블록 경계에서 trailing insert를 현재 노드에 할당할 때 사용. + exclude_insert_at_start: True이면 i1 == start 위치의 insert를 제외한다. + 이전 노드가 해당 insert를 이미 claim한 경우 중복 방지용. + """ result_parts = [] for tag, i1, i2, j1, j2 in opcodes: # 이 opcode가 [start, end) 범위와 겹치는지 확인 @@ -360,8 +423,12 @@ def _map_text_range(old_text: str, new_text: str, opcodes, start: int, end: int) # insert는 old 텍스트에서 위치 i1 == i2 # 이 insert가 현재 노드 범위 [start, end) 안에 위치하면 포함 # half-open range를 사용하여 인접 노드 경계에서 중복 삽입 방지 + if exclude_insert_at_start and i1 == start: + continue if start <= i1 < end: result_parts.append(new_text[j1:j2]) + elif include_insert_at_end and i1 == end: + result_parts.append(new_text[j1:j2]) elif tag == 'delete': # 삭제된 부분은 new에 아무것도 추가하지 않음 pass diff --git a/confluence-mdx/tests/test_reverse_sync_patch_builder.py b/confluence-mdx/tests/test_reverse_sync_patch_builder.py index b5cd9850b..a93ad99c3 100644 --- a/confluence-mdx/tests/test_reverse_sync_patch_builder.py +++ b/confluence-mdx/tests/test_reverse_sync_patch_builder.py @@ -804,6 +804,60 @@ def test_child_miss_falls_back_to_containing(self): assert len(patches) == 1 assert patches[0]['xhtml_xpath'] == 'ul[1]' + def test_flat_list_append_text_at_end_of_item(self): + """flat list에서 항목 끝에 텍스트를 추가할 때 다음 항목으로 넘치지 않아야 한다. + + 실제 사례: 9.10.0-9.10.4.mdx + Original: "* [MongoDB] 데이터 조회 시 이모지 깨지는 이슈" + Improved: "* [MongoDB] 데이터 조회 시 이모지가 깨지는 이슈 해결" + 현상: "해결"이 다음 항목 앞에 붙어 "해결[Privilege Type]..."이 됨 + + 실제 사례: 11.1.0-11.1.2.mdx + Original: "* [General] 워크플로우 승인완료되었거나 ... External API" + Improved: "* [General] 워크플로우 승인 완료 상태이거나 ... External API 추가" + 현상: "추가"가 다음 항목 앞에 붙어 "추가[DAC]..."이 됨 + """ + parent = _make_mapping( + 'list-55', + '[MongoDB] Aggregate 커멘드 정책 적용 지원 (Web Editor) ' + '[MongoDB] 데이터 조회 시 이모지 깨지는 이슈 ' + '[Privilege Type] Default Privilege Type 이름 변경 (-role 제거)', + xpath='ul[5]', + type_='list', + children=[], + ) + mappings = [parent] + xpath_to_mapping = {m.xhtml_xpath: m for m in mappings} + id_to_mapping = {m.block_id: m for m in mappings} + + change = _make_change( + 0, + '* [MongoDB] Aggregate 커멘드 정책 적용 지원 (Web Editor)\n' + '* [MongoDB] 데이터 조회 시 이모지 깨지는 이슈\n' + '* [Privilege Type] Default Privilege Type 이름 변경 (-role 제거)\n', + '* [MongoDB] Aggregate 커맨드 정책 적용 지원(Web Editor)\n' + '* [MongoDB] 데이터 조회 시 이모지가 깨지는 이슈 해결\n' + '* [Privilege Type] Default Privilege Type 이름 변경 (-role 제거)\n', + type_='list', + ) + mdx_to_sidecar = {0: _make_sidecar('ul[5]', [0])} + + patches = build_list_item_patches( + change, mappings, set(), + mdx_to_sidecar, xpath_to_mapping, id_to_mapping) + + assert len(patches) == 1 + p = patches[0] + assert 'new_plain_text' in p + # "해결"이 올바른 위치(이슈 뒤)에 있어야 함 + assert '이슈 해결' in p['new_plain_text'], ( + f"'이슈 해결'이 연속되어야 함: {p['new_plain_text']!r}" + ) + # "해결"이 다음 항목 앞에 붙으면 안 됨 + assert '해결[Privilege' not in p['new_plain_text'], ( + f"'해결'이 다음 항목 앞에 잘못 삽입됨: {p['new_plain_text']!r}" + ) + def test_flat_list_bracket_insert_at_item_start(self): """flat list에서 리스트 아이템 맨 앞에 '[' 삽입 시 XHTML의 해당 아이템 위치에 정확히 삽입되어야 한다. diff --git a/confluence-mdx/tests/test_reverse_sync_xhtml_patcher.py b/confluence-mdx/tests/test_reverse_sync_xhtml_patcher.py index 47e3eb8fa..e825524b0 100644 --- a/confluence-mdx/tests/test_reverse_sync_xhtml_patcher.py +++ b/confluence-mdx/tests/test_reverse_sync_xhtml_patcher.py @@ -301,3 +301,112 @@ def test_insert_space_at_inline_element_boundary_not_duplicated(): assert ' 문서를 참조해' in result, ( f' 뒤에 공백이 없거나 다른 위치에 삽입됨: {result}' ) + + +def test_flat_list_append_text_stays_in_same_item(): + """flat list에서 항목 끝에 텍스트를 추가할 때 다음 항목으로 넘치지 않아야 한다. + + 재현 시나리오: + XHTML: + 교정: '이모지 깨지는 이슈' → '이모지가 깨지는 이슈 해결' + + 기대: '해결'이 첫 번째
  • 안에 남음. + 버그: _apply_text_changes가 insert를 노드 경계에서 다음
  • 의 text node에 할당하여 + '해결이름 변경'이 됨. + """ + xhtml = ( + '' + ) + patches = [ + { + 'xhtml_xpath': 'ul[1]', + 'old_plain_text': ( + '[MongoDB] 데이터 조회 시 이모지 깨지는 이슈' + '[Privilege Type] Default Privilege Type 이름 변경' + ), + 'new_plain_text': ( + '[MongoDB] 데이터 조회 시 이모지가 깨지는 이슈 해결' + '[Privilege Type] Default Privilege Type 이름 변경' + ), + } + ] + result = patch_xhtml(xhtml, patches) + + # '해결'이 첫 번째 항목에 남아야 함 + assert '이슈 해결

  • ' in result, ( + f"'해결'이 첫 번째
  • 안에 없음: {result}" + ) + # '해결'이 두 번째 항목 앞에 붙으면 안 됨 + assert '해결[Privilege' not in result, ( + f"'해결'이 다음 항목으로 넘침: {result}" + ) + + +def test_flat_list_append_text_multiple_items(): + """여러 항목에서 동시에 텍스트를 추가해도 올바른 항목에 남아야 한다.""" + xhtml = ( + '' + ) + patches = [ + { + 'xhtml_xpath': 'ul[1]', + 'old_plain_text': 'item A textitem B textitem C text', + 'new_plain_text': 'item A text appendeditem B text modifieditem C text', + } + ] + result = patch_xhtml(xhtml, patches) + + assert '
  • item A text appended

  • ' in result, ( + f"첫 번째 항목에 ' appended' 누락: {result}" + ) + assert '
  • item B text modified

  • ' in result, ( + f"두 번째 항목에 ' modified' 누락: {result}" + ) + + +def test_flat_list_prepend_bracket_stays_in_correct_item(): + """리스트 항목 앞에 '[' 삽입 시 해당 항목에 남아야 한다 (이전 항목으로 이동하면 안 됨). + + 재현 시나리오: + old: "DynamoDB 데이터 조회 관련 이슈 개선Authentication Type 변경 시" + new: "DynamoDB 데이터 조회 관련 이슈 개선[Authentication] Type 변경 시" + + 기대: '[' 가 두 번째
  • 에 삽입됨. + 회귀 버그: block boundary fix가 '[' 를 이전
  • 에 잘못 할당. + """ + xhtml = ( + '' + ) + patches = [ + { + 'xhtml_xpath': 'ul[1]', + 'old_plain_text': ( + 'DynamoDB 데이터 조회 관련 이슈 개선' + 'Authentication Type 변경 시 오류 메시지 개선' + ), + 'new_plain_text': ( + 'DynamoDB 데이터 조회 관련 이슈 개선' + '[Authentication] Type 변경 시 오류 메시지 개선' + ), + } + ] + result = patch_xhtml(xhtml, patches) + + # DynamoDB 항목은 변경 없이 그대로 + assert '
  • DynamoDB 데이터 조회 관련 이슈 개선

  • ' in result, ( + f"DynamoDB 항목이 변경됨: {result}" + ) + # '[' 가 두 번째 항목에 삽입됨 + assert '
  • [Authentication] Type 변경 시 오류 메시지 개선

  • ' in result, ( + f"Authentication 항목에 bracket이 없음: {result}" + )