"""군집화 기반 표절 판별 단위테스트 (계획서 2단계 고도화). 핵심 시나리오: "본문(lemma)·키워드는 그대로 두고 인물만 바꾼 표절" 을 element_swap_plagiarism 으로 분해해서 잡는다. """ from __future__ import annotations from app.api.schemas import ExtractedElements from app.engine.clustering import ( ClusterIndex, signature_similarity, _element_signature, ) def _elem(characters=None, motifs=None, keywords=None, genre=None) -> ExtractedElements: return ExtractedElements( characters=characters or [], motifs=motifs or [], keywords=keywords or [], genre=genre, ) def test_signature_similarity_identical(): a = _element_signature(_elem(characters=["홍길동"], keywords=["활빈당", "탐관오리"], genre="역사")) assert signature_similarity(a, a) == 1.0 def test_clusters_group_similar_docs(): # 두 홍길동 변형은 한 군집, 어린왕자는 별도 군집 ids = ["hong-a", "hong-b", "prince"] elems = [ _elem(characters=["홍길동"], keywords=["활빈당", "탐관오리", "재물"], genre="역사"), _elem(characters=["홍길동"], keywords=["활빈당", "탐관오리", "재물"], genre="역사"), _elem(characters=["어린왕자", "여우"], keywords=["사막", "장미", "별"], genre="동화"), ] idx = ClusterIndex(ids, elems, link_threshold=0.35) assert idx.cluster_of("hong-a") == idx.cluster_of("hong-b") assert idx.cluster_of("prince") != idx.cluster_of("hong-a") def test_route_to_nearest_cluster(): ids = ["hong", "prince"] elems = [ _elem(characters=["홍길동"], keywords=["활빈당", "탐관오리", "재물"], genre="역사"), _elem(characters=["어린왕자"], keywords=["사막", "장미", "별"], genre="동화"), ] idx = ClusterIndex(ids, elems, link_threshold=0.9) # 분리 유지 q = _elem(characters=["홍길동"], keywords=["활빈당", "재물"], genre="역사") routed = idx.route(q) assert routed is not None assert "hong" in routed.members def test_element_swap_plagiarism_detected(): """본문 lemma·키워드 유지 + 인물만 교체 → element_swap_plagiarism.""" ids = ["original"] elems = [_elem(characters=["홍길동"], motifs=["복수"], keywords=["활빈당", "탐관오리", "재물", "의적"])] lemmas = [["만들다", "빼앗다", "재물", "탐관오리", "활빈당", "나누다"]] idx = ClusterIndex(ids, elems, doc_lemmas=lemmas, link_threshold=0.35) # 인물(홍길동→김민수)·모티프만 바꾸고 본문 lemma/키워드는 그대로 q_elem = _elem(characters=["김민수"], motifs=["정의"], keywords=["활빈당", "탐관오리", "재물", "의적"]) q_lemmas = ["만들다", "빼앗다", "재물", "탐관오리", "활빈당", "나누다"] sig = idx.partial_signal("original", q_elem, q_lemmas) assert sig is not None assert sig.verdict == "element_swap_plagiarism", sig.per_element assert "characters" in sig.changed_elements assert "lemmas" in sig.retained_elements def test_near_duplicate_verdict(): ids = ["original"] elems = [_elem(characters=["홍길동"], keywords=["활빈당", "탐관오리", "재물"])] lemmas = [["만들다", "빼앗다", "재물"]] idx = ClusterIndex(ids, elems, doc_lemmas=lemmas) sig = idx.partial_signal( "original", _elem(characters=["홍길동"], keywords=["활빈당", "탐관오리", "재물"]), ["만들다", "빼앗다", "재물"], ) assert sig is not None assert sig.verdict == "near_duplicate" def test_unrelated_gets_none_or_weak(): ids = ["original"] elems = [_elem(characters=["홍길동"], keywords=["활빈당", "탐관오리"])] lemmas = [["만들다", "빼앗다"]] idx = ClusterIndex(ids, elems, doc_lemmas=lemmas) sig = idx.partial_signal( "original", _elem(characters=["우주비행사"], keywords=["로켓", "행성"]), ["날다", "탐사하다"], ) assert sig is not None assert sig.verdict in ("none", "weak") assert sig.verdict != "element_swap_plagiarism" def test_empty_index(): idx = ClusterIndex([], []) assert idx.num_clusters == 0 assert idx.route(_elem(characters=["x"])) is None assert idx.candidate_ids(_elem(characters=["x"])) == set()