CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 33s
Spike #3128 found the storage sound and the retrieval vocabulary frozen before `issue` shipped (0065). Five things, in the order they had to land. **The mirror (rec 5, the data-integrity one).** `notes.data` is DERIVED from a snippet's body, but only `update_snippet` knew that. `update_note` is a hasattr loop with no snippet awareness, and both doors reach it — so PATCH /api/notes/<snippet_id> {body} rewrote the body and left the mirror behind. `snippet_fields` PREFERS the mirror, so the row went on reporting its old repo/path/symbol to the location reverse lookup and to prior-art recall while displaying its new body: surfaced with full authority, and wrong. `snippets.recompose_data` rebuilds it from the body, carrying `verification` and `provenance` (neither is in the body to parse). An explicit `data` still wins, so every snippet-service write is untouched. **One facet table (rec 3), before adding any facet.** The type predicate was written three times — SQL, Python over semantic candidates, and a ternary computing the `is_task` pre-filter — 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. `_FACETS` now generates all three. The Python arm also regains the `status IS NULL` half its SQL twin always had. **Issue and spike become facets (rec 2).** 435 issues — 17% of every task — were filterable nowhere on the human surface, while retired `plan` (90 rows) had a chip of its own. `_VALID_TYPES` was a hand-kept copy and is now derived. `plan` stays a valid facet for its legacy rows; it loses its chip. **Snippets stop being half-present in the feed (rec 4).** All 90 were in the All list, in no count, wearing an empty badge, and opening in the note editor. Counts now group by task_kind — every kind for the same two round-trips, which is why `issue` had no number — and total includes snippets, so the All chip matches the list it labels. Snippet cards route to /snippets/:id. **The prose that excused it (rec 6).** `snippet_fields` and the `data` column both still said pre-0070 rows were "never backfilled". True when 0070 landed, false since `backfill_snippet_data` shipped, and it read as licence for a stale mirror. Tests: the pre-filter can never exclude a row its own facet accepts (the regression, parameterised over every facet); both dialects select exactly their own rows; an unknown facet matches nothing; the mirror follows a body or title write, carries the verdict, and yields to an explicit `data`. `compiled_sql` moves to tests/helpers rather than becoming a third copy. Write-up: note #3161.
87 lines
3.1 KiB
Python
87 lines
3.1 KiB
Python
"""get_knowledge_counts — one number per facet, and an honest total.
|
|
|
|
Two grouped queries, one per typing axis. It used to be three: a grouped count
|
|
over note_type restricted to ("note", "process"), a scalar count of tasks, and
|
|
a second scalar just for plans. That shape is why `issue` had no number —
|
|
every kind needed a query of its own and nobody added one — and why the "All"
|
|
chip sat ~90 below the list it labelled, since snippets were in the feed but
|
|
in no count (#3128).
|
|
"""
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from tests.helpers import make_mock_session
|
|
|
|
|
|
def _grouped(rows):
|
|
r = MagicMock()
|
|
r.all.return_value = rows
|
|
return r
|
|
|
|
|
|
async def _counts(non_task_rows, kind_rows, **kwargs):
|
|
session = make_mock_session()
|
|
# 1) non-task rows grouped by note_type, 2) task rows grouped by task_kind
|
|
session.execute = AsyncMock(
|
|
side_effect=[_grouped(non_task_rows), _grouped(kind_rows)]
|
|
)
|
|
with patch("scribe.services.knowledge.async_session") as cls:
|
|
cls.return_value = session
|
|
from scribe.services.knowledge import get_knowledge_counts
|
|
return await get_knowledge_counts(user_id=1, **kwargs), session
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_every_facet_gets_a_number_including_the_kinds():
|
|
counts, _ = await _counts(
|
|
[("note", 395), ("process", 3), ("snippet", 90)],
|
|
[("work", 2104), ("issue", 435), ("spike", 1), ("plan", 90)],
|
|
)
|
|
assert counts["note"] == 395
|
|
assert counts["process"] == 3
|
|
assert counts["snippet"] == 90
|
|
assert counts["issue"] == 435
|
|
assert counts["spike"] == 1
|
|
assert counts["plan"] == 90
|
|
assert counts["work"] == 2104
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_task_is_the_sum_of_its_kinds():
|
|
"""`task` is not counted separately any more — it is what the kinds add up
|
|
to, so the two can't disagree."""
|
|
counts, _ = await _counts([], [("work", 2104), ("issue", 435), ("spike", 1)])
|
|
assert counts["task"] == 2540
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_total_counts_snippets_and_counts_no_task_twice():
|
|
"""The All chip labels a feed that contains every kind, so it has to count
|
|
every kind — and exactly once. Kinds are subsets of `task`; adding them
|
|
would count each issue a second time."""
|
|
counts, _ = await _counts(
|
|
[("note", 10), ("process", 2), ("snippet", 5)],
|
|
[("work", 20), ("issue", 4)],
|
|
)
|
|
assert counts["task"] == 24
|
|
assert counts["total"] == 10 + 2 + 5 + 24
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_absent_facets_report_zero_rather_than_missing():
|
|
counts, _ = await _counts([("note", 1)], [])
|
|
assert counts["note"] == 1
|
|
for key in ("process", "snippet", "task", "work", "issue", "spike", "plan"):
|
|
assert counts[key] == 0, key
|
|
assert counts["total"] == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_tag_filter_narrows_both_axes():
|
|
"""A tag has to reach both queries, or the chips would disagree with each
|
|
other under a filter — tasks narrowed, notes not."""
|
|
_, session = await _counts([], [], tags=["python"])
|
|
assert session.execute.await_count == 2
|
|
for call in session.execute.await_args_list:
|
|
assert "notes.tags" in str(call.args[0])
|