e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
217 lines
8.0 KiB
Python
217 lines
8.0 KiB
Python
"""Tests for the line-numbered document view + edit application."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from deeptutor.services.memory.consolidator.line_doc import (
|
|
DeleteLinesOp,
|
|
InsertAfterOp,
|
|
ReplaceLineOp,
|
|
apply_edits,
|
|
parse_edits_payload,
|
|
render_view,
|
|
)
|
|
from deeptutor.services.memory.document import Document, Entry, serialize
|
|
from deeptutor.services.memory.ids import new_entry_id
|
|
|
|
|
|
def _doc_with_three_entries() -> tuple[Document, list[str]]:
|
|
ids = [new_entry_id() for _ in range(3)]
|
|
doc = Document(
|
|
title="notebook memory",
|
|
sections=[
|
|
(
|
|
"Themes",
|
|
[
|
|
Entry(
|
|
id=ids[0],
|
|
section="Themes",
|
|
text="uses spaced repetition",
|
|
refs=["notebook:r1"],
|
|
),
|
|
Entry(
|
|
id=ids[1],
|
|
section="Themes",
|
|
text="prefers Anki over Quizlet",
|
|
refs=["notebook:r2"],
|
|
),
|
|
],
|
|
),
|
|
(
|
|
"Open questions",
|
|
[
|
|
Entry(
|
|
id=ids[2],
|
|
section="Open questions",
|
|
text="ε-δ geometric meaning",
|
|
refs=["notebook:r3"],
|
|
),
|
|
],
|
|
),
|
|
],
|
|
)
|
|
return doc, ids
|
|
|
|
|
|
def test_render_view_shows_lines_and_section_headers() -> None:
|
|
doc, ids = _doc_with_three_entries()
|
|
view = render_view(doc)
|
|
text = view.render()
|
|
assert "## Themes" in text
|
|
assert "## Open questions" in text
|
|
assert f"[^{ids[0]}]" in text
|
|
|
|
|
|
def test_render_view_strips_footnote_block() -> None:
|
|
doc, _ = _doc_with_three_entries()
|
|
view = render_view(doc)
|
|
# Sanitized view never contains a footnote definition.
|
|
assert "[^" in view.render() # bullet markers ok
|
|
assert ": notebook:" not in view.render() # footnote-body line absent
|
|
|
|
|
|
def test_replace_preserves_entry_id_and_replaces_refs() -> None:
|
|
doc, ids = _doc_with_three_entries()
|
|
view = render_view(doc)
|
|
target_line = next(line for line in view.lines if line.entry_id == ids[0])
|
|
edit = ReplaceLineOp(
|
|
line=target_line.number,
|
|
new_text="uses SRS with FSRS scheduling",
|
|
refs=["notebook:r1", "notebook:r1b"],
|
|
reason="more specific",
|
|
)
|
|
new_doc, report = apply_edits(doc, [edit])
|
|
assert not report.rejected
|
|
entry = next(e for e in new_doc.all_entries() if e.id == ids[0])
|
|
assert entry.text == "uses SRS with FSRS scheduling"
|
|
assert entry.refs == ["notebook:r1", "notebook:r1b"]
|
|
|
|
|
|
def test_delete_lines_removes_entries_and_collapses_empty_section() -> None:
|
|
doc, ids = _doc_with_three_entries()
|
|
view = render_view(doc)
|
|
target_line = next(line for line in view.lines if line.entry_id == ids[2])
|
|
edit = DeleteLinesOp(line_start=target_line.number, line_end=target_line.number, reason="stale")
|
|
new_doc, report = apply_edits(doc, [edit])
|
|
assert not report.rejected
|
|
assert all(e.id != ids[2] for e in new_doc.all_entries())
|
|
section_names = [name for name, _ in new_doc.sections]
|
|
assert "Open questions" not in section_names # empty section dropped
|
|
|
|
|
|
def test_insert_after_bullet_keeps_section_context() -> None:
|
|
doc, ids = _doc_with_three_entries()
|
|
view = render_view(doc)
|
|
anchor = next(line for line in view.lines if line.entry_id == ids[1])
|
|
edit = InsertAfterOp(
|
|
after_line=anchor.number,
|
|
text="uses Obsidian for daily notes",
|
|
refs=["notebook:r4"],
|
|
reason="new evidence",
|
|
)
|
|
new_doc, report = apply_edits(doc, [edit])
|
|
assert not report.rejected
|
|
themes = next(entries for name, entries in new_doc.sections if name == "Themes")
|
|
assert any(e.text == "uses Obsidian for daily notes" for e in themes)
|
|
|
|
|
|
def test_replace_rejects_when_refs_empty() -> None:
|
|
doc, ids = _doc_with_three_entries()
|
|
view = render_view(doc)
|
|
target_line = next(line for line in view.lines if line.entry_id == ids[0])
|
|
edit = ReplaceLineOp(line=target_line.number, new_text="hello", refs=[], reason="bad")
|
|
new_doc, report = apply_edits(doc, [edit])
|
|
assert len(report.rejected) == 1
|
|
# Original text still there.
|
|
assert any(e.text == "uses spaced repetition" for e in new_doc.all_entries())
|
|
|
|
|
|
def test_apply_in_reverse_order_does_not_shift_line_numbers() -> None:
|
|
doc, ids = _doc_with_three_entries()
|
|
view = render_view(doc)
|
|
first_line = next(line for line in view.lines if line.entry_id == ids[0])
|
|
third_line = next(line for line in view.lines if line.entry_id == ids[2])
|
|
|
|
# Delete line 8 (the third entry) and replace line 4 (the first
|
|
# entry) — these are valid line numbers in the original view.
|
|
edits = [
|
|
ReplaceLineOp(line=first_line.number, new_text="updated", refs=["notebook:r1"], reason="x"),
|
|
DeleteLinesOp(line_start=third_line.number, line_end=third_line.number, reason="stale"),
|
|
]
|
|
new_doc, report = apply_edits(doc, edits)
|
|
assert not report.rejected
|
|
assert any(e.text == "updated" for e in new_doc.all_entries())
|
|
assert all(e.id != ids[2] for e in new_doc.all_entries())
|
|
|
|
|
|
def test_parse_edits_payload_tolerates_fences_and_prose() -> None:
|
|
raw = """Here are my edits:
|
|
```json
|
|
{"edits": [
|
|
{"op": "replace", "line": 4, "new_text": "x", "refs": ["a:b"], "reason": "y"},
|
|
{"op": "delete", "line_start": 8, "line_end": 8, "reason": "z"}
|
|
]}
|
|
```"""
|
|
edits = parse_edits_payload(raw)
|
|
assert len(edits) == 2
|
|
assert isinstance(edits[0], ReplaceLineOp)
|
|
assert isinstance(edits[1], DeleteLinesOp)
|
|
|
|
|
|
def test_parse_edits_payload_strips_ref_wrapper_chars() -> None:
|
|
"""LLM sometimes echoes ``[^m_xxx]`` markers from the line view —
|
|
caret and brackets included — into the refs array."""
|
|
raw = """{"edits": [
|
|
{"op": "replace", "line": 4, "new_text": "x",
|
|
"refs": ["^m_01HZK1ABCDEFGHJKMNPQRSTVWX", " [chat:01] "],
|
|
"reason": "y"}
|
|
]}"""
|
|
edits = parse_edits_payload(raw, layer="L3")
|
|
assert isinstance(edits[0], ReplaceLineOp)
|
|
# In L3 mode the m_<ULID> form is legal (L3 refs are L2 entry ids),
|
|
# so it survives — but only with the ``^`` and surrounding noise
|
|
# stripped.
|
|
assert edits[0].refs == ["m_01HZK1ABCDEFGHJKMNPQRSTVWX", "chat:01"]
|
|
|
|
|
|
def test_parse_edits_payload_drops_entry_id_refs_in_l2() -> None:
|
|
"""L2 refs are ``surface:id`` shaped; ``m_<ULID>`` in L2 is the
|
|
audit/dedup LLM hallucinating from the line-numbered view (it sees
|
|
each bullet's entry-id anchor and copies it as a ref). Drop those."""
|
|
raw = """{"edits": [
|
|
{"op": "replace", "line": 4, "new_text": "x",
|
|
"refs": ["^m_01HZK1ABCDEFGHJKMNPQRSTVWX", "cowriter:abc"],
|
|
"reason": "y"}
|
|
]}"""
|
|
edits = parse_edits_payload(raw, layer="L2")
|
|
assert isinstance(edits[0], ReplaceLineOp)
|
|
assert edits[0].refs == ["cowriter:abc"]
|
|
|
|
|
|
def test_parse_edits_payload_default_layer_keeps_everything() -> None:
|
|
"""Without a layer hint we only strip wrappers — never drop refs."""
|
|
raw = """{"edits": [
|
|
{"op": "insert", "after_line": 0, "text": "t",
|
|
"refs": ["^m_01HZK1ABCDEFGHJKMNPQRSTVWX", "chat:01"],
|
|
"reason": "r"}
|
|
]}"""
|
|
edits = parse_edits_payload(raw)
|
|
assert isinstance(edits[0], InsertAfterOp)
|
|
assert edits[0].refs == ["m_01HZK1ABCDEFGHJKMNPQRSTVWX", "chat:01"]
|
|
|
|
|
|
def test_serialize_roundtrip_after_edits_keeps_footnotes_consistent() -> None:
|
|
doc, ids = _doc_with_three_entries()
|
|
view = render_view(doc)
|
|
edit = ReplaceLineOp(
|
|
line=next(line for line in view.lines if line.entry_id == ids[0]).number,
|
|
new_text="updated text",
|
|
refs=["notebook:r1-new"],
|
|
reason="x",
|
|
)
|
|
new_doc, _ = apply_edits(doc, [edit])
|
|
md = serialize(new_doc)
|
|
# Footnote section reflects the updated refs (auto-rebuilt).
|
|
assert "notebook:r1-new" in md
|
|
# The old ref text is gone from footnotes (replaced, not appended).
|
|
assert "notebook:r1," not in md and "notebook:r1\n" not in md
|