From 169e12a9c0d1b4a3d7897d62c81c18e421ab2169 Mon Sep 17 00:00:00 2001 From: JK Date: Fri, 13 Feb 2026 16:07:28 +0900 Subject: [PATCH] =?UTF-8?q?test:=20expand=20patch=5Fbuilder=20test=20cover?= =?UTF-8?q?age=20(7=20=E2=86=92=2052=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive unit tests for the most complex module (470 lines): - build_patches() 6 branching paths: sidecar match + child resolve, child fail + list split, child fail + containing block, sidecar miss + text search, sidecar miss + table split, text mismatch + remapping - _resolve_child_mapping() 4-tier fallback (8 tests) - Helper functions: is_markdown_table, split_table_rows, normalize_table_row, split_list_items, extract_list_marker_prefix - build_table_row_patches, build_list_item_patches integration - Edge cases: NON_CONTENT_TYPES skip, multiple changes grouped Test count: 251 → 296 (all passing) Co-Authored-By: Claude Opus 4.6 --- .../tests/test_reverse_sync_patch_builder.py | 513 +++++++++++++++++- 1 file changed, 506 insertions(+), 7 deletions(-) diff --git a/confluence-mdx/tests/test_reverse_sync_patch_builder.py b/confluence-mdx/tests/test_reverse_sync_patch_builder.py index cb91a8ee3..68eccee11 100644 --- a/confluence-mdx/tests/test_reverse_sync_patch_builder.py +++ b/confluence-mdx/tests/test_reverse_sync_patch_builder.py @@ -1,19 +1,78 @@ -"""_find_containing_mapping() 유닛 테스트.""" -from reverse_sync.patch_builder import _find_containing_mapping +"""patch_builder 유닛 테스트. + +기존 _find_containing_mapping 테스트 + build_patches 6개 분기 경로 ++ helper 함수 (is_markdown_table, split_table_rows, normalize_table_row, +split_list_items, extract_list_marker_prefix, _resolve_child_mapping, +build_table_row_patches, build_list_item_patches) 테스트. +""" +from reverse_sync.block_diff import BlockChange from reverse_sync.mapping_recorder import BlockMapping +from reverse_sync.mdx_block_parser import MdxBlock +from reverse_sync.sidecar_lookup import SidecarEntry +from reverse_sync.patch_builder import ( + _find_containing_mapping, + _resolve_child_mapping, + build_patches, + build_list_item_patches, + build_table_row_patches, + is_markdown_table, + split_table_rows, + normalize_table_row, + split_list_items, + extract_list_marker_prefix, +) + +# ── 헬퍼 팩토리 ── -def _make_mapping(block_id: str, xhtml_plain_text: str) -> BlockMapping: + +def _make_mapping( + block_id: str, + xhtml_plain_text: str, + xpath: str | None = None, + type_: str = 'paragraph', + children: list | None = None, +) -> BlockMapping: return BlockMapping( block_id=block_id, - type='paragraph', - xhtml_xpath=f'p[{block_id}]', + type=type_, + xhtml_xpath=xpath or f'p[{block_id}]', xhtml_text=f'

{xhtml_plain_text}

', xhtml_plain_text=xhtml_plain_text, xhtml_element_index=0, + children=children or [], + ) + + +def _make_block( + content: str, type_: str = 'paragraph', line_start: int = 1, +) -> MdxBlock: + lines = content.count('\n') + 1 + return MdxBlock( + type=type_, content=content, + line_start=line_start, line_end=line_start + lines - 1, + ) + + +def _make_change( + index: int, old_content: str, new_content: str, + type_: str = 'paragraph', +) -> BlockChange: + return BlockChange( + index=index, + change_type='modified', + old_block=_make_block(old_content, type_), + new_block=_make_block(new_content, type_), ) +def _make_sidecar(xpath: str, mdx_blocks: list) -> SidecarEntry: + return SidecarEntry(xhtml_xpath=xpath, xhtml_type='paragraph', mdx_blocks=mdx_blocks) + + +# ── _find_containing_mapping (기존 7개 테스트 유지) ── + + class TestFindContainingMapping: def test_finds_mapping_containing_old_plain(self): m1 = _make_mapping('m1', 'Command Audit : Server내 수행 명령어 이력') @@ -21,7 +80,6 @@ def test_finds_mapping_containing_old_plain(self): mappings = [m1, m2] result = _find_containing_mapping( 'Command Audit : Server내 수행 명령어 이력', mappings, set()) - # m1이 정확히 포함하므로 m1 반환 assert result is m1 def test_skips_used_ids(self): @@ -56,7 +114,6 @@ def test_ignores_whitespace_differences(self): assert result is m1 def test_ignores_invisible_unicode_chars(self): - # Hangul Filler (U+3164) and ZWSP (U+200B) in XHTML text m1 = _make_mapping( 'm1', 'Account Lock History\u3164 : QueryPie\u200b사용자별 서버 접속 계정') @@ -64,3 +121,445 @@ def test_ignores_invisible_unicode_chars(self): 'Account Lock History : QueryPie사용자별 서버 접속 계정', [m1], set()) assert result is m1 + + +# ── _resolve_child_mapping ── + + +class TestResolveChildMapping: + def test_exact_match_first_pass(self): + child = _make_mapping('c1', 'child text') + parent = _make_mapping('p1', 'parent text', children=['c1']) + id_map = {'c1': child, 'p1': parent} + result = _resolve_child_mapping('child text', parent, id_map) + assert result is child + + def test_whitespace_collapsed_match(self): + child = _make_mapping('c1', 'child text here') + parent = _make_mapping('p1', 'parent', children=['c1']) + id_map = {'c1': child, 'p1': parent} + result = _resolve_child_mapping('child text here', parent, id_map) + assert result is child + + def test_nospace_match(self): + child = _make_mapping('c1', 'child text') + parent = _make_mapping('p1', 'parent', children=['c1']) + id_map = {'c1': child, 'p1': parent} + # collapse_ws doesn't match, but nospace does + result = _resolve_child_mapping('childtext', parent, id_map) + assert result is child + + def test_xhtml_list_marker_stripped(self): + child = _make_mapping('c1', '- item text') + parent = _make_mapping('p1', 'parent', children=['c1']) + id_map = {'c1': child, 'p1': parent} + result = _resolve_child_mapping('item text', parent, id_map) + assert result is child + + def test_mdx_list_marker_stripped(self): + child = _make_mapping('c1', 'item text') + parent = _make_mapping('p1', 'parent', children=['c1']) + id_map = {'c1': child, 'p1': parent} + result = _resolve_child_mapping('- item text', parent, id_map) + assert result is child + + def test_returns_none_when_no_match(self): + child = _make_mapping('c1', 'completely different') + parent = _make_mapping('p1', 'parent', children=['c1']) + id_map = {'c1': child, 'p1': parent} + result = _resolve_child_mapping('no match text here', parent, id_map) + assert result is None + + def test_returns_none_for_empty_text(self): + parent = _make_mapping('p1', 'parent', children=['c1']) + child = _make_mapping('c1', 'child') + id_map = {'c1': child, 'p1': parent} + result = _resolve_child_mapping('', parent, id_map) + assert result is None + + def test_missing_child_id(self): + parent = _make_mapping('p1', 'parent', children=['missing']) + id_map = {'p1': parent} + result = _resolve_child_mapping('some text here', parent, id_map) + assert result is None + + +# ── Helper 함수 테스트 ── + + +class TestIsMarkdownTable: + def test_valid_table(self): + content = '| a | b |\n| --- | --- |\n| 1 | 2 |' + assert is_markdown_table(content) is True + + def test_single_line_not_table(self): + assert is_markdown_table('| a | b |') is False + + def test_no_pipes(self): + assert is_markdown_table('hello\nworld') is False + + def test_only_separator(self): + assert is_markdown_table('| --- | --- |') is False + + +class TestSplitTableRows: + def test_splits_data_rows(self): + content = '| h1 | h2 |\n| --- | --- |\n| a | b |\n| c | d |' + rows = split_table_rows(content) + assert rows == ['| h1 | h2 |', '| a | b |', '| c | d |'] + + def test_skips_separator(self): + content = '| h |\n| --- |\n| v |' + rows = split_table_rows(content) + assert '| --- |' not in rows + + def test_skips_empty_lines(self): + content = '| a |\n\n| b |' + rows = split_table_rows(content) + assert rows == ['| a |', '| b |'] + + +class TestNormalizeTableRow: + def test_extracts_cell_text(self): + assert normalize_table_row('| hello | world |') == 'hello world' + + def test_strips_bold(self): + assert normalize_table_row('| **bold** | text |') == 'bold text' + + def test_strips_code(self): + assert normalize_table_row('| `code` | text |') == 'code text' + + def test_strips_link(self): + assert normalize_table_row('| [Title](url) | x |') == 'Title x' + + def test_empty_cells_skipped(self): + assert normalize_table_row('| | text |') == 'text' + + def test_unescapes_html(self): + assert normalize_table_row('| A & B | x |') == 'A & B x' + + +class TestSplitListItems: + def test_dash_items(self): + content = '- item one\n- item two\n- item three' + items = split_list_items(content) + assert items == ['- item one', '- item two', '- item three'] + + def test_numbered_items(self): + content = '1. first\n2. second' + items = split_list_items(content) + assert items == ['1. first', '2. second'] + + def test_multiline_item(self): + content = '- item one\n continued\n- item two' + items = split_list_items(content) + assert len(items) == 2 + assert 'continued' in items[0] + + def test_blank_line_separator(self): + content = '- item one\n\n- item two' + items = split_list_items(content) + assert items == ['- item one', '- item two'] + + +class TestExtractListMarkerPrefix: + def test_dash(self): + assert extract_list_marker_prefix('- item') == '- ' + + def test_asterisk(self): + assert extract_list_marker_prefix('* item') == '* ' + + def test_number(self): + assert extract_list_marker_prefix('1. item') == '1. ' + + def test_no_marker(self): + assert extract_list_marker_prefix('plain text') == '' + + +# ── build_patches 분기 경로 테스트 ── + + +class TestBuildPatches: + """build_patches()의 6가지 주요 분기 경로를 테스트한다.""" + + def _setup_sidecar(self, xpath: str, mdx_idx: int): + """sidecar와 xpath→mapping 인덱스를 구성하는 헬퍼.""" + entry = _make_sidecar(xpath, [mdx_idx]) + mdx_to_sidecar = {mdx_idx: entry} + return mdx_to_sidecar + + # Path 1: sidecar 매칭 → children 있음 → child 해석 성공 → 직접 패치 + def test_path1_sidecar_match_child_resolved(self): + child = _make_mapping('c1', 'child text', xpath='li[1]') + parent = _make_mapping('p1', 'parent text child 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, 'child text', 'updated child') + mdx_to_sidecar = self._setup_sidecar('ul[1]', 0) + + patches = build_patches( + [change], [change.old_block], [change.new_block], + mappings, mdx_to_sidecar, xpath_to_mapping) + + assert len(patches) == 1 + assert patches[0]['xhtml_xpath'] == 'li[1]' + assert 'updated child' in patches[0]['new_plain_text'] + + # Path 2: sidecar 매칭 → children 있음 → child 해석 실패 + # → 텍스트 불일치 → list 분리 (반환 빈 = item 수 불일치) + def test_path2_sidecar_match_child_fail_list_split(self): + parent = _make_mapping('p1', 'totally different parent', xpath='ul[1]', + type_='list', children=['c1']) + child = _make_mapping('c1', 'no match here', xpath='li[1]') + mappings = [parent, child] + xpath_to_mapping = {m.xhtml_xpath: m for m in mappings} + + # list type with different item count → returns [] + change = _make_change( + 0, '- item one\n- item two', '- item one\n- item two\n- item three', + type_='list') + mdx_to_sidecar = self._setup_sidecar('ul[1]', 0) + + patches = build_patches( + [change], [change.old_block], [change.new_block], + mappings, mdx_to_sidecar, xpath_to_mapping) + + # item count mismatch → build_list_item_patches returns [] + assert patches == [] + + # Path 3: sidecar 매칭 → children 있음 → child 해석 실패 + # → parent를 containing block으로 사용 + def test_path3_sidecar_child_fail_containing_block(self): + parent = _make_mapping( + 'p1', 'parent contains child text here', xpath='div[1]', + children=['c1']) + child = _make_mapping('c1', 'no match at all', xpath='span[1]') + mappings = [parent, child] + xpath_to_mapping = {m.xhtml_xpath: m for m in mappings} + + change = _make_change(0, 'child text', 'updated text') + mdx_to_sidecar = self._setup_sidecar('div[1]', 0) + + patches = build_patches( + [change], [change.old_block], [change.new_block], + mappings, mdx_to_sidecar, xpath_to_mapping) + + assert len(patches) == 1 + assert patches[0]['xhtml_xpath'] == 'div[1]' + + # Path 4: sidecar 미스 → 텍스트 포함 검색 → containing block + def test_path4_sidecar_miss_text_search_containing(self): + m1 = _make_mapping('m1', 'this mapping contains the search text here') + mappings = [m1] + xpath_to_mapping = {m.xhtml_xpath: m for m in mappings} + + change = _make_change(0, 'search text', 'replaced text') + mdx_to_sidecar = {} # 빈 sidecar → sidecar 미스 + + patches = build_patches( + [change], [change.old_block], [change.new_block], + mappings, mdx_to_sidecar, xpath_to_mapping) + + assert len(patches) == 1 + assert patches[0]['xhtml_xpath'] == m1.xhtml_xpath + + # Path 5: sidecar 미스 → list/table 분리 + def test_path5_sidecar_miss_table_split(self): + # table 타입이면서 sidecar 미스 + table_content_old = '| h1 | h2 |\n| --- | --- |\n| a | b |' + table_content_new = '| h1 | h2 |\n| --- | --- |\n| x | y |' + change = _make_change(0, table_content_old, table_content_new) + + # sidecar로 매핑할 수 없고, containing도 없고, table이면 build_table_row_patches + # 하지만 table_row_patches도 sidecar 필요 → 결국 빈 결과 + patches = build_patches( + [change], [change.old_block], [change.new_block], + [], {}, {}) + + assert patches == [] + + # Path 6: sidecar 매칭 → children 없음 → 텍스트 불일치 → 재매핑 + def test_path6_sidecar_match_text_mismatch_remapping(self): + # sidecar 매핑이 있지만 텍스트가 포함되지 않음 → better 매핑 찾기 + wrong = _make_mapping('wrong', 'completely wrong mapping', xpath='p[1]') + better = _make_mapping('better', 'contains the target text here', xpath='p[2]') + mappings = [wrong, better] + xpath_to_mapping = {m.xhtml_xpath: m for m in mappings} + + change = _make_change(0, 'target text', 'updated target') + mdx_to_sidecar = self._setup_sidecar('p[1]', 0) + + patches = build_patches( + [change], [change.old_block], [change.new_block], + mappings, mdx_to_sidecar, xpath_to_mapping) + + assert len(patches) == 1 + assert patches[0]['xhtml_xpath'] == 'p[2]' + + # 직접 매칭 + text_transfer 사용 + def test_direct_match_with_transfer(self): + m1 = _make_mapping('m1', 'hello world', xpath='p[1]') + mappings = [m1] + xpath_to_mapping = {m.xhtml_xpath: m for m in mappings} + + change = _make_change(0, 'hello world', 'hello earth') + mdx_to_sidecar = self._setup_sidecar('p[1]', 0) + + patches = build_patches( + [change], [change.old_block], [change.new_block], + mappings, mdx_to_sidecar, xpath_to_mapping) + + assert len(patches) == 1 + # text_transfer가 XHTML 공백을 보존하면서 변경 적용 + assert 'earth' in patches[0]['new_plain_text'] + + # 직접 매칭 + text_transfer 미사용 (텍스트 동일) + def test_direct_match_no_transfer(self): + m1 = _make_mapping('m1', 'hello world', xpath='p[1]') + mappings = [m1] + xpath_to_mapping = {m.xhtml_xpath: m for m in mappings} + + change = _make_change(0, 'hello world', 'hello earth') + mdx_to_sidecar = self._setup_sidecar('p[1]', 0) + + patches = build_patches( + [change], [change.old_block], [change.new_block], + mappings, mdx_to_sidecar, xpath_to_mapping) + + assert len(patches) == 1 + assert patches[0]['new_plain_text'] == 'hello earth' + + # NON_CONTENT_TYPES 스킵 + def test_skips_non_content_types(self): + m1 = _make_mapping('m1', 'text', xpath='p[1]') + mappings = [m1] + xpath_to_mapping = {m.xhtml_xpath: m for m in mappings} + + change = _make_change(0, 'import X', 'import Y') + change.old_block = _make_block('import X', 'import_statement') + change.new_block = _make_block('import Y', 'import_statement') + mdx_to_sidecar = self._setup_sidecar('p[1]', 0) + + patches = build_patches( + [change], [change.old_block], [change.new_block], + mappings, mdx_to_sidecar, xpath_to_mapping) + + assert patches == [] + + # 여러 변경이 동일 containing block에 그룹화 + def test_multiple_changes_grouped_to_containing(self): + container = _make_mapping( + 'm1', 'first part and second part', xpath='p[1]') + mappings = [container] + xpath_to_mapping = {m.xhtml_xpath: m for m in mappings} + + change1 = _make_change(0, 'first part', 'first UPDATED') + change2 = _make_change(1, 'second part', 'second UPDATED') + mdx_to_sidecar = {} # sidecar 미스 → containing 검색 + + patches = build_patches( + [change1, change2], + [change1.old_block, change2.old_block], + [change1.new_block, change2.new_block], + mappings, mdx_to_sidecar, xpath_to_mapping) + + assert len(patches) == 1 + assert 'UPDATED' in patches[0]['new_plain_text'] + + +# ── build_table_row_patches ── + + +class TestBuildTableRowPatches: + def test_patches_changed_row(self): + container = _make_mapping( + 'm1', 'Header1 Header2 old_val other', xpath='table[1]', + type_='table') + mappings = [container] + mdx_to_sidecar = {0: _make_sidecar('table[1]', [0])} + xpath_to_mapping = {'table[1]': container} + + change = _make_change( + 0, + '| Header1 | Header2 |\n| --- | --- |\n| old_val | other |', + '| Header1 | Header2 |\n| --- | --- |\n| new_val | other |', + ) + + patches = build_table_row_patches( + change, mappings, set(), mdx_to_sidecar, xpath_to_mapping) + + assert len(patches) == 1 + assert 'new_val' in patches[0]['new_plain_text'] + + def test_row_count_mismatch_returns_empty(self): + container = _make_mapping('m1', 'text', xpath='table[1]') + change = _make_change( + 0, + '| a |\n| --- |\n| b |', + '| a |\n| --- |\n| b |\n| c |', + ) + patches = build_table_row_patches( + change, [container], set(), + {0: _make_sidecar('table[1]', [0])}, {'table[1]': container}) + assert patches == [] + + def test_no_sidecar_returns_empty(self): + change = _make_change( + 0, '| a |\n| --- |\n| b |', '| a |\n| --- |\n| c |') + patches = build_table_row_patches(change, [], set(), {}, {}) + assert patches == [] + + +# ── build_list_item_patches ── + + +class TestBuildListItemPatches: + def test_patches_changed_item_with_child(self): + child = _make_mapping('c1', 'old item', xpath='li[1]') + parent = _make_mapping( + 'p1', 'list parent', xpath='ul[1]', children=['c1']) + mappings = [parent, child] + id_map = {m.block_id: m for m in mappings} + mdx_to_sidecar = {0: _make_sidecar('ul[1]', [0])} + xpath_to_mapping = {m.xhtml_xpath: m for m in mappings} + + change = _make_change( + 0, '- old item\n- keep item', '- new item\n- keep item', + type_='list') + + patches = build_list_item_patches( + change, mappings, set(), + mdx_to_sidecar, xpath_to_mapping, id_map) + + assert len(patches) == 1 + assert 'new item' in patches[0]['new_plain_text'] + + def test_item_count_mismatch_returns_empty(self): + change = _make_change( + 0, '- item one\n- item two', '- item one', + type_='list') + patches = build_list_item_patches(change, [], set(), {}, {}) + assert patches == [] + + def test_child_miss_falls_back_to_containing(self): + parent = _make_mapping( + 'p1', 'parent old text here in list', xpath='ul[1]', + children=['c1']) + child = _make_mapping('c1', 'no match whatsoever', xpath='li[1]') + mappings = [parent, child] + id_map = {m.block_id: m for m in mappings} + mdx_to_sidecar = {0: _make_sidecar('ul[1]', [0])} + xpath_to_mapping = {m.xhtml_xpath: m for m in mappings} + + change = _make_change( + 0, '- old text', '- new text', + type_='list') + + patches = build_list_item_patches( + change, mappings, set(), + mdx_to_sidecar, xpath_to_mapping, id_map) + + assert len(patches) == 1 + assert patches[0]['xhtml_xpath'] == 'ul[1]'