279 lines
9.3 KiB
Python
279 lines
9.3 KiB
Python
"""Tests for repeated -r ranges: unit window intersection, plain item slicing."""
|
|
|
|
import asyncio
|
|
import json
|
|
from datetime import UTC, datetime
|
|
|
|
from kanta.serialization import JsonSerializer
|
|
from kanta.structs import META_FORMAT, ChangeRecord, Snapshot
|
|
from tests.support import Data, make_kanta
|
|
|
|
# The CLI reads files with its own JSON Kanta instance unless -k is given.
|
|
JSON = ("json", JsonSerializer)
|
|
|
|
T0 = datetime(2026, 1, 1, tzinfo=UTC)
|
|
|
|
|
|
def _run_cli(path, *args):
|
|
from kanta.__main__ import main
|
|
|
|
return main([str(path), *args])
|
|
|
|
|
|
def _build(path, increments=3):
|
|
async def build():
|
|
kanta = make_kanta(path, Data, JSON)
|
|
await kanta.open(log=False)
|
|
for i in range(increments):
|
|
with kanta.transaction(f"inc{i}", log=False) as data:
|
|
data.counter += 1
|
|
await kanta.close()
|
|
|
|
asyncio.run(build())
|
|
|
|
|
|
def test_plain_range_restricts_dump_to_last_printed(tmp_path, capsys):
|
|
"""The JSON dump holds the state after the last printed item."""
|
|
path = tmp_path / "data.kantadb"
|
|
_build(path)
|
|
capsys.readouterr()
|
|
|
|
# Items are bootstrap, inc0, inc1, inc2: 1:3 prints inc0 and inc1.
|
|
assert _run_cli(path, "-r", "1:3", "-o", "-") == 0
|
|
out = capsys.readouterr()
|
|
assert "bootstrap" not in out.err
|
|
assert "inc0" in out.err
|
|
assert "inc1" in out.err
|
|
assert "inc2" not in out.err
|
|
# The dump is the state after inc1, the last printed record.
|
|
assert json.loads(out.out) == {"users": {}, "counter": 2}
|
|
|
|
# Inclusive end.
|
|
assert _run_cli(path, "-r", "0..1") == 0
|
|
err = capsys.readouterr().err
|
|
assert "bootstrap" in err
|
|
assert "inc0" in err
|
|
assert "inc1" not in err
|
|
|
|
|
|
def test_plain_single_index(tmp_path, capsys):
|
|
path = tmp_path / "data.kantadb"
|
|
_build(path)
|
|
capsys.readouterr()
|
|
|
|
assert _run_cli(path, "-r", "0") == 0
|
|
err = capsys.readouterr().err
|
|
assert "bootstrap" in err
|
|
assert "inc0" not in err
|
|
|
|
# Negative index counts from the end of the filtered output.
|
|
assert _run_cli(path, "-r", "-1") == 0
|
|
err = capsys.readouterr().err
|
|
assert "inc2" in err
|
|
assert "inc1" not in err
|
|
|
|
# A single index must exist; out of range is exit 2.
|
|
assert _run_cli(path, "-r", "99") == 2
|
|
assert "99" in capsys.readouterr().err
|
|
|
|
|
|
def test_plain_ranges_intersect(tmp_path, capsys):
|
|
path = tmp_path / "data.kantadb"
|
|
_build(path)
|
|
capsys.readouterr()
|
|
|
|
# [1:) and [:3) intersect to items 1 and 2.
|
|
assert _run_cli(path, "-r", "1:", "-r", ":3") == 0
|
|
err = capsys.readouterr().err
|
|
assert "bootstrap" not in err
|
|
assert "inc0" in err
|
|
assert "inc1" in err
|
|
assert "inc2" not in err
|
|
|
|
# An empty intersection prints nothing, exit 0 (truncation, not error).
|
|
assert _run_cli(path, "-r", ":1", "-r", "3:") == 0
|
|
err = capsys.readouterr().err
|
|
assert "inc" not in err
|
|
assert "bootstrap" not in err
|
|
|
|
|
|
def test_plain_applies_after_grep(tmp_path, capsys):
|
|
"""Plain numbers index into what survives --grep, order-independently."""
|
|
path = tmp_path / "data.kantadb"
|
|
_build(path)
|
|
capsys.readouterr()
|
|
|
|
# Grep leaves inc0..inc2; plain 0 picks the first of those.
|
|
assert _run_cli(path, "--grep", "inc", "-r", "0") == 0
|
|
first = capsys.readouterr().err
|
|
assert "inc0" in first
|
|
assert "inc1" not in first
|
|
assert "bootstrap" not in first
|
|
|
|
assert _run_cli(path, "-r", "0", "--grep", "inc") == 0
|
|
assert capsys.readouterr().err == first
|
|
|
|
# Negative index from the filtered end.
|
|
assert _run_cli(path, "--grep", "inc", "-r", "-1") == 0
|
|
err = capsys.readouterr().err
|
|
assert "inc2" in err
|
|
assert "inc1" not in err
|
|
|
|
|
|
def test_unit_specs_intersect(tmp_path, capsys):
|
|
path = tmp_path / "data.kantadb"
|
|
_build(path)
|
|
capsys.readouterr()
|
|
|
|
# r1:r4 = bootstrap..inc1, r2:r5 = inc0..inc2; intersection is inc0, inc1.
|
|
assert _run_cli(path, "-r", "r1:r4", "-r", "r2:r5", "-o", "-") == 0
|
|
out = capsys.readouterr()
|
|
assert "bootstrap" not in out.err
|
|
assert "inc0" in out.err
|
|
assert "inc1" in out.err
|
|
assert "inc2" not in out.err
|
|
# The replay end is the intersected window end (state after inc1).
|
|
assert json.loads(out.out) == {"users": {}, "counter": 2}
|
|
|
|
|
|
def test_unit_and_plain_combine(tmp_path, capsys):
|
|
"""Unit window first, then plain slicing over the window's items."""
|
|
path = tmp_path / "data.kantadb"
|
|
_build(path)
|
|
capsys.readouterr()
|
|
|
|
# Window r2:r4 yields inc0, inc1; plain item 0 prints only inc0.
|
|
assert _run_cli(path, "-r", "r2:r4", "-r", "0", "-o", "-") == 0
|
|
out = capsys.readouterr()
|
|
assert "inc0" in out.err
|
|
assert "inc1" not in out.err
|
|
# The dump is the state after the last printed item (inc0).
|
|
assert json.loads(out.out) == {"users": {}, "counter": 1}
|
|
|
|
|
|
def test_context_expands_after_grep(tmp_path, capsys):
|
|
"""Context records come from the whole file, free of --grep."""
|
|
path = tmp_path / "data.kantadb"
|
|
_build(path, increments=5)
|
|
capsys.readouterr()
|
|
|
|
assert _run_cli(path, "--grep", "inc2", "--context", "1") == 0
|
|
err = capsys.readouterr().err
|
|
assert "inc1" in err
|
|
assert "inc2" in err
|
|
assert "inc3" in err
|
|
assert "inc0" not in err
|
|
assert "inc4" not in err
|
|
assert "bootstrap" not in err
|
|
|
|
# Context 0 (default) changes nothing.
|
|
assert _run_cli(path, "--grep", "inc2") == 0
|
|
err = capsys.readouterr().err
|
|
assert "inc1" not in err
|
|
assert "inc2" in err
|
|
|
|
# Context is clamped at the start of the file.
|
|
assert _run_cli(path, "--grep", "bootstrap", "--context", "2") == 0
|
|
err = capsys.readouterr().err
|
|
assert "bootstrap" in err
|
|
assert "inc0" in err
|
|
assert "inc1" in err
|
|
assert "inc2" not in err
|
|
|
|
|
|
def test_context_expands_past_unit_window(tmp_path, capsys):
|
|
"""Context expands whatever a unit selection would show, past the window."""
|
|
path = tmp_path / "data.kantadb"
|
|
_build(path)
|
|
capsys.readouterr()
|
|
|
|
# -r r2 alone shows only inc0 (the record producing state 2); context 1
|
|
# expands past the window to bootstrap and inc1, and the dump follows
|
|
# the last printed record.
|
|
assert _run_cli(path, "-r", "r2", "--context", "1", "-o", "-") == 0
|
|
out = capsys.readouterr()
|
|
assert "bootstrap" in out.err
|
|
assert "inc0" in out.err
|
|
assert "inc1" in out.err
|
|
assert "inc2" not in out.err
|
|
assert json.loads(out.out) == {"users": {}, "counter": 2}
|
|
|
|
|
|
def test_context_expands_snapshot_selection(tmp_path, capsys):
|
|
"""-r sN --context N shows the snapshot plus its neighbors."""
|
|
path = tmp_path / "data.kantadb"
|
|
serializer = JsonSerializer()
|
|
framer = serializer.framer_cls()
|
|
out_bytes = bytearray()
|
|
out_bytes += framer.frame_change(
|
|
serializer.encode(ChangeRecord(ts=T0, a="inc0", v=0, diff={"counter": 1})),
|
|
record_offset=len(out_bytes),
|
|
)
|
|
snap = Snapshot(ts=T0, v=0, state={"counter": 1}, r=1, k=META_FORMAT)
|
|
out_bytes += framer.frame_snapshot(
|
|
serializer.encode(snap), record_offset=len(out_bytes)
|
|
)
|
|
out_bytes += framer.frame_change(
|
|
serializer.encode(ChangeRecord(ts=T0, a="inc1", v=0, diff={"counter": 2})),
|
|
record_offset=len(out_bytes),
|
|
)
|
|
path.write_bytes(bytes(out_bytes))
|
|
capsys.readouterr()
|
|
|
|
# Without context, s1 (the file starts with a change, so s0 is l0)
|
|
# shows just the snapshot and its state.
|
|
assert _run_cli(path, "-r", "s1", "-o", "-") == 0
|
|
out = capsys.readouterr()
|
|
assert "snapshot s1" in out.err
|
|
assert "inc0" not in out.err
|
|
assert json.loads(out.out) == {"counter": 1}
|
|
|
|
# With context, the selection expands past the snapshot line.
|
|
assert _run_cli(path, "-r", "s1", "--context", "1", "-o", "-") == 0
|
|
out = capsys.readouterr()
|
|
assert "inc0" in out.err
|
|
assert "snapshot s1" in out.err
|
|
assert "inc1" in out.err
|
|
assert json.loads(out.out) == {"counter": 2}
|
|
|
|
|
|
def test_context_merges_overlaps(tmp_path, capsys):
|
|
"""Nearby matches share context instead of duplicating records."""
|
|
path = tmp_path / "data.kantadb"
|
|
_build(path, increments=5)
|
|
capsys.readouterr()
|
|
|
|
# Grep leaves inc0..inc4; plain 1:3 selects inc1, inc2; context 1
|
|
# expands to inc0..inc3, each printed exactly once (overlaps merged).
|
|
assert _run_cli(path, "--grep", "inc", "-r", "1:3", "--context", "1") == 0
|
|
err = capsys.readouterr().err
|
|
for action in ["inc0", "inc1", "inc2", "inc3"]:
|
|
assert err.count(action) == 1
|
|
assert "bootstrap" not in err
|
|
assert "inc4" not in err
|
|
|
|
|
|
def test_grep_and_context_shift_dump_to_last_printed(tmp_path, capsys):
|
|
"""Grep and --context are display filters, but the dump follows the
|
|
last printed record, so they move it too."""
|
|
path = tmp_path / "data.kantadb"
|
|
_build(path)
|
|
capsys.readouterr()
|
|
|
|
# Only inc1 prints; the dump is the state after inc1.
|
|
assert _run_cli(path, "--grep", "inc1", "-o", "-") == 0
|
|
out = capsys.readouterr()
|
|
assert "inc0" not in out.err
|
|
assert json.loads(out.out) == {"users": {}, "counter": 2}
|
|
|
|
# Context extends the printout past the match; the dump follows it.
|
|
assert _run_cli(path, "--grep", "inc1", "--context", "1", "-o", "-") == 0
|
|
out = capsys.readouterr()
|
|
assert "inc2" in out.err
|
|
assert json.loads(out.out) == {"users": {}, "counter": 3}
|
|
|
|
# Nothing printed: the dump falls back to the range-end state.
|
|
assert _run_cli(path, "--grep", "zzz", "-o", "-") == 0
|
|
out = capsys.readouterr()
|
|
assert json.loads(out.out) == {"users": {}, "counter": 3}
|