"""Юнит-тесты скоринга плагиата (итоговый процент, который видит студент).""" from app.scoring import aggregate_results, is_cited def _m(title: str, pos: int) -> dict: return {"source_title": title, "position_start": pos} def _mp(title: str, start: int, end: int) -> dict: return {"source_title": title, "position_start": start, "position_end": end} def test_empty_input_is_zero(): out = aggregate_results([], [], [], total_fragments=10) assert out["overall_similarity"] == 0.0 assert out["flagged_fragments"] == 0 assert out["matches"] == [] assert out["by_method"] == {"exact": 0, "fuzzy": 0, "semantic_llm": 0} def test_zero_fragments_does_not_divide_by_zero(): out = aggregate_results([_m("A", 0)], [], [], total_fragments=0) assert out["overall_similarity"] == 0.0 def test_percentage_is_share_of_flagged_positions(): # 2 из 4 фрагментов помечены → 50% level1 = [_m("A", 0), _m("B", 5)] out = aggregate_results(level1, [], [], total_fragments=4) assert out["overall_similarity"] == 50.0 assert out["flagged_fragments"] == 2 def test_same_position_multiple_sources_counts_once(): # Одна позиция совпала с двумя разными источниками — доля не раздувается level1 = [_m("A", 0)] semantic = [_m("B", 0)] # та же позиция 0, другой источник out = aggregate_results(level1, [], semantic, total_fragments=2) assert out["flagged_fragments"] == 1 assert out["overall_similarity"] == 50.0 # оба совпадения сохранены в matches (дедуп только по паре title:pos) assert len(out["matches"]) == 2 def test_exact_duplicate_match_is_deduplicated(): # Один и тот же источник в одной позиции пришёл дважды — остаётся один dup = _m("A", 3) out = aggregate_results([dict(dup)], [dict(dup)], [], total_fragments=5) assert len(out["matches"]) == 1 def test_similarity_capped_at_100(): # Больше уникальных позиций, чем фрагментов (пограничный случай) → не выше 100% matches = [_m(f"S{i}", i) for i in range(5)] out = aggregate_results(matches, [], [], total_fragments=3) assert out["overall_similarity"] == 100.0 def test_by_method_counts_raw_matches_per_level(): out = aggregate_results( [_m("A", 0), _m("B", 1)], # exact = 2 [_m("C", 2)], # fuzzy = 1 [_m("D", 3), _m("E", 4), _m("F", 5)], # semantic_llm = 3 total_fragments=10, ) assert out["by_method"] == {"exact": 2, "fuzzy": 1, "semantic_llm": 3} def test_percentage_rounded_to_two_decimals(): # 1 из 3 → 33.333... → 33.33 out = aggregate_results([_m("A", 0)], [], [], total_fragments=3) assert out["overall_similarity"] == 33.33 # ─── is_cited ─────────────────────────────────────────────────────────────── def test_is_cited_quoted_fragment(): text = 'Автор пишет: «дословная цитата» и развивает мысль.' start = text.index("дословная") end = start + len("дословная цитата") assert is_cited(text, start, end) is True def test_is_cited_bracket_citation_with_year(): text = "Текст фрагмента без кавычек [Иванов, 2023] продолжение." end = text.index(" [Иванов") assert is_cited(text, 0, end) is True def test_is_cited_apa_style_citation(): text = "Some borrowed sentence here (Smith, 2020) continues." end = text.index(" (Smith") assert is_cited(text, 0, end) is True def test_is_cited_bare_plagiarism_is_false(): text = "Просто скопированный текст без всякого оформления далее." assert is_cited(text, 0, 10) is False def test_is_cited_bracket_without_year_is_false(): text = "Текст фрагмента [см. приложение] продолжение." end = text.index(" [см") assert is_cited(text, 0, end) is False def test_is_cited_empty_full_text_is_false(): assert is_cited("", 0, 5) is False def test_is_cited_out_of_range_position_is_false(): assert is_cited("короткий текст", 0, 999) is False # ─── aggregate_results: cited/uncited split ────────────────────────────────── def test_cited_match_excluded_from_uncited_similarity(): text = 'Вот «процитированный фрагмент» и текст дальше.' start = text.index("процитированный") end = start + len("процитированный фрагмент") out = aggregate_results([_mp("A", start, end)], [], [], total_fragments=4, full_text=text) assert out["matches"][0]["cited"] is True assert out["overall_similarity"] == 25.0 # цитата всё ещё учтена в общем % assert out["uncited_similarity"] == 0.0 # но не в "некорректных" заимствованиях assert out["cited_fragments"] == 1 assert out["uncited_fragments"] == 0 def test_uncited_match_counts_in_both_percentages(): text = "Скопированный без указания источника текст фрагмента дальше." out = aggregate_results([_mp("A", 0, 20)], [], [], total_fragments=4, full_text=text) assert out["matches"][0]["cited"] is False assert out["overall_similarity"] == out["uncited_similarity"] == 25.0 assert out["uncited_fragments"] == 1 def test_no_full_text_means_nothing_marked_cited(): out = aggregate_results([_m("A", 0)], [], [], total_fragments=2) # full_text не передан assert out["matches"][0]["cited"] is False assert out["overall_similarity"] == out["uncited_similarity"] # ─── aggregate_results: recommendations ────────────────────────────────────── def _cand(title: str, similarity: float, url: str | None = None) -> dict: return { "fragment": "фрагмент", "position_start": 0, "position_end": 8, "similarity": similarity, "source_title": title, "source_url": url, "source_db": "openalex", } def test_no_related_candidates_means_empty_recommendations(): out = aggregate_results([], [], [], total_fragments=10) assert out["recommendations"] == [] def test_related_candidates_become_recommendations_sorted_by_similarity(): out = aggregate_results( [], [], [], total_fragments=10, related_candidates=[_cand("Low", 60.0), _cand("High", 90.0)], ) titles = [r["source_title"] for r in out["recommendations"]] assert titles == ["High", "Low"] def test_recommendations_dedup_keeps_best_per_source(): out = aggregate_results( [], [], [], total_fragments=10, related_candidates=[_cand("A", 60.0, "url-a"), _cand("A", 85.0, "url-a")], ) assert len(out["recommendations"]) == 1 assert out["recommendations"][0]["similarity"] == 85.0 def test_recommendations_capped_at_ten(): candidates = [_cand(f"S{i}", float(i), f"url-{i}") for i in range(15)] out = aggregate_results([], [], [], total_fragments=10, related_candidates=candidates) assert len(out["recommendations"]) == 10 def test_source_already_flagged_as_match_excluded_from_recommendations(): semantic = [{**_cand("A", 90.0, "url-a"), "method": "semantic+llm"}] out = aggregate_results( [], [], semantic, total_fragments=10, related_candidates=[_cand("A", 60.0, "url-a"), _cand("B", 70.0, "url-b")], ) titles = [r["source_title"] for r in out["recommendations"]] assert titles == ["B"]