Files
FabledScribe/tests/test_knowledge_facets.py
T
bvandeusenandClaude Opus 5 d127d48c14
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 42s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / Python tests (push) Successful in 1m37s
CI & Build / Build & push image (push) Successful in 26s
fix(tests): the browse vocabulary guard names the fourth kind (#3729)
CI 7016. `test_non_task_facets_are_the_note_types_and_only_those` pins the
non-task vocabulary as a literal set, so adding `lesson` to `_FACETS` turned
it red — the guard working, not breaking. It stays a literal: derived from
_FACETS it would assert nothing, and rule 167 wants a guard that can fail.

The representative corpus had no lesson row, so `lesson` was reaching only
the two tests that iterate FACET_TYPES and never the one that asserts each
facet selects EXACTLY its own rows. It has one now, which is what pins the
half that matters: a lesson is not picked up by the Notes facet despite
both being non-task records.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
2026-09-18 15:34:17 -04:00

127 lines
5.0 KiB
Python

"""The type facet, in both of its dialects.
The facet predicate is written twice by necessity — as SQL for rows the
database hands back, and as Python for candidates the vector search has
already fetched — plus a third time as the `is_task` pre-filter that narrows
the candidate set before it exists. Those three used to be hand-written and
agreed only by luck: adding `issue` to the SQL arm alone would have set the
pre-filter to is_task=False, handed the Python arm a candidate set with no
tasks in it, and returned an empty semantic half for the Issues facet forever
with nothing red anywhere (#3128).
They are now generated from one table. These tests pin the property that made
the trap possible, so it cannot come back by a different route.
"""
import pytest
from scribe.services.knowledge import (
_FACETS,
FACET_TYPES,
NON_TASK_FACETS,
_apply_type_filter,
facet_is_task,
matches_facet,
)
from tests.helpers import compiled_sql, fake_note, fake_snippet, fake_task
def _sql(note_type):
from sqlalchemy import select
from scribe.models.note import Note
return compiled_sql(_apply_type_filter(select(Note.id), note_type))
# One representative row per shape the corpus actually holds.
ROWS = {
"plain note": fake_note(note_type="note"),
"process": fake_note(note_type="process"),
"snippet": fake_snippet(),
"lesson": fake_note(note_type="lesson"),
"work task": fake_task(task_kind="work", note_type="note"),
"issue": fake_task(task_kind="issue", note_type="note"),
"spike": fake_task(task_kind="spike", note_type="note"),
"legacy plan": fake_task(task_kind="plan", note_type="note"),
}
@pytest.mark.parametrize("facet", sorted(FACET_TYPES))
def test_pre_filter_never_excludes_a_row_the_facet_wants(facet):
"""THE regression. `facet_is_task` narrows the semantic candidate set before
`matches_facet` ever sees it, so a pre-filter that disagrees with the
predicate doesn't return wrong rows — it returns NO rows, silently, on one
half of a hybrid search."""
want = facet_is_task(facet)
for label, row in ROWS.items():
if matches_facet(row, facet):
assert want is None or want == row.is_task, (
f"facet {facet!r} accepts the {label} row, but its pre-filter "
f"asks for is_task={want} while the row has is_task={row.is_task} "
f"— the semantic arm would never be handed this row"
)
@pytest.mark.parametrize(
"facet,expected",
[
("task", {"work task", "issue", "spike", "legacy plan"}),
("issue", {"issue"}),
("spike", {"spike"}),
("work", {"work task"}),
("plan", {"legacy plan"}),
("note", {"plain note"}),
("process", {"process"}),
("snippet", {"snippet"}),
("lesson", {"lesson"}),
("", set(ROWS)),
],
)
def test_each_facet_selects_exactly_its_own_rows(facet, expected):
assert {k for k, row in ROWS.items() if matches_facet(row, facet)} == expected
def test_a_plain_note_is_not_selected_by_its_own_type_when_it_is_a_task():
"""A task's `note_type` is 'note' — that column says nothing about task-ness.
The hand-written Python arm omitted the `status IS NULL` half its SQL twin
carried, so it only avoided returning every task under the Notes facet
because the pre-filter had already dropped them."""
assert matches_facet(fake_task(note_type="note"), "note") is False
def test_an_unknown_facet_matches_nothing_rather_than_everything():
"""A typo must return an empty list, never the whole corpus."""
assert all(not matches_facet(row, "wrok") for row in ROWS.values())
assert "notes.status IS NULL" in _sql("wrok")
def test_the_live_task_kinds_are_all_facets():
"""`issue` shipped in 0065 and `spike` in 0091; the browse vocabulary went
three kinds without noticing either."""
for kind in ("work", "issue", "spike"):
assert kind in FACET_TYPES and _FACETS[kind][0] is True
def test_non_task_facets_are_the_note_types_and_only_those():
"""Spelled out rather than derived, so adding a kind to `_FACETS` has to
be a deliberate edit in two places. `lesson` joined in milestone 385 step
2 (#3729) and is non-task on purpose: `is_task` IS `status is not None`,
so a lesson that acquired a status would stop being a lesson."""
assert set(NON_TASK_FACETS) == {"note", "process", "snippet", "lesson"}
@pytest.mark.parametrize("facet", sorted(FACET_TYPES))
def test_sql_arm_constrains_the_axis_the_facet_lives_on(facet):
"""The SQL dialect of the same table. A task facet must pin `status` (and,
for a single kind, `task_kind`); a record-type facet must pin `note_type`
AND exclude tasks."""
sql = _sql(facet)
is_task, value = _FACETS[facet]
assert "notes.deleted_at IS NULL" in sql
if is_task:
assert "notes.status IS NOT NULL" in sql
assert (f"notes.task_kind = '{value}'" in sql) is (value is not None)
else:
assert "notes.status IS NULL" in sql
assert f"notes.note_type = '{value}'" in sql