Files
FabledScribe/tests/test_integration_lesson_kind.py
T
bvandeusenandClaude Opus 5.5 66e21a6c60
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / integration (push) Successful in 52s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m35s
CI & Build / Build & push image (push) Successful in 32s
refactor(notes): a snippet's and lesson's stored title is its name; the trigger joins it only in the embedded document (milestone 427)
The title was `subject — trigger` because the stored title WAS the
embedded one, and the join is what makes these kinds rank on the
situation they apply to (#2485). Every surface that shows a title then
showed the trigger too -- menus, lists and search rows ran to kilobytes.

- embeddings.document_title(title, note_type, data, body) joins the
  trigger from `data` (body fallback) at embed time. Idempotent: an
  un-migrated composed title comes out the same, never doubled. The
  embed path, the startup backfill and the dedup gate's semantic signal
  all use it, so the embedded text -- and every vector -- is unchanged.
- Writers store the subject: snippet create/update (service, REST, MCP)
  and lesson_document. Both compose_title helpers are removed.
- Readers: dedup takes `data`; the menus strip the embedded title from a
  passage; list rows project `when_to_use`, which SnippetListView reads.
- 0108 rewrites existing rows on an exact `' — ' || <own trigger>`
  suffix with raw SQL, leaving updated_at alone so the backfill does not
  re-embed the corpus for identical vectors. Downgrade recomposes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 16:48:28 -04:00

114 lines
4.4 KiB
Python

"""A lesson is a row the database actually accepts (milestone 385 step 2).
WHY THIS IS THE REAL GUARD, AND WHY IT IS HERE
The step asked for "a guard that the CHECK actually accepts `lesson` and
rejects a typo", citing #3128 — a `spike` kind that could not be written on an
instance predating its migration. That failure mode belongs to `task_kind`,
which IS gated (`notes_task_kind_check`, migrations 0056 and 0065).
`note_type` is not gated at all: migration 0036 added it as plain Text with a
server default and nothing has constrained it since.
So there is no whitelist to expand and no rejection to assert. Writing the
guard as "the constraint admits lesson" would have pinned a constraint that
does not exist; writing it as "no constraint exists" would pin today's schema
rather than the behaviour that matters, and would go red on a change that is
perfectly fine.
Asserting the WRITE covers both worlds. It passes today, it keeps passing if a
CHECK is added that admits `lesson`, and it goes red the day one is added that
does not — which is the only outcome anyone needs to be told about.
The second test is the cell #3163 asks to be left empty on purpose: a lesson
must not be a task. `is_task` is a read-only property over `status`, so this
cannot be asserted by setting a flag — it has to be observed on a real row.
"""
import pytest
import pytest_asyncio
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.note import Note
from scribe.services import lessons as lessons_svc
from scribe.services import notes as notes_svc
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
OWNER_USERNAME = "lesson_kind_owner"
TRIGGER = "the operator pasted a stack trace and said it is still broken"
SUBJECT = "Change one thing, then look"
@pytest_asyncio.fixture
async def owner_id():
async with async_session() as s:
owner = await ensure_user(s, OWNER_USERNAME)
await s.commit()
uid = owner.id
# Cleaned at SETUP rather than teardown: create_note fires a detached
# embedding refresh that opens its own connection and writes the row,
# and a teardown delete would race it. A fresh loop has already
# cancelled whatever the previous test left in flight.
for note in (await s.execute(
select(Note).where(
Note.user_id == uid,
Note.note_type == lessons_svc.LESSON_NOTE_TYPE,
)
)).scalars().all():
await s.delete(note)
await s.commit()
return uid
async def test_a_lesson_is_a_row_the_database_accepts(owner_id):
"""The whole point. Goes red if `note_type` is ever gated without this
value, and stays correct if it is gated with it."""
lesson = await notes_svc.create_note(
owner_id,
title=SUBJECT,
body=f"**When to apply:** {TRIGGER}\n\nOne change at a time.",
note_type=lessons_svc.LESSON_NOTE_TYPE,
data={lessons_svc.TRIGGER_KEY: TRIGGER},
)
async with async_session() as s:
stored = (await s.execute(
select(Note).where(Note.id == lesson.id)
)).scalars().one()
assert stored.note_type == "lesson"
# The trigger survives the round trip on both halves — the indexed mirror
# and the readable body — because the vector is built from the text and
# the queries are built from the mirror.
assert lessons_svc.lesson_trigger(stored) == TRIGGER
# The subject alone (milestone 427): the trigger is in `data` and the body.
assert stored.title == SUBJECT
assert "**When to apply:**" in (stored.body or "")
async def test_a_lesson_is_not_a_task(owner_id):
"""#3163's cell left empty on purpose.
`is_task` is `status is not None` and is read-only, so this is only
observable on a stored row. A lesson that arrived with a status would join
the open-work listings — the one way an unfilled field changes what the
record IS rather than what it says.
"""
lesson = await notes_svc.create_note(
owner_id,
title=SUBJECT,
body="One change at a time.",
note_type=lessons_svc.LESSON_NOTE_TYPE,
)
async with async_session() as s:
stored = (await s.execute(
select(Note).where(Note.id == lesson.id)
)).scalars().one()
assert stored.status is None
assert stored.is_task is False
assert stored.milestone_id is None