From 7f8512b3de7d598a27b80d88a50fb5655e303199 Mon Sep 17 00:00:00 2001 From: JK Date: Mon, 16 Mar 2026 20:25:21 +0900 Subject: [PATCH 1/7] confluence-mdx: preserve container wrapper on pr903 follow-up --- .../bin/reverse_sync/reconstructors.py | 40 +++++++++++++++++-- ...test_reverse_sync_reconstruct_container.py | 36 +++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/confluence-mdx/bin/reverse_sync/reconstructors.py b/confluence-mdx/bin/reverse_sync/reconstructors.py index d6f11e3f2..793e28712 100644 --- a/confluence-mdx/bin/reverse_sync/reconstructors.py +++ b/confluence-mdx/bin/reverse_sync/reconstructors.py @@ -203,7 +203,6 @@ def _rebuild_list_fragment(new_fragment: str, recon: dict) -> str: return str(soup) - # ── container 재구성 헬퍼 ────────────────────────────────────────────────────── @@ -379,6 +378,21 @@ def _reconstruct_child_with_anchors(child_frag: str, child_meta: dict) -> str: return str(soup) +def _find_container_body(root: Tag) -> Optional[Tag]: + """Container fragment에서 child slot이 들어가는 body wrapper를 찾는다.""" + return root.find('ac:rich-text-body') or root.find('ac:adf-content') + + +def _replace_container_body_children(body: Tag, children: list[str]) -> None: + """Container body의 직계 child를 새 fragment 목록으로 교체한다.""" + for child in list(body.contents): + child.extract() + for fragment in children: + fragment_soup = BeautifulSoup(fragment, 'html.parser') + for child in list(fragment_soup.contents): + body.append(child.extract()) + + def sidecar_block_requires_reconstruction( sidecar_block: Optional['SidecarBlock'], ) -> bool: @@ -456,6 +470,7 @@ def reconstruct_container_fragment( 1. inline markup 보존: 원본 fragment를 template으로 bold·italic·link 유지 2. anchor 재삽입: ac:image를 offset 매핑으로 복원 3. outer wrapper 보존: sidecar xhtml_fragment를 template으로 macro 속성 유지 + anchor가 없는 clean container도 emitted child를 template wrapper 안에 다시 배치한다. """ if sidecar_block is None or sidecar_block.reconstruction is None: return new_fragment @@ -467,11 +482,21 @@ def reconstruct_container_fragment( # emitted new_fragment에서 body children 추출 emitted_soup = BeautifulSoup(new_fragment, 'html.parser') - emitted_body = emitted_soup.find('ac:rich-text-body') or emitted_soup.find('ac:adf-content') + emitted_root = next((child for child in emitted_soup.contents if isinstance(child, Tag)), None) + if emitted_root is None: + return new_fragment + emitted_body = _find_container_body(emitted_root) if emitted_body is None: return new_fragment - emitted_children = [c for c in emitted_body.children if isinstance(c, Tag)] + template_fragment = sidecar_block.xhtml_fragment or new_fragment + template_soup = BeautifulSoup(template_fragment, 'html.parser') + template_root = next((child for child in template_soup.contents if isinstance(child, Tag)), None) + if template_root is None: + return new_fragment + template_body = _find_container_body(template_root) + if template_body is None: + return new_fragment # clean container 처리: # 1) 단락 병합 (emitted 수 < stored 수): stored body를 template으로 텍스트 재배분 @@ -506,6 +531,7 @@ def reconstruct_container_fragment( # 각 child 재구성 rebuilt_fragments = [] + emitted_children = [c for c in emitted_body.children if isinstance(c, Tag)] for i, child_tag in enumerate(emitted_children): if i >= len(children_meta): rebuilt_fragments.append(str(child_tag)) @@ -524,6 +550,14 @@ def reconstruct_container_fragment( # Step 2: anchor 재삽입 if has_anchors and child_meta.get('anchors'): child_frag = _reconstruct_child_with_anchors(child_frag, child_meta) + elif child_meta.get('items'): + child_frag = _rebuild_list_fragment( + child_frag, + { + 'old_plain_text': child_meta.get('plain_text', ''), + 'items': child_meta.get('items', []), + }, + ) rebuilt_fragments.append(child_frag) diff --git a/confluence-mdx/tests/test_reverse_sync_reconstruct_container.py b/confluence-mdx/tests/test_reverse_sync_reconstruct_container.py index 075702865..253ff0c53 100644 --- a/confluence-mdx/tests/test_reverse_sync_reconstruct_container.py +++ b/confluence-mdx/tests/test_reverse_sync_reconstruct_container.py @@ -364,6 +364,42 @@ def test_no_sidecar_block_returns_new_fragment(self): result = reconstruct_container_fragment(new_frag, None) assert result == new_frag + def test_clean_container_preserves_template_wrapper(self): + """clean container도 원본 wrapper 종류는 유지해야 한다.""" + new_frag = ( + '' + '

