diff --git a/confluence-mdx/bin/reverse_sync/patch_builder.py b/confluence-mdx/bin/reverse_sync/patch_builder.py index f0d446a67..bbc3d4b8e 100644 --- a/confluence-mdx/bin/reverse_sync/patch_builder.py +++ b/confluence-mdx/bin/reverse_sync/patch_builder.py @@ -82,6 +82,11 @@ def _contains_preserved_anchor_markup(xhtml_text: str) -> bool: return " bool: + """링크 계열 preserved anchor가 포함된 경우만 가시 공백 raw transfer 대상이다.""" + return " bool: return has_space_change +def _normalize_list_for_content_compare(content: str) -> str: + """리스트 변경 비교용 plain text를 생성한다. + + 리스트 항목 내부의 continuation line 줄바꿈은 emitter에서 공백 하나로 합쳐지므로 + 내용 변경 판정에서는 무시한다. 대신 항목 경계와 항목 내부의 실제 공백 수 차이는 + 그대로 보존해 no-op reflow와 가시 공백 변경을 구분한다. + """ + lines = content.strip().split('\n') + item_chunks: List[str] = [] + current_chunk: List[str] = [] + + def _flush_current() -> None: + if not current_chunk: + return + plain = normalize_mdx_to_plain('\n'.join(current_chunk), 'list') + if plain: + item_chunks.append(plain.replace('\n', ' ')) + + for line in lines: + if not line.strip(): + continue + if re.match(r'^\s*(?:\d+\.(?:\s+|$)|[-*+]\s+)', line): + _flush_current() + current_chunk = [line] + continue + if current_chunk: + current_chunk.append(line) + else: + current_chunk = [line] + + _flush_current() + return '\n'.join(item_chunks) + + def _build_inline_fixups( old_content: str, new_content: str, @@ -1056,10 +1095,15 @@ def _mark_used(block_id: str, m: BlockMapping): ) # v3 fallback, sidecar 없음, 또는 실제 텍스트 변경이 있는 경우 whole-fragment 재생성 # (Phase 5 Axis 3: build_list_item_patches fallback 제거) - # 실제 텍스트 변경 여부: normalize+collapse_ws로 비교하여 링크 공백 등 형식 차이 무시 - _old_plain = collapse_ws(normalize_mdx_to_plain(change.old_block.content, 'list')) - _new_plain = collapse_ws(normalize_mdx_to_plain(change.new_block.content, 'list')) - has_content_change = _old_plain != _new_plain + # 내용 비교는 가시 공백 수 변화는 보존하되, continuation line reflow처럼 + # emitter 결과가 동일한 줄바꿈 정리는 무시한다. + _old_plain_raw = _normalize_list_for_content_compare(change.old_block.content) + _new_plain_raw = _normalize_list_for_content_compare(change.new_block.content) + has_content_change = _old_plain_raw != _new_plain_raw + # _apply_mdx_diff_to_xhtml에 전달할 기본값은 collapse_ws 적용: + # XHTML plain text에는 줄바꿈이 없으므로 clean list 정렬에는 공백 축약본이 맞다. + _old_plain = collapse_ws(_old_plain_raw) + _new_plain = collapse_ws(_new_plain_raw) # ol start 변경 감지: 숫자 목록의 시작 번호가 달라진 경우 _old_start = re.match(r'^\s*(\d+)\.', change.old_block.content) _new_start = re.match(r'^\s*(\d+)\.', change.new_block.content) @@ -1130,12 +1174,21 @@ def _mark_used(block_id: str, m: BlockMapping): patches.append(patch_entry) _text_change_patches[bid] = patch_entry if has_content_change: - # XHTML text를 정규화하여 MDX와 공백 1:1 매핑 보장 - # (strong trailing space 등으로 인한 이중 공백 문제 방지) - _xhtml_plain_normalized = collapse_ws( - _text_change_patches[bid]['new_plain_text']) + preserve_visible_ws = _contains_preserved_link_markup( + mapping.xhtml_text + ) + transfer_old_plain = _old_plain_raw if preserve_visible_ws else _old_plain + transfer_new_plain = _new_plain_raw if preserve_visible_ws else _new_plain + transfer_xhtml_plain = _text_change_patches[bid]['new_plain_text'] + if not preserve_visible_ws: + # XHTML text를 정규화하여 MDX와 공백 1:1 매핑 보장 + # (strong trailing space 등으로 인한 이중 공백 문제 방지) + transfer_xhtml_plain = collapse_ws(transfer_xhtml_plain) _text_change_patches[bid]['new_plain_text'] = _apply_mdx_diff_to_xhtml( - _old_plain, _new_plain, _xhtml_plain_normalized) + transfer_old_plain, + transfer_new_plain, + transfer_xhtml_plain, + ) if has_ol_start_change: _text_change_patches[bid]['ol_start'] = int(_new_start.group(1)) if has_inline_boundary: diff --git a/confluence-mdx/tests/test_reverse_sync_patch_builder.py b/confluence-mdx/tests/test_reverse_sync_patch_builder.py index 7b0297ba5..1e26faa88 100644 --- a/confluence-mdx/tests/test_reverse_sync_patch_builder.py +++ b/confluence-mdx/tests/test_reverse_sync_patch_builder.py @@ -139,20 +139,19 @@ def test_path1_direct_sidecar_match_list_with_content_change_regenerates(self): assert patches[0]['xhtml_xpath'] == 'ul[1]' assert patches[0]['action'] == 'replace_fragment' - # Path 1b: 직접 sidecar 매칭 + 형식 전용 변경 (텍스트 동일) → skip - # 예: [ **General** ] → [**General**] (링크 내 공백, collapse_ws 후 텍스트 동일) - def test_path1b_direct_sidecar_format_only_change_skips(self): + # Path 1b: 직접 sidecar 매칭 + URL만 변경 (링크 텍스트 동일) → skip + def test_path1b_direct_sidecar_url_only_change_skips(self): child = _make_mapping('c1', 'General text', xpath='li[1]') parent = _make_mapping('p1', 'General text more', xpath='ul[1]', type_='list', children=['c1']) mappings = [parent, child] xpath_to_mapping = {m.xhtml_xpath: m for m in mappings} - # 링크 공백 변경만: [**General**](url) → [ **General** ](url), 텍스트 동일 + # URL만 변경, 링크 텍스트 동일: normalize 후 텍스트 동일 → skip change = _make_change( 0, '* [**General**](company-management/general) text\n', - '* [ **General** ](company-management/general) text\n', + '* [**General**](company-management/general-v2) text\n', type_='list', ) mdx_to_sidecar = self._setup_sidecar('ul[1]', 0) @@ -165,9 +164,36 @@ def test_path1b_direct_sidecar_format_only_change_skips(self): mappings, mdx_to_sidecar, xpath_to_mapping, roundtrip_sidecar=roundtrip_sidecar) - # 형식만 변경(링크 공백), normalize+collapse_ws 후 텍스트 동일 → skip + # URL만 변경, normalize 후 텍스트 동일 → skip assert patches == [] + def test_path1b_link_boundary_whitespace_generates_patch(self): + """링크 경계 공백 변경은 패치를 생성해야 한다.""" + child = _make_mapping('c1', 'General text', xpath='li[1]') + parent = _make_mapping('p1', 'General text more', xpath='ul[1]', + type_='list', children=['c1']) + mappings = [parent, child] + xpath_to_mapping = {m.xhtml_xpath: m for m in mappings} + + change = _make_change( + 0, + '* [**General**](company-management/general) text\n', + '* [ **General** ](company-management/general) text\n', + type_='list', + ) + mdx_to_sidecar = self._setup_sidecar('ul[1]', 0) + roundtrip_sidecar = _make_roundtrip_sidecar([ + SidecarBlock(0, 'ul[1]', '
  • General text

  • ', 'hash1', (1, 1)) + ]) + + patches, *_ = build_patches( + [change], [change.old_block], [change.new_block], + mappings, mdx_to_sidecar, xpath_to_mapping, + roundtrip_sidecar=roundtrip_sidecar) + + assert len(patches) == 1 + assert patches[0]['action'] == 'replace_fragment' + # Path 1c: sidecar 매칭 → list type + roundtrip_sidecar 없음 + content change # → clean list이면 replace_fragment (Phase 5: has_content_change → patch) def test_path1c_sidecar_match_list_without_roundtrip_sidecar_with_content_change_patches(self): @@ -2766,3 +2792,176 @@ def test_nested_blank_item_removal_does_not_touch_other_nested_lists(self): f"변경되지 않은 두 번째 하위 리스트는 2개 항목을 유지해야 합니다. " f"patched XHTML: {patched[:400]}" ) + + +class TestWhitespaceOnlyChangeGeneratesPatch: + """공백만 변경된 리스트 블록도 패치가 생성되어야 한다. + + build_patches의 has_content_change가 collapse_ws로 비교하면 + 공백 변경이 무시되어 패치가 생성되지 않는 버그 재현. + sidecar가 있는 clean list에서 has_any_change=False → 패치 누락. + """ + + def _build_list_patches(self, xhtml, old_content, new_content): + """리스트 공백 변경 패치 생성 헬퍼.""" + old_block = MdxBlock( + type='list', content=old_content, line_start=1, line_end=1) + new_block = MdxBlock( + type='list', content=new_content, line_start=1, line_end=1) + change = BlockChange( + index=0, change_type='modified', + old_block=old_block, new_block=new_block, + ) + mapping = BlockMapping( + block_id='list-1', type='list', xhtml_xpath='ul[1]', + xhtml_text=xhtml, + xhtml_plain_text=old_content.lstrip('* ').strip(), + xhtml_element_index=0, children=[], + ) + sidecar_block = SidecarBlock( + block_index=0, xhtml_xpath='ul[1]', + xhtml_fragment=xhtml, + mdx_content_hash=sha256_text(old_content), + mdx_line_range=(1, 1), + ) + roundtrip_sidecar = _make_roundtrip_sidecar([sidecar_block]) + patches, _, skipped = build_patches( + [change], [old_block], [new_block], + mappings=[mapping], + roundtrip_sidecar=roundtrip_sidecar, + ) + return patches, skipped + + def test_double_space_to_single_generates_patch(self): + """이중 공백 → 단일 공백 변경 시 패치가 생성된다.""" + xhtml = '' + patches, skipped = self._build_list_patches( + xhtml, + '* privilege가 모두 "Read-Only" 인 경우\n', + '* privilege가 모두 "Read-Only" 인 경우\n', + ) + assert len(patches) > 0, ( + f"공백 축소 변경이 패치를 생성해야 합니다. skipped={skipped}" + ) + patched = patch_xhtml(xhtml, patches) + assert '모두 "Read-Only"' in patched, ( + f"패치 후 단일 공백이어야 합니다: {patched}" + ) + + def test_single_space_to_double_generates_patch(self): + """단일 공백 → 이중 공백 확대도 패치가 생성된다.""" + xhtml = '' + patches, skipped = self._build_list_patches( + xhtml, + '* 텍스트 뒤에\n', + '* 텍스트 뒤에\n', + ) + assert len(patches) > 0, ( + f"공백 확대 변경이 패치를 생성해야 합니다. skipped={skipped}" + ) + patched = patch_xhtml(xhtml, patches) + assert '텍스트 뒤에' in patched, ( + f"패치 후 이중 공백이어야 합니다: {patched}" + ) + + def test_continuation_line_reflow_only_skips_patch(self): + """continuation line 재배치는 동일 XHTML이면 패치를 만들지 않아야 한다.""" + xhtml = '' + patches, skipped = self._build_list_patches( + xhtml, + '* hello world\n', + '* hello\n world\n', + ) + assert patches == [], ( + f"continuation line reflow만 바뀐 경우 no-op 패치를 만들면 안 됩니다. " + f"patches={patches}, skipped={skipped}" + ) + + +class TestPreservedAnchorListWhitespaceTransfer: + """preserved anchor 리스트에서도 가시 공백 변경은 text transfer로 반영되어야 한다.""" + + def test_double_space_around_link_is_transferred(self): + xhtml = ( + '' + ) + old_content = '* 텍스트 [링크](url) 뒤에\n' + new_content = '* 텍스트 [링크](url) 뒤에\n' + change = _make_change(0, old_content, new_content, type_='list') + mapping = BlockMapping( + block_id='list-anchor-1', + type='list', + xhtml_xpath='ul[1]', + xhtml_text=xhtml, + xhtml_plain_text='텍스트 링크 뒤에', + xhtml_element_index=0, + children=[], + ) + roundtrip_sidecar = _make_roundtrip_sidecar([ + SidecarBlock(0, 'ul[1]', xhtml, sha256_text(old_content), (1, 1)) + ]) + + patches, _, skipped = build_patches( + [change], [change.old_block], [change.new_block], + mappings=[mapping], + roundtrip_sidecar=roundtrip_sidecar, + ) + + assert len(patches) == 1, ( + f"preserved anchor 리스트 공백 확대도 패치를 생성해야 합니다. skipped={skipped}" + ) + assert patches[0]['new_plain_text'] == '텍스트 링크 뒤에' + + patched = patch_xhtml(xhtml, patches) + assert '링크' in patched + assert '텍스트
  • 목록 좌측 상단에서 Delete버튼을 클릭합니다

    ' + '' + '' + '

  • ' + ) + old_content = ( + '4. 목록 좌측 상단에서 `Delete`버튼을 클릭합니다
    \n' + '
    \n' + ' img\n' + '
    \n' + ) + new_content = ( + '4. 목록 좌측 상단에서 `Delete` 버튼을 클릭합니다.
    \n' + '
    \n' + ' img\n' + '
    \n' + ) + change = _make_change(0, old_content, new_content, type_='list') + mapping = BlockMapping( + block_id='list-image-1', + type='list', + xhtml_xpath='ol[1]', + xhtml_text=xhtml, + xhtml_plain_text='목록 좌측 상단에서 Delete버튼을 클릭합니다', + xhtml_element_index=0, + children=[], + ) + roundtrip_sidecar = _make_roundtrip_sidecar([ + SidecarBlock(0, 'ol[1]', xhtml, sha256_text(old_content), (1, 4)) + ]) + + patches, _, skipped = build_patches( + [change], [change.old_block], [change.new_block], + mappings=[mapping], + roundtrip_sidecar=roundtrip_sidecar, + ) + + assert len(patches) == 1, ( + f"이미지 preserved anchor 리스트도 패치를 생성해야 합니다. skipped={skipped}" + ) + assert patches[0]['new_plain_text'] == '목록 좌측 상단에서 Delete 버튼을 클릭합니다.'