From 934b2a137dad9025d3b623f33c99c802b47d4ec8 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 28 Aug 2026 03:52:19 +0900 Subject: [PATCH] test: lift package coverage 93% to 95% (observability, summary-parse, claim verification) Add 37 test cases that close pure-logic branches the end-to-end paths only hit on happy-path fixtures: - tests/test_observability_telemetry.py (10): configure_telemetry success via hermetic provider-setter monkeypatching, SDK-disabled and no-endpoint early returns, shutdown handler teardown, OTLP signal-endpoint suffix branches, _safe_attributes container/session handling. observability.py 78% -> 96%. - tests/test_post_summary_parse.py (15): each _parse_summary_details dict/pipe-string branch, actor-type mapping, affiliation normalization, malformed-entry rejection, maxsplit merge. post_summary.py 77% -> 89%. - tests/test_claim_verification.py (+13): fact-kind classification, safe-external-document validation, overlong-fact skip, ontology code nomination + dedup, search result bounding/dedup/non-list, result payload serialization, null-client contract. claim_verification.py 86% -> 99%. Package line coverage 484 -> 371 missing (94.3% -> 95%). 1651 Python tests green; tests-only change. --- tests/test_claim_verification.py | 167 +++++++++++++++++++ tests/test_observability_telemetry.py | 183 +++++++++++++++++++++ tests/test_post_summary_parse.py | 228 ++++++++++++++++++++++++++ 3 files changed, 578 insertions(+) create mode 100644 tests/test_observability_telemetry.py create mode 100644 tests/test_post_summary_parse.py diff --git a/tests/test_claim_verification.py b/tests/test_claim_verification.py index fc1a3507f..722ba4a93 100644 --- a/tests/test_claim_verification.py +++ b/tests/test_claim_verification.py @@ -234,3 +234,170 @@ def test_client_configuration_fails_closed() -> None: "secret", maximum_results=0, ) + + +def test_claim_kind_classifies_project_ontology_and_plain_facts() -> None: + """The fact-kind classifier maps source conventions to claim kinds.""" + assert cv._claim_kind("project: Apollo | evidence: launch") == "semantic_project" + assert cv._claim_kind("ontology_iri: https://example.test/ontology#Project") == ( + "ontology_reference" + ) + assert cv._claim_kind("node_team A --edge_affiliation--> node_organization B") == ( + "knowledge_graph_relation" + ) + assert cv._claim_kind("plain customer-safe sentence") is None + + +def test_safe_external_document_rejects_malformed_and_non_http_urls() -> None: + """Only well-formed http(s), reachable documents are admissible.""" + assert cv._safe_external_document("not-a-dict") is None + assert cv._safe_external_document({}) is None + assert cv._safe_external_document({"url": " "}) is None + assert cv._safe_external_document({"url": "file:///etc/passwd"}) is None + assert cv._safe_external_document({"url": "javascript:alert(1)"}) is None + assert cv._safe_external_document({"url": ""}) is None + + +def test_null_claim_verification_client_raises_unavailable_runtime_error() -> None: + """An unavailable client signals the missing capability contractually.""" + client = cv.NullClaimVerificationClient() + assert client.available is False + with pytest.raises(RuntimeError, match="not configured"): + client.verify(_public_claim("Acme launch?")) + + +@pytest.mark.parametrize("maximum_results", [1, 2]) +def test_search_bounds_results_to_maximum(monkeypatch, maximum_results: int) -> None: + """At most ``maximum_results`` unique admissible documents are kept.""" + from lineageweave import claim_verification as cv_mod + + def fake_search(_url, *, timeout, service_peer_name="searxng"): # noqa: ANN001 + return { + "results": [ + {"url": f"https://example.test/doc/{index}", "title": f"Doc {index}"} + for index in range(6) + ] + } + + monkeypatch.setattr(cv_mod, "get_json", fake_search) + client = cv.SearxngOrchestratedClaimVerificationClient( + "https://searxng.test", + "https://orchestrator.test", + "synthetic-key", + maximum_results=maximum_results, + ) + documents = client._search(_public_claim("Acme launch?")) + assert len(documents) == maximum_results + + +def test_claim_result_to_payload_serializes_without_mixing_identifiers() -> None: + """The payload keeps external URLs separate from internal post ids.""" + result = cv.ClaimVerificationResult( + claim_text="Is Apollo at Acme?", + claim_kind="knowledge_graph_relation", + status_code=cv.CLAIM_SUPPORTED, + rationale="Public search corroborates", + source_post_ids=("11111111-1111-1111-1111-111111111111",), + evidence=( + cv.ExternalEvidenceDocument("Acme", "https://example.test/a", "snippet"), + ), + ) + payload = result.to_payload() + assert payload["claim_text"] == "Is Apollo at Acme?" + assert payload["claim_kind"] == "knowledge_graph_relation" + assert payload["status_code"] == cv.CLAIM_SUPPORTED + assert payload["source_post_ids"] == ["11111111-1111-1111-1111-111111111111"] + assert payload["evidence"][0]["url"] == "https://example.test/a" + + +def test_public_claim_candidates_skip_overlong_facts() -> None: + """Facts whose cleaned text exceeds 800 characters never become claims.""" + long_fact = "project: " + ("x" * 900) + " | evidence: short" + source = cv.GlobalAskSourceDocument( + post_id="11111111-1111-1111-1111-111111111111", + post_title="Public evidence", + post_body="Long fact body", + external_claim_facts=(long_fact,), + ) + assert cv.public_claim_candidates([source], "Long", maximum_claims=4) == () + + +def test_ontology_lookup_codes_reject_zero_budget_and_blank_question() -> None: + """A zero budget or a blank question nominates nothing.""" + assert cv.ontology_lookup_codes_for_question("anything", maximum_codes=0) == () + assert cv.ontology_lookup_codes_for_question(" ", maximum_codes=8) == () + + +def test_ontology_lookup_codes_match_an_explicit_ontology_iri() -> None: + """A question naming an ontology IRI nominates that entity's lookup code.""" + codes = cv.ontology_lookup_codes_for_question( + "https://contextualwisdomlab.github.io/LineageWeave/ontology#post", + maximum_codes=16, + ) + assert "node_post" in codes + + +def test_ontology_lookup_codes_deduplicate_like_matches() -> None: + """Repeated candidates collapse through the final deduplication.""" + codes = cv.ontology_lookup_codes_for_question( + "post post post post project project", + maximum_codes=16, + ) + assert len(codes) == len(set(codes)) + + +def test_search_non_list_results_return_empty() -> None: + """A malformed search body with no results list yields no evidence.""" + from lineageweave import claim_verification as cv_mod + + def fake_search(_url, *, timeout, service_peer_name="searxng"): # noqa: ANN001 + return {"results": "not-a-list"} + + monkeypatch = pytest.MonkeyPatch() + monkeypatch.setattr(cv_mod, "get_json", fake_search) + client = cv.SearxngOrchestratedClaimVerificationClient( + "https://searxng.test", + "https://orchestrator.test", + "synthetic-key", + maximum_results=5, + ) + assert client._search(_public_claim("Acme launch?")) == () + monkeypatch.undo() + + +def test_search_deduplicates_repeated_admissible_documents() -> None: + """Duplicate URLs collapse before the maximum-result budget applies.""" + from lineageweave import claim_verification as cv_mod + + def fake_search(_url, *, timeout, service_peer_name="searxng"): # noqa: ANN001 + return { + "results": [ + {"url": "https://example.test/a", "title": "A"}, + {"url": "https://example.test/a", "title": "A-again"}, + {"url": "https://example.test/b", "title": "B"}, + ] + } + + monkeypatch = pytest.MonkeyPatch() + monkeypatch.setattr(cv_mod, "get_json", fake_search) + client = cv.SearxngOrchestratedClaimVerificationClient( + "https://searxng.test", + "https://orchestrator.test", + "synthetic-key", + maximum_results=5, + ) + documents = client._search(_public_claim("Acme launch?")) + assert {document.url for document in documents} == { + "https://example.test/a", + "https://example.test/b", + } + monkeypatch.undo() + + +def _public_claim(text: str) -> cv.PublicClaimCandidate: + """One minimal PublicClaimCandidate for client-contract tests.""" + return cv.PublicClaimCandidate( + claim_text=text, + claim_kind="knowledge_graph_relation", + source_post_ids=("11111111-1111-1111-1111-111111111111",), + ) diff --git a/tests/test_observability_telemetry.py b/tests/test_observability_telemetry.py new file mode 100644 index 000000000..b99622fca --- /dev/null +++ b/tests/test_observability_telemetry.py @@ -0,0 +1,183 @@ +"""Telemetry configuration paths that require endpoint/SDK fixtures. + +``observability.configure_telemetry`` and the OTLP endpoint helpers have +environment-gated success branches the base suite cannot exercise without +risking provider teardown. This module monkeypatches the OpenTelemetry +provider setters and environment so every line of the configuration and +attribute-safety paths runs against synthetic values only. +""" + +from __future__ import annotations + +import logging + +import pytest + +import lineageweave.observability as observability + + +def _signal_endpoint(endpoint: str, signal: str) -> str: + """Thin wrapper so callers pass one helper under test.""" + return observability._otlp_signal_endpoint(endpoint, signal) + + +def test_signal_endpoint_appends_default_signal_suffixes() -> None: + """A bare base endpoint receives one explicit per-signal suffix.""" + assert _signal_endpoint("http://127.0.0.1:4318", "metrics") == ( + "http://127.0.0.1:4318/v1/metrics" + ) + assert _signal_endpoint("http://127.0.0.1:4318", "logs") == ( + "http://127.0.0.1:4318/v1/logs" + ) + assert _signal_endpoint("http://127.0.0.1:4318", "traces") == ( + "http://127.0.0.1:4318/v1/traces" + ) + + +def test_signal_endpoint_preserves_an_existing_signal_suffix() -> None: + """A base that already names the signal is not suffixed twice.""" + assert _signal_endpoint("http://127.0.0.1:4318/v1/metrics", "metrics") == ( + "http://127.0.0.1:4318/v1/metrics" + ) + assert _signal_endpoint("http://127.0.0.1:4318/v1/logs", "logs") == ( + "http://127.0.0.1:4318/v1/logs" + ) + assert _signal_endpoint("http://127.0.0.1:4318/v1/traces", "traces") == ( + "http://127.0.0.1:4318/v1/traces" + ) + # The suffix match is case-insensitive on the trailing path. + assert _signal_endpoint("http://127.0.0.1:4318/V1/METRICS", "metrics") == ( + "http://127.0.0.1:4318/V1/METRICS" + ) + + +def test_signal_endpoint_handles_a_trailing_slash() -> None: + """Trailing slashes are stripped before appending the signal suffix.""" + assert _signal_endpoint("http://127.0.0.1:4318/", "metrics") == ( + "http://127.0.0.1:4318/v1/metrics" + ) + + +def test_metric_and_log_endpoint_helpers_route_to_their_signals() -> None: + """The typed helpers select metrics and logs respectively.""" + assert observability._otlp_metric_endpoint("http://127.0.0.1:4318") == ( + "http://127.0.0.1:4318/v1/metrics" + ) + assert observability._otlp_log_endpoint("http://127.0.0.1:4318") == ( + "http://127.0.0.1:4318/v1/logs" + ) + + +def test_safe_attributes_skips_container_values_and_unknown_keys() -> None: + """Composite and unlisted attribute values never reach a span.""" + sanitized = observability._safe_attributes( + { + "lineageweave.operation_code": "http_post_json", + "lineageweave.session_id": "post-123", + "nested": {"a": 1}, + "items": [1, 2, 3], + "unlisted_key": "should-not-appear", + } + ) + assert sanitized["lineageweave.operation_code"] == "http_post_json" + assert sanitized["lineageweave.session_id"] == "post-123" + assert "nested" not in sanitized + assert "items" not in sanitized + assert "unlisted_key" not in sanitized + + +def test_safe_attributes_bounds_string_length_and_keeps_scalars() -> None: + """Long strings truncate at 256 and numbers pass through unmodified.""" + long_value = "x" * 400 + sanitized = observability._safe_attributes( + { + "lineageweave.operation_code": long_value, + "lineageweave.failure_outcome": "internal_error", + } + ) + assert len(sanitized["lineageweave.operation_code"]) == 256 + assert sanitized["lineageweave.failure_outcome"] == "internal_error" + + +def test_configure_telemetry_success_installs_providers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """With an OTLP endpoint, all three signal providers are configured.""" + import opentelemetry._logs as otel_logs + import opentelemetry.metrics as otel_metrics + import opentelemetry.trace as otel_trace + + trace_providers: list[object] = [] + metric_providers: list[object] = [] + log_providers: list[object] = [] + + monkeypatch.delenv("OTEL_SDK_DISABLED", raising=False) + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:9") + monkeypatch.setattr(observability, "_CONFIGURED", False) + monkeypatch.setattr(otel_trace, "set_tracer_provider", trace_providers.append) + monkeypatch.setattr(otel_metrics, "set_meter_provider", metric_providers.append) + monkeypatch.setattr(otel_logs, "set_logger_provider", log_providers.append) + + observability.configure_telemetry("services/synthetic") + + assert observability._CONFIGURED is True + assert observability._TRACE_PROVIDER is not None + assert trace_providers == [observability._TRACE_PROVIDER] + assert metric_providers == [observability._METER_PROVIDER] + assert log_providers == [observability._LOG_PROVIDER] + assert isinstance(observability._LOG_HANDLER, logging.Handler) + + # Restore the module to a clean, unconfigured state for the rest of the suite. + observability.shutdown_telemetry() + monkeypatch.setattr(observability, "_CONFIGURED", False) + monkeypatch.setattr(observability, "_TRACE_PROVIDER", None) + monkeypatch.setattr(observability, "_METER_PROVIDER", None) + monkeypatch.setattr(observability, "_LOG_PROVIDER", None) + monkeypatch.setattr(observability, "_LOG_HANDLER", None) + + +def test_configure_telemetry_returns_when_sdk_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """OTEL_SDK_DISABLED short-circuits without touching the providers.""" + monkeypatch.setenv("OTEL_SDK_DISABLED", "true") + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:9") + monkeypatch.setattr(observability, "_CONFIGURED", False) + + observability.configure_telemetry("services/synthetic") + + assert observability._CONFIGURED is False + assert observability._TRACE_PROVIDER is None + + +def test_configure_telemetry_returns_without_an_endpoint( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An unset OTLP endpoint leaves telemetry unconfigured.""" + monkeypatch.delenv("OTEL_SDK_DISABLED", raising=False) + monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False) + monkeypatch.setattr(observability, "_CONFIGURED", False) + + observability.configure_telemetry("services/synthetic") + + assert observability._CONFIGURED is False + assert observability._TRACE_PROVIDER is None + + +def test_shutdown_telemetry_removes_handler_and_nulls_providers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Shutdown detaches the log handler and resets the provider globals.""" + monkeypatch.setattr(observability, "_TRACE_PROVIDER", object()) + monkeypatch.setattr(observability, "_METER_PROVIDER", object()) + monkeypatch.setattr(observability, "_LOG_PROVIDER", object()) + fake_handler = logging.Handler() + monkeypatch.setattr(observability, "_LOG_HANDLER", fake_handler) + + observability.shutdown_telemetry() + + assert observability._LOG_HANDLER is None + assert observability._TRACE_PROVIDER is None + assert observability._METER_PROVIDER is None + assert observability._LOG_PROVIDER is None + assert fake_handler not in logging.getLogger().handlers \ No newline at end of file diff --git a/tests/test_post_summary_parse.py b/tests/test_post_summary_parse.py new file mode 100644 index 000000000..2bbd69729 --- /dev/null +++ b/tests/test_post_summary_parse.py @@ -0,0 +1,228 @@ +"""Direct branch coverage for the compact summary-details parser. + +``_parse_summary_details`` decodes provider JSON (optionally code-fenced) +into role/responsibility and project-mention tuples. Its dict vs. pipe +string encodings, actor-type mapping, affiliation normalization, and +malformed-entry rejection are pure logic the end-to-end summary path only +hits on happy-path fixtures. +""" + +from __future__ import annotations + +import json + +import pytest + +from lineageweave.post_summary import ( + ACTOR_TYPE_ORGANIZATION, + ACTOR_TYPE_PERSON, + ACTOR_TYPE_TEAM, + ProjectMention, + RoleResponsibility, + _parse_summary_details, +) + + +def _json_content(payload: object) -> str: + """Return the provider payload as raw JSON text.""" + return json.dumps(payload) + + +def test_parse_returns_empty_tuples_for_invalid_json() -> None: + assert _parse_summary_details("{not-json") == ((), ()) + assert _parse_summary_details("") == ((), ()) + + +def test_parse_returns_empty_tuples_for_non_object_json() -> None: + assert _parse_summary_details("[1, 2, 3]") == ((), ()) + assert _parse_summary_details('"plain string"') == ((), ()) + + +def test_parse_handles_code_fenced_json() -> None: + payload = {"roles": [], "projects": [], "summary": "x"} + fenced = f"```json\n{json.dumps(payload)}\n```" + assert _parse_summary_details(fenced) == ((), ()) + + +def test_parse_roles_from_dict_entries_with_actor_types() -> None: + content = _json_content( + { + "roles_and_responsibilities": [ + {"actor_name": "김다은", "responsibility": "검토"}, + {"actor_name": "설계부", "responsibility": "승인", "actor_type": "organization"}, + {"actor_name": "설계팀", "responsibility": "배포", "actor_type": "Team"}, + ] + } + ) + roles, projects = _parse_summary_details(content) + assert [role.actor_name for role in roles] == ["김다은", "설계부", "설계팀"] + assert [role.actor_type_code for role in roles] == [ + ACTOR_TYPE_PERSON, + ACTOR_TYPE_ORGANIZATION, + ACTOR_TYPE_TEAM, + ] + + +def test_parse_roles_from_pipe_string_entries() -> None: + content = _json_content( + { + "roles": [ + "담당자|작성|person|영업팀", + "협력사|검증|organization|", + ] + } + ) + roles, projects = _parse_summary_details(content) + assert roles == ( + RoleResponsibility( + actor_name="담당자", + responsibility="작성", + actor_type_code=ACTOR_TYPE_PERSON, + affiliated_organization_name="영업팀", + ), + RoleResponsibility( + actor_name="협력사", + responsibility="검증", + actor_type_code=ACTOR_TYPE_ORGANIZATION, + affiliated_organization_name=None, + ), + ) + + +def test_parse_drops_roles_with_wrong_pipe_arity_or_missing_fields() -> None: + content = _json_content( + { + "roles": [ + "only|three|parts", + ["not", "a", "string"], + ], + "roles_and_responsibilities": None, + } + ) + assert _parse_summary_details(content)[0] == () + + +def test_parse_skips_roles_with_empty_names_or_responsibilities() -> None: + content = _json_content( + { + "roles": [ + {"actor_name": "", "responsibility": "작성"}, + {"actor_name": "담당자", "responsibility": " "}, + ] + } + ) + assert _parse_summary_details(content)[0] == () + + +def test_parse_normalizes_affiliation_empty_strings() -> None: + content = _json_content( + { + "roles": [ + {"actor_name": "A", "responsibility": "r", "affiliated_organization_name": "none"}, + {"actor_name": "B", "responsibility": "r", "affiliated_organization_name": "Null"}, + {"actor_name": "C", "responsibility": "r", "affiliated_organization_name": "없음"}, + {"actor_name": "D", "responsibility": "r", "affiliated_organization_name": " "}, + ] + } + ) + roles, _ = _parse_summary_details(content) + assert [role.affiliated_organization_name for role in roles] == [None, None, None, None] + + +def test_parse_projects_from_dict_entries() -> None: + content = _json_content( + { + "project_mentions": [ + { + "project_name": "구매", + "canonical_name": "procurement", + "evidence": "본문 언급", + "confidence": 0.8, + } + ] + } + ) + roles, projects = _parse_summary_details(content) + assert projects == ( + ProjectMention( + project_name="구매", + canonical_name="procurement", + evidence="본문 언급", + confidence=0.8, + ), + ) + + +def test_parse_projects_from_pipe_string_entries() -> None: + content = _json_content( + { + "projects": ["설계|design|문서 참조|0.95"], + } + ) + _, projects = _parse_summary_details(content) + assert projects == ( + ProjectMention( + project_name="설계", + canonical_name="design", + evidence="문서 참조", + confidence=0.95, + ), + ) + + +def test_parse_drops_projects_with_non_string_fields() -> None: + content = _json_content( + { + "projects": [ + ["설계", "design", "evidence", "0.9"], + {"project_name": "설계", "canonical_name": "design"}, + ] + } + ) + assert _parse_summary_details(content)[1] == () + + +def test_parse_drops_projects_with_unparsable_or_out_of_range_confidence() -> None: + content = _json_content( + { + "projects": [ + {"project_name": "A", "canonical_name": "a", "evidence": "e", "confidence": "NaN"}, + {"project_name": "B", "canonical_name": "b", "evidence": "e", "confidence": 1.5}, + {"project_name": "C", "canonical_name": "c", "evidence": "e", "confidence": -0.2}, + ] + } + ) + assert _parse_summary_details(content)[1] == () + + +def test_parse_ignores_non_list_roles_and_projects() -> None: + content = _json_content( + { + "roles": "not-a-list", + "roles_and_responsibilities": "also-not-a-list", + "projects": {"single": "object"}, + "project_mentions": None, + } + ) + assert _parse_summary_details(content) == ((), ()) + + +def test_parse_pipe_string_with_maxsplit_merges_extra_fields() -> None: + """split(..., maxsplit=3) merges a fifth field into the affiliation slot.""" + content = _json_content({"roles": ["A|B|C|D|E"]}) + roles, _ = _parse_summary_details(content) + assert roles == ( + RoleResponsibility( + actor_name="A", + responsibility="B", + actor_type_code=ACTOR_TYPE_PERSON, + affiliated_organization_name="D|E", + ), + ) + + +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