Updated note panel.

' + '
' + ) + block = SidecarBlock( + block_index=0, + xhtml_xpath='ac:adf-extension[1]', + xhtml_fragment=( + '' + 'note' + '

This is a note panel.

' + '
' + ), + reconstruction={ + 'kind': 'container', + 'children': [ + { + 'xpath': 'ac:adf-extension[1]/p[1]', + 'fragment': '

This is a note panel.

', + 'plain_text': 'This is a note panel.', + 'type': 'paragraph', + } + ], + 'child_xpaths': ['ac:adf-extension[1]/p[1]'], + }, + ) + + result = reconstruct_container_fragment(new_frag, block) + + assert '' in result + assert 'Updated note panel.' in result + assert '' not in result + @pytest.mark.parametrize("page_id", ['544112828', '1454342158', '544379140', 'panels']) def test_container_child_fragment_oracle(page_id): From 4a4993de084e3ff298f880a1c7691bbf979e2b7b Mon Sep 17 00:00:00 2001 From: JK Date: Tue, 17 Mar 2026 00:38:32 +0900 Subject: [PATCH 2/7] confluence-mdx: add adf panel wrapper regression test --- ...test_reverse_sync_reconstruct_container.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/confluence-mdx/tests/test_reverse_sync_reconstruct_container.py b/confluence-mdx/tests/test_reverse_sync_reconstruct_container.py index 253ff0c53..0c78360b6 100644 --- a/confluence-mdx/tests/test_reverse_sync_reconstruct_container.py +++ b/confluence-mdx/tests/test_reverse_sync_reconstruct_container.py @@ -540,6 +540,44 @@ def test_clean_callout_uses_transfer_text_changes(self): ) assert 'Updated text' in patched + def test_adf_panel_with_anchor_preserves_adf_wrapper(self): + """ADF panel이 replace_fragment 경로를 타도 ac:adf-extension wrapper를 유지한다.""" + xhtml = ( + '' + 'note' + '' + '

Original ' + '' + ' note.

' + '
' + '
' + '
' + '

Original note.

