"""Unit: source name/label/tags helpers + the D2 matching rule (phase 191, task 01). The pure half (``normalize_tags``, ``question_name_hits``) needs no DB; the session helpers run against the local compose Postgres (``podman compose up -d db``) — the house DB-test pattern of ``test_sources_meta.py``: the helpers are thin session wrappers whose contract (upsert, partial-update shape, flush-not-commit, absent-row ``None``/``False``) only holds against a real database. Skips with clear instructions when the stack is not up. The D2 rule is the heart of the phase — every branch is pinned: the name/label/tag hit classes, the case-insensitive ``_normalize_name`` form (the phase-162 accent fold included), the 4-char minimum on BOTH sides, the NO digit-class rule ("gitea" hits digitless), ``None`` → ``False``, and the no-overlap baseline. A fixture truncates ``source_labels`` before and after every test so the suite leaves the dev DB exactly as migration 0030 left it (empty). """ from __future__ import annotations import pytest from sqlalchemy import text from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.db import SessionLocal from app.rag.source_labels import ( TAGS_MAX_COUNT, SourceLabelView, all_labels, delete_label, get_label, label_for, normalize_tags, question_name_hit_sources, question_name_hits, set_label, ) @pytest.fixture(autouse=True) def clean_source_labels(db: Session): """Empty ``source_labels`` before and after every test (the migration-0030 state).""" db.execute(text("DELETE FROM source_labels")) db.commit() yield db.execute(text("DELETE FROM source_labels")) db.commit() # --------------------------------------------------------------------------- # The model round-trip + the upsert / partial-update shape (D1) # --------------------------------------------------------------------------- def test_set_label_creates_row_with_stamp(db: Session) -> None: """All three fields set: the row is created verbatim and ``updated_at`` carries the server stamp (the house style).""" row = set_label( db, "gitea-forge", display_name="the git server", label="self-hosted forge + Gitea", tags=["gitea", "forge"], ) db.commit() assert row.source == "gitea-forge" stored = get_label(db, "gitea-forge") assert stored is not None assert stored.display_name == "the git server" assert stored.label == "self-hosted forge + Gitea" assert stored.tags == ["gitea", "forge"] # stored exactly as normalized assert stored.updated_at is not None, "updated_at must be server-stamped" def test_set_label_defaults_omitted_fields(db: Session) -> None: """A brand-new row created with only ``display_name`` carries the D1 defaults: ``label`` NULL and ``tags`` ``[]`` (NOT NULL column).""" set_label(db, "uploads", display_name="the uploads") db.commit() stored = get_label(db, "uploads") assert stored is not None assert stored.display_name == "the uploads" assert stored.label is None assert stored.tags == [] def test_partial_update_omitted_field_untouched(db: Session) -> None: """The partial-update shape: an OMITTED field leaves the stored value untouched (the ``_UNSET`` sentinel owns the difference).""" set_label( db, "homelab", display_name="the lab", label="first", tags=["homelab"] ) db.commit() set_label(db, "homelab", label="second") db.commit() stored = get_label(db, "homelab") assert stored is not None assert stored.label == "second", "the passed field overwrites" assert stored.display_name == "the lab", "the omitted field is untouched" assert stored.tags == ["homelab"], "the omitted tags are untouched" def test_partial_update_explicit_none_clears(db: Session) -> None: """An explicit ``None`` CLEARS the field (the D5 shape); ``tags`` clears to ``[]`` — the column stays NOT NULL.""" set_label( db, "homelab", display_name="the lab", label="first", tags=["homelab"] ) db.commit() set_label(db, "homelab", display_name=None, label=None, tags=None) db.commit() stored = get_label(db, "homelab") assert stored is not None assert stored.display_name is None assert stored.label is None assert stored.tags == [] def test_set_label_flushes_without_committing(db: Session) -> None: """Flush-only (the phase-53 session convention): a rolled-back session rolls the upsert back with it.""" set_label(db, "homelab", display_name="the lab") db.rollback() other = SessionLocal() try: assert get_label(other, "homelab") is None, "the uncommitted row must roll back" finally: other.close() def test_source_is_the_sole_pk(db: Session) -> None: """One label row per source: the raw duplicate insert raises.""" db.execute(text("INSERT INTO source_labels (source) VALUES ('dup')")) db.commit() with pytest.raises(IntegrityError): db.execute(text("INSERT INTO source_labels (source) VALUES ('dup')")) db.rollback() def test_tags_server_default_is_empty_list(db: Session) -> None: """A row inserted without ``tags`` reads ``[]`` (the phase-89 ``ignore_paths`` server-default shape — no NULL ever surfaces).""" db.execute(text("INSERT INTO source_labels (source) VALUES ('nodefault')")) db.commit() stored = get_label(db, "nodefault") assert stored is not None assert stored.tags == [] # --------------------------------------------------------------------------- # The read side: label_for / all_labels / delete_label # --------------------------------------------------------------------------- def test_label_for_returns_frozen_view(db: Session) -> None: """``label_for`` maps the row to the frozen view; ``tags`` arrives as a tuple.""" set_label(db, "homelab", display_name="the lab", tags=["a", "b"]) db.commit() view = label_for(db, "homelab") assert view is not None assert view == SourceLabelView( source="homelab", display_name="the lab", label=None, tags=("a", "b") ) assert isinstance(view.tags, tuple) def test_label_for_absent_returns_none(db: Session) -> None: """No row → ``None`` — the "no label row = pre-phase" contract.""" assert label_for(db, "never-named") is None def test_all_labels_keyed_by_source(db: Session) -> None: """One query, every row, keyed by ``source`` (the views the boost and the API iterate).""" set_label(db, "alpha", display_name="the alpha") set_label(db, "beta", label="the beta row") db.commit() labels = all_labels(db) assert set(labels) == {"alpha", "beta"} assert labels["alpha"].display_name == "the alpha" assert labels["beta"].label == "the beta row" assert labels["alpha"].tags == () def test_all_labels_empty_table(db: Session) -> None: """The pre-phase state: an empty table reads as an empty mapping.""" assert all_labels(db) == {} def test_delete_label_removes_row(db: Session) -> None: """The reset path: the row is gone, the helper reports ``True``.""" set_label(db, "homelab", display_name="the lab") db.commit() assert delete_label(db, "homelab") is True db.commit() assert get_label(db, "homelab") is None def test_delete_label_absent_returns_false(db: Session) -> None: """An absent row is ``False`` — the API maps that to 404 (D5).""" assert delete_label(db, "never-named") is False def test_delete_label_flushes_without_committing(db: Session) -> None: """Flush-only both ways: a rolled-back delete keeps the row.""" set_label(db, "homelab", display_name="the lab") db.commit() assert delete_label(db, "homelab") is True db.rollback() other = SessionLocal() try: assert get_label(other, "homelab") is not None, "the rollback must restore" finally: other.close() # --------------------------------------------------------------------------- # normalize_tags (the API's 422 source, D5) # --------------------------------------------------------------------------- def test_normalize_tags_trims_and_drops_empties() -> None: """Entries are trimmed; entries that are empty afterwards dropped; ``[]`` is legal (it clears).""" assert normalize_tags([" gitea ", "", " ", "forge"]) == ["gitea", "forge"] assert normalize_tags([]) == [] assert normalize_tags(["", " "]) == [] def test_normalize_tags_dedupes_case_insensitively_first_case_wins() -> None: """Case-insensitive de-dupe preserving the FIRST case seen, order otherwise kept.""" assert normalize_tags(["Gitea", "gitea", "GITEA", "Forge"]) == ["Gitea", "Forge"] def test_normalize_tags_rejects_too_many_tags() -> None: """> 10 entries raise (counted on the trimmed non-empty list).""" with pytest.raises(ValueError): normalize_tags([f"tag{i}" for i in range(TAGS_MAX_COUNT + 1)]) # exactly 10 is legal assert len(normalize_tags([f"tag{i}" for i in range(TAGS_MAX_COUNT)])) == 10 def test_normalize_tags_rejects_overlong_tag() -> None: """A > 32-char entry raises (the cap is checked after the trim).""" with pytest.raises(ValueError): normalize_tags(["gitea", "x" * 33]) assert normalize_tags(["x" * 32]) == ["x" * 32] # exactly 32 is legal # --------------------------------------------------------------------------- # question_name_hits — the D2 rule (every branch pinned) # --------------------------------------------------------------------------- _VIEW = SourceLabelView( source="gitea-forge", display_name="the git server", label="self-hosted forge + Gitea", tags=("gitea", "forgejo"), ) def test_hit_on_display_name_token() -> None: """A question token equal to a display-name token hits — and the phase-119 digit-class rule does NOT apply ("server" is digitless).""" assert question_name_hits(_VIEW, "restart the SERVER properly") is True def test_hit_on_label_token() -> None: """A token of the ``label`` field hits (``+Gitea`` normalizes to ``gitea``).""" assert question_name_hits(_VIEW, "upgrade gitea today") is True def test_hit_on_tag_entry() -> None: """A tag entry hits even when neither name nor label mentions it.""" view = SourceLabelView( source="s", display_name=None, label=None, tags=("forgejo",) ) assert question_name_hits(view, "mirror forgejo repos") is True def test_hit_is_accent_folded_both_sides() -> None: """The phase-162 fold rides ``_normalize_name``: the accented source name answers to the ASCII question token (and vice versa).""" view = SourceLabelView( source="besta", display_name="BESTÅ", label=None, tags=() ) assert question_name_hits(view, "how tall is the besta shelf") is True assert question_name_hits(view, "BESTÅ shelf height") is True def test_digitless_name_hits_without_digit_rule() -> None: """The 2026-09-16 finding, name side: "gitea" must hit even though it carries no digit (the path digit rule is NOT reused here).""" view = SourceLabelView( source="gitea-forge", display_name="Gitea", label=None, tags=() ) assert question_name_hits(view, "How do I deploy gitea?") is True def test_short_source_side_token_never_matches() -> None: """The 4-char minimum applies to the SOURCE side: a 3-char tag can never match, even against an identical question token.""" view = SourceLabelView( source="ops", display_name="gke", label=None, tags=("gke",) ) assert question_name_hits(view, "set up gke today") is False def test_short_question_token_never_matches() -> None: """The phase-119 ``NAME_TOKEN_MIN_LEN`` side: a 3-char question token is no candidate against a matching source token.""" view = SourceLabelView( source="ops", display_name="Gke Cluster", label=None, tags=() ) assert question_name_hits(view, "gke deploy") is False def test_none_view_never_hits() -> None: """No label row → ``False`` — the byte-identical pre-phase contract.""" assert question_name_hits(None, "restart the git server") is False def test_question_without_candidates_never_hits() -> None: """A question with NO qualifying candidate (every token under the 4-char floor) short-circuits to ``False`` without touching the source side.""" assert question_name_hits(_VIEW, "ai ox up") is False def test_no_overlap_question_never_hits() -> None: """The baseline: a question sharing no normalized token with any field is not a hit.""" assert question_name_hits(_VIEW, "bake a birthday cake") is False def test_dotted_and_versioned_candidates_still_match() -> None: """The reused phase-119 candidate list: a dotted token (kept whole, ``llama.cpp`` → ``llamacpp``) matches the equal source token.""" view = SourceLabelView( source="infer", display_name="llama.cpp", label=None, tags=() ) assert question_name_hits(view, "tune llama.cpp flags") is True # --------------------------------------------------------------------------- # question_name_hit_sources — the per-turn entry point (one read) # --------------------------------------------------------------------------- def test_hit_sources_matches_per_row(db: Session) -> None: """The DB half: every labelled row is tested against the ONE ``all_labels`` read; only the named source comes back.""" set_label(db, "gitea-forge", display_name="the git server", tags=["gitea"]) set_label(db, "media", display_name="the media box", tags=["jellyfin"]) set_label(db, "plain") # a row with nothing set can never hit db.commit() assert question_name_hit_sources(db, "restart jellyfin tonight") == {"media"} assert question_name_hit_sources(db, "upgrade gitea") == {"gitea-forge"} assert question_name_hit_sources(db, "nothing relevant here") == set() def test_hit_sources_empty_table(db: Session) -> None: """Pre-phase state: no rows → the empty set (the boost never fires).""" assert question_name_hit_sources(db, "restart the git server") == set()