diff --git a/tests/test_chunking.py b/tests/test_chunking.py index 20fab16e5..d10bd2338 100644 --- a/tests/test_chunking.py +++ b/tests/test_chunking.py @@ -1,5 +1,7 @@ from __future__ import annotations +import pytest + from lineageweave.chunking import ( ConversationTurn, chunk_by_conversation_turn, @@ -624,3 +626,59 @@ def test_chunk_by_source_body_maps_plain_caret_quantities() -> None: chunks = chunk_by_source_body("Reserve 12 m^3 and 10^{-3} M stock.") assert chunks[0].text == "Reserve 12 m³ and 10⁻³ M stock." + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("1px", 0), # rounds below one eight-pixel unit + ("16px", 2), + ("1em", 2), + ("0", 0), + ("-4px", 0), + ("10pt", 2), + ("garbage", 0), + ("", 0), + ], +) +def test_length_to_indent_units_clamps_rounds_and_rejects(value: str, expected: int) -> None: + from lineageweave.chunking import _length_to_indent_units + + assert _length_to_indent_units(value) == expected + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("10px", "10px"), + ("10px 20px", "20px"), + ("10px 20px 30px", "20px"), + ("10px 20px 30px 40px", "40px"), + ("", ""), + ], +) +def test_shorthand_left_value_picks_the_box_model_slot(raw: str, expected: str) -> None: + from lineageweave.chunking import _shorthand_left_value + + assert _shorthand_left_value(raw) == expected + + +def test_chunk_by_sentence_returns_empty_for_no_sentences() -> None: + from lineageweave.chunking import chunk_by_sentence + + assert chunk_by_sentence(" ") == [] + + +def test_decode_data_uri_image_accepts_png_and_rejects_malformed() -> None: + import base64 + + from lineageweave.chunking import _decode_data_uri_image + + png = base64.b64encode(b"\x89PNG\r\n\x1a\n").decode("ascii") + mime, raw = _decode_data_uri_image(f"data:image/png;base64,{png}") + assert mime == "image/png" + assert raw == b"\x89PNG\r\n\x1a\n" + + assert _decode_data_uri_image("http://example.test/image.png") is None + assert _decode_data_uri_image("data:image/png,notbase64") is None + assert _decode_data_uri_image("data:image/png;base64,%%%bad") is None diff --git a/tests/test_post_summary_parse.py b/tests/test_post_summary_parse.py index 2bbd69729..3d33ab461 100644 --- a/tests/test_post_summary_parse.py +++ b/tests/test_post_summary_parse.py @@ -225,4 +225,207 @@ def test_parse_single_part_pipe_string_is_dropped() -> None: content = _json_content({"roles": ["A"], "projects": ["A"]}) roles, projects = _parse_summary_details(content) assert roles == () - assert projects == () \ No newline at end of file + assert projects == () + +def test_project_candidate_node_id_requires_normalized_key() -> None: + """A raw project label must be normalized before node construction.""" + from lineageweave.post_summary import ( + normalize_project_key, + project_candidate_node_id, + ) + + assert project_candidate_node_id( + "11111111-1111-1111-1111-111111111111", "hvdc-pilot" + ) == "11111111-1111-1111-1111-111111111111/hvdc-pilot" + assert normalize_project_key(" HVDC Pilot ") == "hvdc-pilot" + with pytest.raises(ValueError, match="already be normalized"): + project_candidate_node_id("11111111-1111-1111-1111-111111111111", "HVDC Pilot") + + +def test_parse_project_candidate_node_id_rejects_bad_separators() -> None: + """A node id must contain exactly one post/key separator and be canonical.""" + from lineageweave.post_summary import parse_project_candidate_node_id + + with pytest.raises(ValueError, match="one post/key separator"): + parse_project_candidate_node_id("no-separator-here") + with pytest.raises(ValueError, match="one post/key separator"): + parse_project_candidate_node_id("post/key/extra") + with pytest.raises(ValueError, match="already be normalized|not canonical"): + parse_project_candidate_node_id( + "11111111-1111-1111-1111-111111111111/UNCANONICAL" + ) + assert parse_project_candidate_node_id( + "11111111-1111-1111-1111-111111111111/hvdc-pilot" + ) == ("11111111-1111-1111-1111-111111111111", "hvdc-pilot") + + +def test_major_event_action_rejects_missing_text() -> None: + """Action and evidence text are both required.""" + from lineageweave.post_summary import MajorEventAction + + base = dict(requester_actor_name=None, processor_actor_name=None) + with pytest.raises(ValueError, match="action and evidence"): + MajorEventAction(**base, action_text=" ", evidence_text="evidence") + with pytest.raises(ValueError, match="action and evidence"): + MajorEventAction(**base, action_text="action", evidence_text=" ") + assert MajorEventAction( + **base, action_text="action", evidence_text="evidence" + ).evidence_text == "evidence" + + +def test_five_w1h_evidence_rejects_unknown_slot_or_missing_text() -> None: + """5W1H evidence requires a governed slot plus value and support text.""" + from lineageweave.post_summary import FiveW1HEvidence + + with pytest.raises(ValueError, match="unsupported 5W1H evidence slot"): + FiveW1HEvidence(slot_code="when-not-a-slot", value_text="v", evidence_text="e") + with pytest.raises(ValueError, match="requires a value and supporting text"): + FiveW1HEvidence(slot_code="when", value_text=" ", evidence_text="e") + with pytest.raises(ValueError, match="requires a value and supporting text"): + FiveW1HEvidence(slot_code="where", value_text="v", evidence_text="") + + assert ( + FiveW1HEvidence(slot_code="when", value_text="3월 4일", evidence_text="회의").slot_code + == "when" + ) + + +def test_project_mention_rejects_blank_names_or_out_of_range_confidence() -> None: + """Every project mention requires names and evidence plus bounded confidence.""" + from lineageweave.post_summary import ProjectMention + + with pytest.raises(ValueError, match="require names and evidence"): + ProjectMention(project_name=" ", canonical_name="c", evidence="e", confidence=0.5) + with pytest.raises(ValueError, match="require names and evidence"): + ProjectMention(project_name="p", canonical_name="c", evidence=" ", confidence=0.5) + with pytest.raises(ValueError, match="between 0 and 1"): + ProjectMention(project_name="p", canonical_name="c", evidence="e", confidence=2.0) + + +def test_key_event_rejects_blank_text_or_explicitly_empty_project_key() -> None: + """Key events require text and reject an empty project key.""" + from lineageweave.post_summary import KeyEvent + + with pytest.raises(ValueError, match="require event text"): + KeyEvent(event_text=" ") + with pytest.raises(ValueError, match="must be non-empty"): + KeyEvent(event_text="event", project_key=" ") + + +def test_hallucinated_account_name_detects_a_matching_context_hint() -> None: + """A context hint naming the account selects it as a hallucination guard.""" + from lineageweave.post_summary import _hallucinated_account_name + + assert ( + _hallucinated_account_name( + "author_account_name=Demo Analyst [source_field=user_account.display_name]" + ) + == "Demo Analyst" + ) + assert _hallucinated_account_name("") is None + assert _hallucinated_account_name("no account hint here") is None + + +def test_plain_details_accepts_three_column_role_rows() -> None: + """A 3-column role row defaults the actor type to person.""" + from lineageweave.post_summary import _parse_plain_summary_details + + details = _parse_plain_summary_details( + "ROLES:\n홍길동 | 자료 검토 | 당사\n" + "PROJECTS:\nNONE\n" + "EVIDENCE:\nNONE", + context_hints="author_account_name=Demo Analyst [source_field=user_account.display_name]", + ) + assert details is not None + assert details[0][0].actor_type_code == "prov_person" + assert details[0][0].affiliated_organization_name == "당사" + + +def test_plain_details_drops_template_echo_rows_and_hallucinated_actor() -> None: + """Prompt-template echoes and the logged-in account are never actors.""" + from lineageweave.post_summary import _parse_plain_summary_details + + details = _parse_plain_summary_details( + "ROLES:\n" + "actor name | responsibility | person, organization, or team | affiliation or none\n" + "Demo Analyst | 고객 면담 | person | Demo Corp\n" + "Jordi Gil | 견적 승인 | person | Northwind Labs\n" + "PROJECTS:\nNONE", + context_hints="author_account_name=Demo Analyst [source_field=user_account.display_name]; " + "author_affiliations=Demo Corp [source_field=account_affiliation.corporate_entity_id]", + ) + assert details is not None + assert [role.actor_name for role in details[0]] == ["Jordi Gil"] + + +def test_plain_details_skips_role_rows_with_unknown_actor_column() -> None: + """A row whose actor column isn't person/organization/team is dropped.""" + from lineageweave.post_summary import _parse_plain_summary_details + + details = _parse_plain_summary_details( + "ROLES:\n" + "Q&A participant | 발표 듣기 | some-description | Acme\n" + "PROJECTS:\nNONE" + ) + assert details is not None + assert details[0] == () + + +def test_plain_details_project_uses_post_title_as_evidence_when_empty() -> None: + """A project whose evidence column is empty falls back to the post title.""" + from lineageweave.post_summary import _parse_plain_summary_details + + details = _parse_plain_summary_details( + "ROLES:\nNONE\n" + "PROJECTS:\nHVDC Pilot | hvdc-pilot | none | 0.9", + post_title="HVDC Pilot 견적 검토", + ) + assert details is not None + assert details[1][0].evidence == "HVDC Pilot 견적 검토" + + +def test_plain_details_drops_untitled_and_unparsable_project_rows() -> None: + """Projects whose empty evidence cannot borrow a title are dropped.""" + from lineageweave.post_summary import _parse_plain_summary_details + + details = _parse_plain_summary_details( + "ROLES:\nNONE\n" + "PROJECTS:\nUnknown Project | unknown-project | NONE | not-a-number\n" + "Mystery | mystery | NONE | 0.5", + post_title="", + ) + assert details is not None + assert details[1] == () + + +def test_plain_details_five_column_actions_recognize_actors_and_fallback() -> None: + """5-column actions use actor columns; unrecognized rows fall back to legacy.""" + from lineageweave.post_summary import _parse_plain_summary_details + + details = _parse_plain_summary_details( + "ROLES:\n" + "홍길동 | 변경 요청 | person | 당사\n" + "김철수 | 도면 수정 | person | 고객사\n" + "PROJECTS:\nNONE\n" + "ACTIONS:\n" + "도면 변경 승인 | hvdc-pilot | 홍길동 | 김철수 | 근거 문장\n" + "드롭 될 행 | x | NotAnActor | NothingInteresting | bad" + ) + assert details is not None + assert [action.action_text for action in details[2]] == [ + "도면 변경 승인", + "드롭 될 행", + ] + assert details[2][0].project_key == "hvdc-pilot" + assert details[2][0].requester_actor_name == "홍길동" + assert details[2][0].processor_actor_name == "김철수" + assert details[2][1].project_key is None + + +def test_plain_details_requires_roles_and_projects_sections() -> None: + """Missing ROLES/PROJECTS sections fail the whole parse.""" + from lineageweave.post_summary import _parse_plain_summary_details + + assert _parse_plain_summary_details("ROLES:\nNONE") is None + assert _parse_plain_summary_details("EVIDENCE:\nwhere | x | y") is None + assert _parse_plain_summary_details("") is None