' + '
' + '
' + ) + original_mdx = ( + 'import { Callout } from "nextra/components"\n\n' + '\n' + 'Original sample.png note.\n' + '\n' + ) + improved_mdx = ( + 'import { Callout } from "nextra/components"\n\n' + '\n' + 'Updated sample.png note.\n' + '\n' + ) + + patches, patched = _run_pipeline(xhtml, original_mdx, improved_mdx) + + replace_patches = [p for p in patches if p.get('action') == 'replace_fragment'] + assert len(replace_patches) == 1 + assert replace_patches[0]['xhtml_xpath'] == 'ac:adf-extension[1]' + assert '' in patched + assert '' not in patched + assert ' Date: Fri, 27 Mar 2026 13:04:30 +0900 Subject: [PATCH 3/7] confluence-mdx: fix clean container reconstruction after rebase --- confluence-mdx/bin/reverse_sync/reconstructors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/confluence-mdx/bin/reverse_sync/reconstructors.py b/confluence-mdx/bin/reverse_sync/reconstructors.py index 793e28712..3e570814a 100644 --- a/confluence-mdx/bin/reverse_sync/reconstructors.py +++ b/confluence-mdx/bin/reverse_sync/reconstructors.py @@ -488,6 +488,7 @@ def reconstruct_container_fragment( emitted_body = _find_container_body(emitted_root) if emitted_body is None: return new_fragment + emitted_children = [c for c in emitted_body.children if isinstance(c, Tag)] template_fragment = sidecar_block.xhtml_fragment or new_fragment template_soup = BeautifulSoup(template_fragment, 'html.parser') @@ -531,7 +532,6 @@ def reconstruct_container_fragment( # 각 child 재구성 rebuilt_fragments = [] - emitted_children = [c for c in emitted_body.children if isinstance(c, Tag)] for i, child_tag in enumerate(emitted_children): if i >= len(children_meta): rebuilt_fragments.append(str(child_tag)) From 660a113e4cad7a6475d033cdad1755239d97ed72 Mon Sep 17 00:00:00 2001 From: JK Date: Sat, 28 Mar 2026 18:03:04 +0900 Subject: [PATCH 4/7] confluence-mdx: handle ADF fallback in container reconstruction --- .../bin/reverse_sync/reconstructors.py | 50 ++++++++--- ...test_reverse_sync_reconstruct_container.py | 86 +++++++++++++++++++ 2 files changed, 124 insertions(+), 12 deletions(-) diff --git a/confluence-mdx/bin/reverse_sync/reconstructors.py b/confluence-mdx/bin/reverse_sync/reconstructors.py index 3e570814a..15ed19a62 100644 --- a/confluence-mdx/bin/reverse_sync/reconstructors.py +++ b/confluence-mdx/bin/reverse_sync/reconstructors.py @@ -393,6 +393,25 @@ def _replace_container_body_children(body: Tag, children: list[str]) -> None: body.append(child.extract()) +def _find_adf_fallback_body(root: Tag) -> Optional[Tag]: + """ac:adf-extension 내부 fallback content slot을 찾는다.""" + fallback = root.find('ac:adf-fallback') + if fallback is None: + return None + panel_content = fallback.find( + 'div', + class_=lambda value: isinstance(value, str) and 'panelContent' in value.split(), + ) + if panel_content is not None: + return panel_content + return fallback.find('div') or fallback + + +def _fragments_contain_confluence_markup(fragments: list[str]) -> bool: + """fallback에 그대로 복사하기 unsafe한 Confluence 전용 markup이 있으면 True.""" + return any(' bool: @@ -576,28 +595,35 @@ def _apply_outer_wrapper_template( """ outer_template = sidecar_block.xhtml_fragment template_soup = BeautifulSoup(outer_template or new_fragment, 'html.parser') - template_body = ( - template_soup.find('ac:rich-text-body') or template_soup.find('ac:adf-content') - ) + template_root = next((child for child in template_soup.contents if isinstance(child, Tag)), None) + if template_root is None: + return new_fragment + template_body = _find_container_body(template_root) if template_body is None: return new_fragment # body children 추출 if rebuilt_children is None: emitted_soup = BeautifulSoup(new_fragment, 'html.parser') - emitted_body = ( - emitted_soup.find('ac:rich-text-body') or emitted_soup.find('ac:adf-content') - ) + emitted_root = next((child for child in emitted_soup.contents if isinstance(child, Tag)), None) + if emitted_root is None: + return new_fragment + emitted_body = _find_container_body(emitted_root) if emitted_body is None: return new_fragment rebuilt_children = [str(c) for c in emitted_body.children if isinstance(c, Tag)] - for child in list(template_body.contents): - child.extract() - for frag in rebuilt_children: - frag_soup = BeautifulSoup(frag, 'html.parser') - for node in list(frag_soup.contents): - template_body.append(node.extract()) + _replace_container_body_children(template_body, rebuilt_children) + + if template_root.name == 'ac:adf-extension': + fallback_body = _find_adf_fallback_body(template_root) + if fallback_body is not None: + if _fragments_contain_confluence_markup(rebuilt_children): + fallback = template_root.find('ac:adf-fallback') + if fallback is not None: + fallback.decompose() + else: + _replace_container_body_children(fallback_body, rebuilt_children) return str(template_soup) diff --git a/confluence-mdx/tests/test_reverse_sync_reconstruct_container.py b/confluence-mdx/tests/test_reverse_sync_reconstruct_container.py index 0c78360b6..428e16372 100644 --- a/confluence-mdx/tests/test_reverse_sync_reconstruct_container.py +++ b/confluence-mdx/tests/test_reverse_sync_reconstruct_container.py @@ -400,6 +400,92 @@ def test_clean_container_preserves_template_wrapper(self): assert 'Updated note panel.' in result assert '' not in result + def test_clean_adf_container_updates_fallback_body(self): + """clean ADF panel은 fallback도 stale 상태로 남기면 안 된다.""" + new_frag = ( + '' + '

Updated note panel.

' + '
' + ) + block = SidecarBlock( + block_index=0, + xhtml_xpath='ac:adf-extension[1]', + xhtml_fragment=( + '' + 'note' + '

Original content.

' + '
' + '
' + '

Original fallback.

' + '
' + '
' + ), + reconstruction={ + 'kind': 'container', + 'children': [ + { + 'xpath': 'ac:adf-extension[1]/p[1]', + 'fragment': '

Original content.

', + 'plain_text': 'Original content.', + 'type': 'paragraph', + } + ], + 'child_xpaths': ['ac:adf-extension[1]/p[1]'], + }, + ) + + result = reconstruct_container_fragment(new_frag, block) + + assert 'Updated note panel.' in result + assert 'Original fallback.' not in result + assert '' in result + + def test_anchor_bearing_adf_container_drops_stale_fallback(self): + """Confluence 전용 anchor가 들어가면 fallback은 stale 상태로 남기지 않는다.""" + new_frag = ( + '' + '

Updated note.

' + '
' + ) + image_xhtml = ( + '' + '' + '' + ) + block = SidecarBlock( + block_index=0, + xhtml_xpath='ac:adf-extension[1]', + xhtml_fragment=( + '' + 'note' + '

Original note.

' + '
' + '
' + '

Original fallback.

' + '
' + '
' + ), + reconstruction={ + 'kind': 'container', + 'children': [ + { + 'xpath': 'ac:adf-extension[1]/p[1]', + 'fragment': f'

Original {image_xhtml} note.

', + 'plain_text': 'Original note.', + 'type': 'paragraph', + 'anchors': [{'offset': 9, 'raw_xhtml': image_xhtml}], + } + ], + 'child_xpaths': ['ac:adf-extension[1]/p[1]'], + }, + ) + + result = reconstruct_container_fragment(new_frag, block) + + assert '' not in result + assert 'Original fallback.' not in result + @pytest.mark.parametrize("page_id", ['544112828', '1454342158', '544379140', 'panels']) def test_container_child_fragment_oracle(page_id): From 72d83a5183e771031f4f92341dc75e96036c6bf9 Mon Sep 17 00:00:00 2001 From: JK Date: Sat, 28 Mar 2026 18:40:25 +0900 Subject: [PATCH 5/7] =?UTF-8?q?confluence-mdx:=20e2e=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=97=90=20ADF=20fallback=20=EC=A0=9C=EA=B1=B0=20asse?= =?UTF-8?q?rtion=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=A9=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_adf_panel_with_anchor_preserves_adf_wrapper에 stale fallback 제거를 검증하는 assertion을 추가합니다. 기존 테스트는 main에서도 통과하여 PR의 실질적 동작 차이를 검증하지 못했습니다. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tests/test_reverse_sync_reconstruct_container.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/confluence-mdx/tests/test_reverse_sync_reconstruct_container.py b/confluence-mdx/tests/test_reverse_sync_reconstruct_container.py index 428e16372..9b543aafa 100644 --- a/confluence-mdx/tests/test_reverse_sync_reconstruct_container.py +++ b/confluence-mdx/tests/test_reverse_sync_reconstruct_container.py @@ -627,7 +627,7 @@ def test_clean_callout_uses_transfer_text_changes(self): assert 'Updated text' in patched def test_adf_panel_with_anchor_preserves_adf_wrapper(self): - """ADF panel이 replace_fragment 경로를 타도 ac:adf-extension wrapper를 유지한다.""" + """ADF panel 재구성 시 wrapper를 유지하고 stale fallback을 제거한다.""" xhtml = ( '' 'note' @@ -663,6 +663,9 @@ def test_adf_panel_with_anchor_preserves_adf_wrapper(self): assert '' in patched assert '' not in patched assert '' not in patched + assert 'Original note.' not in patched def test_clean_callout_structure_change_keeps_emitted_list(self): """clean container의 구조 변경은 stored paragraph 템플릿으로 덮어쓰면 안 된다.""" From 7198ba43cf6c7455acbcadfbd0bbf34275873d91 Mon Sep 17 00:00:00 2001 From: JK Date: Sat, 28 Mar 2026 19:31:00 +0900 Subject: [PATCH 6/7] =?UTF-8?q?confluence-mdx:=20reconstruct=5Fcontainer?= =?UTF-8?q?=5Ffragment=EC=97=90=EC=84=9C=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20t?= =?UTF-8?q?emplate=20=EB=B3=80=EC=88=98=EB=A5=BC=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=ED=95=A9=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit template_soup/template_root/template_body가 파싱 후 참조되지 않았습니다. _apply_outer_wrapper_template()가 자체적으로 template을 파싱하므로 중복이었습니다. 섹션 주석 앞 빈 줄도 복원합니다. Co-Authored-By: Claude Opus 4.6 (1M context) --- confluence-mdx/bin/reverse_sync/reconstructors.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/confluence-mdx/bin/reverse_sync/reconstructors.py b/confluence-mdx/bin/reverse_sync/reconstructors.py index 15ed19a62..3b9534ab2 100644 --- a/confluence-mdx/bin/reverse_sync/reconstructors.py +++ b/confluence-mdx/bin/reverse_sync/reconstructors.py @@ -203,6 +203,7 @@ def _rebuild_list_fragment(new_fragment: str, recon: dict) -> str: return str(soup) + # ── container 재구성 헬퍼 ────────────────────────────────────────────────────── @@ -509,15 +510,6 @@ def reconstruct_container_fragment( return new_fragment emitted_children = [c for c in emitted_body.children if isinstance(c, Tag)] - template_fragment = sidecar_block.xhtml_fragment or new_fragment - template_soup = BeautifulSoup(template_fragment, 'html.parser') - template_root = next((child for child in template_soup.contents if isinstance(child, Tag)), None) - if template_root is None: - return new_fragment - template_body = _find_container_body(template_root) - if template_body is None: - return new_fragment - # clean container 처리: # 1) 단락 병합 (emitted 수 < stored 수): stored body를 template으로 텍스트 재배분 # 2) children 수 불일치: per-child 매칭 불안전 → outer wrapper만 From c3b02064b8444b895ce1fd32e6f11ea66f2fee64 Mon Sep 17 00:00:00 2001 From: JK Date: Sun, 29 Mar 2026 23:19:12 +0900 Subject: [PATCH 7/7] =?UTF-8?q?confluence-mdx:=20PR=20scope=EB=A5=BC=20ADF?= =?UTF-8?q?=20fallback=20=EC=88=98=EC=A0=95=EC=9C=BC=EB=A1=9C=20=EC=A2=81?= =?UTF-8?q?=ED=9E=99=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reconstruct_container_fragment()를 main과 동일하게 복원합니다. pipeline에서 도달 불가능한 clean container 방어 코드와 items 재구성, 해당 dead-path 테스트 2개를 제거합니다. 변경 범위: _apply_outer_wrapper_template() ADF fallback 처리 + 헬퍼 4개 + 테스트 2개 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../bin/reverse_sync/reconstructors.py | 16 +--- ...test_reverse_sync_reconstruct_container.py | 76 ------------------- 2 files changed, 2 insertions(+), 90 deletions(-) diff --git a/confluence-mdx/bin/reverse_sync/reconstructors.py b/confluence-mdx/bin/reverse_sync/reconstructors.py index 3b9534ab2..868b23e43 100644 --- a/confluence-mdx/bin/reverse_sync/reconstructors.py +++ b/confluence-mdx/bin/reverse_sync/reconstructors.py @@ -485,12 +485,10 @@ def reconstruct_container_fragment( ) -> str: """Container (callout/ADF panel) fragment에 sidecar child 메타데이터로 재구성한다. - anchor 없는 clean container는 stored XHTML를 template으로 텍스트만 업데이트한다. anchor가 있어 재구성이 트리거된 경우 아래 세 단계를 각 child에 적용한다: 1. inline markup 보존: 원본 fragment를 template으로 bold·italic·link 유지 2. anchor 재삽입: ac:image를 offset 매핑으로 복원 3. outer wrapper 보존: sidecar xhtml_fragment를 template으로 macro 속성 유지 - anchor가 없는 clean container도 emitted child를 template wrapper 안에 다시 배치한다. """ if sidecar_block is None or sidecar_block.reconstruction is None: return new_fragment @@ -502,12 +500,10 @@ def reconstruct_container_fragment( # emitted new_fragment에서 body children 추출 emitted_soup = BeautifulSoup(new_fragment, 'html.parser') - emitted_root = next((child for child in emitted_soup.contents if isinstance(child, Tag)), None) - if emitted_root is None: - return new_fragment - emitted_body = _find_container_body(emitted_root) + emitted_body = emitted_soup.find('ac:rich-text-body') or emitted_soup.find('ac:adf-content') if emitted_body is None: return new_fragment + emitted_children = [c for c in emitted_body.children if isinstance(c, Tag)] # clean container 처리: @@ -561,14 +557,6 @@ def reconstruct_container_fragment( # Step 2: anchor 재삽입 if has_anchors and child_meta.get('anchors'): child_frag = _reconstruct_child_with_anchors(child_frag, child_meta) - elif child_meta.get('items'): - child_frag = _rebuild_list_fragment( - child_frag, - { - 'old_plain_text': child_meta.get('plain_text', ''), - 'items': child_meta.get('items', []), - }, - ) rebuilt_fragments.append(child_frag) diff --git a/confluence-mdx/tests/test_reverse_sync_reconstruct_container.py b/confluence-mdx/tests/test_reverse_sync_reconstruct_container.py index 9b543aafa..abe04ab5c 100644 --- a/confluence-mdx/tests/test_reverse_sync_reconstruct_container.py +++ b/confluence-mdx/tests/test_reverse_sync_reconstruct_container.py @@ -364,82 +364,6 @@ def test_no_sidecar_block_returns_new_fragment(self): result = reconstruct_container_fragment(new_frag, None) assert result == new_frag - def test_clean_container_preserves_template_wrapper(self): - """clean container도 원본 wrapper 종류는 유지해야 한다.""" - new_frag = ( - '' - '

Updated note panel.

' - '
' - ) - block = SidecarBlock( - block_index=0, - xhtml_xpath='ac:adf-extension[1]', - xhtml_fragment=( - '' - 'note' - '

This is a note panel.

' - '
' - ), - reconstruction={ - 'kind': 'container', - 'children': [ - { - 'xpath': 'ac:adf-extension[1]/p[1]', - 'fragment': '

This is a note panel.

', - 'plain_text': 'This is a note panel.', - 'type': 'paragraph', - } - ], - 'child_xpaths': ['ac:adf-extension[1]/p[1]'], - }, - ) - - result = reconstruct_container_fragment(new_frag, block) - - assert '' in result - assert 'Updated note panel.' in result - assert '' not in result - - def test_clean_adf_container_updates_fallback_body(self): - """clean ADF panel은 fallback도 stale 상태로 남기면 안 된다.""" - new_frag = ( - '' - '

Updated note panel.

' - '
' - ) - block = SidecarBlock( - block_index=0, - xhtml_xpath='ac:adf-extension[1]', - xhtml_fragment=( - '' - 'note' - '

Original content.

' - '
' - '
' - '

Original fallback.

' - '
' - '
' - ), - reconstruction={ - 'kind': 'container', - 'children': [ - { - 'xpath': 'ac:adf-extension[1]/p[1]', - 'fragment': '

Original content.

', - 'plain_text': 'Original content.', - 'type': 'paragraph', - } - ], - 'child_xpaths': ['ac:adf-extension[1]/p[1]'], - }, - ) - - result = reconstruct_container_fragment(new_frag, block) - - assert 'Updated note panel.' in result - assert 'Original fallback.' not in result - assert '' in result - def test_anchor_bearing_adf_container_drops_stale_fallback(self): """Confluence 전용 anchor가 들어가면 fallback은 stale 상태로 남기지 않는다.""" new_frag = (