The data and domain layers for the chunks editor (#219-224 in 0.6.0). Chunks were previously stored as a JSON blob in analysis_jobs.chunks_json; this commit makes them first-class persisted entities with stable IDs, soft-delete, and an immutable audit log. Domain - Chunk: persistent entity with id, document_id, sequence, text, headings, source_page, bboxes, doc_items, token_count, timestamps, deleted_at (soft delete) - ChunkEdit: immutable audit row (action, actor, at, before, after, parents, children, reason) - ChunkPush: snapshot of which chunk_ids landed in which store at push - ChunkEditAction enum: insert/update/delete/merge/split - domain/chunk_editing.py: pure operations on a chunkset (insert, update, delete, merge, split). Each returns a new chunkset and the affected chunk(s); errors raise ChunkEditingError. Persistence - Three new tables: chunks, chunk_edits, chunk_pushes (FK + indexes) - SqliteChunkRepository (insert, insert_many, update, soft_delete, find_for_document, find_by_id; respects deleted_at) - SqliteChunkEditRepository (append-only audit log; paginated reads ordered newest-first; per-chunk history) - SqliteChunkPushRepository (per-(doc, store) latest snapshot) Ports - ChunkRepository, ChunkEditRepository, ChunkPushRepository protocols added to domain/ports.py Tests - 17 tests for the pure chunk-editing operations covering insert / update / delete / merge / split, sequence shifts, lineage, error paths (out-of-range, missing id, deleted target, cross-document) - 11 tests for the three repositories: round-trips, soft-delete filtering, history ordering, lineage round-trip, cascade-delete with document, find_latest semantics Service orchestration (ChunkEditingService — atomic chunk + audit write) and the API endpoints land in a follow-up commit on the same feature branch / next release. The data + domain foundation here is what unblocks #219-224. Refs #205
196 lines
5.8 KiB
Python
196 lines
5.8 KiB
Python
"""Tests for the pure-domain chunk-editing operations (#205)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from domain.chunk_editing import (
|
|
ChunkEditingError,
|
|
delete,
|
|
insert,
|
|
merge,
|
|
split,
|
|
update,
|
|
)
|
|
from domain.models import Chunk
|
|
|
|
|
|
def _make(document_id: str = "doc-1", count: int = 3) -> list[Chunk]:
|
|
out: list[Chunk] = []
|
|
for i in range(count):
|
|
out.append(
|
|
Chunk(
|
|
id=f"c-{i}",
|
|
document_id=document_id,
|
|
sequence=i,
|
|
text=f"text {i}",
|
|
headings=[f"H{i}"],
|
|
source_page=i + 1,
|
|
)
|
|
)
|
|
return out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# insert
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_insert_at_end_appends_chunk_with_correct_sequence() -> None:
|
|
chunks = _make(count=2)
|
|
out, new = insert(chunks, at_position=2, text="new", document_id="doc-1")
|
|
assert len(out) == 3
|
|
assert out[2].id == new.id
|
|
assert new.sequence == 2
|
|
# Existing chunks untouched.
|
|
assert out[0].sequence == 0
|
|
assert out[1].sequence == 1
|
|
|
|
|
|
def test_insert_in_middle_shifts_subsequent_sequences() -> None:
|
|
chunks = _make(count=3)
|
|
out, new = insert(chunks, at_position=1, text="middle", document_id="doc-1")
|
|
sequences = [c.sequence for c in out]
|
|
assert sequences == [0, 1, 2, 3]
|
|
assert out[1].id == new.id
|
|
# Original chunk-1 was shifted to sequence 2.
|
|
assert next(c for c in out if c.id == "c-1").sequence == 2
|
|
|
|
|
|
def test_insert_out_of_range_raises() -> None:
|
|
chunks = _make(count=2)
|
|
with pytest.raises(ChunkEditingError):
|
|
insert(chunks, at_position=99, text="x", document_id="doc-1")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# update
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_update_changes_text_in_place() -> None:
|
|
chunks = _make()
|
|
out, modified = update(chunks, "c-1", text="edited")
|
|
assert modified.id == "c-1"
|
|
assert modified.text == "edited"
|
|
# Identity preserved.
|
|
assert next(c for c in out if c.id == "c-1").text == "edited"
|
|
|
|
|
|
def test_update_can_change_headings_only() -> None:
|
|
chunks = _make()
|
|
_, modified = update(chunks, "c-0", headings=["X"])
|
|
assert modified.headings == ["X"]
|
|
|
|
|
|
def test_update_unknown_chunk_raises() -> None:
|
|
chunks = _make()
|
|
with pytest.raises(ChunkEditingError):
|
|
update(chunks, "missing", text="x")
|
|
|
|
|
|
def test_update_deleted_chunk_raises() -> None:
|
|
chunks = _make()
|
|
delete(chunks, "c-1")
|
|
with pytest.raises(ChunkEditingError):
|
|
update(chunks, "c-1", text="x")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# delete
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_delete_marks_deleted_at() -> None:
|
|
chunks = _make()
|
|
out, deleted = delete(chunks, "c-1")
|
|
target = next(c for c in out if c.id == "c-1")
|
|
assert target.deleted_at is not None
|
|
assert deleted is target
|
|
|
|
|
|
def test_delete_is_idempotent() -> None:
|
|
chunks = _make()
|
|
out_a, _ = delete(chunks, "c-1")
|
|
out_b, _ = delete(out_a, "c-1")
|
|
target_a = next(c for c in out_a if c.id == "c-1")
|
|
target_b = next(c for c in out_b if c.id == "c-1")
|
|
assert target_a.deleted_at == target_b.deleted_at
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# merge
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_merge_removes_sources_and_returns_new_chunk() -> None:
|
|
chunks = _make(count=3)
|
|
out, merged = merge(chunks, ["c-0", "c-1"])
|
|
ids = {c.id for c in out}
|
|
assert "c-0" not in ids
|
|
assert "c-1" not in ids
|
|
assert merged.id in ids
|
|
assert merged.text == "text 0\ntext 1"
|
|
# Merged chunk inherits headings from first source.
|
|
assert merged.headings == ["H0"]
|
|
# Source page is the min.
|
|
assert merged.source_page == 1
|
|
|
|
|
|
def test_merge_requires_at_least_two_chunks() -> None:
|
|
chunks = _make()
|
|
with pytest.raises(ChunkEditingError):
|
|
merge(chunks, ["c-0"])
|
|
|
|
|
|
def test_merge_across_documents_raises() -> None:
|
|
chunks = _make(document_id="doc-1", count=2)
|
|
other = Chunk(id="x-0", document_id="doc-2", sequence=10, text="other")
|
|
chunks.append(other)
|
|
with pytest.raises(ChunkEditingError):
|
|
merge(chunks, ["c-0", "x-0"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# split
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_split_creates_two_new_chunks() -> None:
|
|
chunks = _make(count=2)
|
|
out, left, right = split(chunks, "c-0", at_offset=4)
|
|
assert left.text == "text"
|
|
assert right.text == " 0"
|
|
ids = {c.id for c in out}
|
|
# Source replaced by two new chunks.
|
|
assert "c-0" not in ids
|
|
assert left.id in ids
|
|
assert right.id in ids
|
|
|
|
|
|
def test_split_preserves_headings_on_both_halves() -> None:
|
|
chunks = _make(count=1)
|
|
_, left, right = split(chunks, "c-0", at_offset=2)
|
|
assert left.headings == ["H0"]
|
|
assert right.headings == ["H0"]
|
|
|
|
|
|
def test_split_shifts_subsequent_sequences() -> None:
|
|
chunks = _make(count=3)
|
|
out, _, _ = split(chunks, "c-0", at_offset=2)
|
|
# After split, c-1 (was seq=1) and c-2 (was seq=2) shift up by 1.
|
|
seqs = sorted(c.sequence for c in out)
|
|
assert seqs == [0, 1, 2, 3]
|
|
|
|
|
|
def test_split_at_zero_offset_raises() -> None:
|
|
chunks = _make()
|
|
with pytest.raises(ChunkEditingError):
|
|
split(chunks, "c-0", at_offset=0)
|
|
|
|
|
|
def test_split_beyond_text_length_raises() -> None:
|
|
chunks = _make()
|
|
target = next(c for c in chunks if c.id == "c-0")
|
|
with pytest.raises(ChunkEditingError):
|
|
split(chunks, "c-0", at_offset=len(target.text))
|