Snapshot indicator lines printed unconditionally under --grep, suggesting the snapshot matched. Now the snapshot's full state is flattened into the same (path, value) entries change records are matched against, and the snapshot line is suppressed unless every pattern matches. Snapshots explicitly selected with -r s<N> still print unconditionally.
690 lines
24 KiB
Python
690 lines
24 KiB
Python
"""Tests for structural ``--grep`` matching and match highlighting."""
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
from kanta.__main__ import main
|
|
from kanta.grep import (
|
|
GrepPattern,
|
|
_Entry,
|
|
_find_spans,
|
|
_match_path,
|
|
_match_value,
|
|
evaluate,
|
|
mark_spans,
|
|
matches_snapshot,
|
|
)
|
|
from kanta.logging import _USER_PATH
|
|
from kanta.serialization import JsonSerializer
|
|
from kanta.serialization.framing import LineFramer
|
|
from kanta.structs import ChangeRecord, Snapshot
|
|
from kanta.tty import strip_ansi
|
|
|
|
TS = datetime(2026, 1, 1, tzinfo=UTC)
|
|
|
|
MARK = "\x1b[48;5;220m"
|
|
UNMARK = "\x1b[49m"
|
|
|
|
|
|
def test_match_path_whole_elements_anywhere():
|
|
assert _match_path("users", ["users"]) == frozenset({0})
|
|
assert _match_path("users", ["data", "users", "alice"]) == frozenset({1})
|
|
# Partial element matches require explicit wildcards.
|
|
assert _match_path("users", ["foousers"]) is None
|
|
assert _match_path("us*rs", ["foousers"]) is None
|
|
assert _match_path("*users", ["foousers"]) == frozenset({0})
|
|
|
|
|
|
def test_match_path_contiguous_element_sequence():
|
|
assert _match_path("alice.email", ["users", "alice", "email"]) == frozenset({1, 2})
|
|
# The sequence must be contiguous.
|
|
assert _match_path("users.email", ["users", "alice", "email"]) is None
|
|
assert _match_path("users.*.email", ["users", "alice", "email"]) == frozenset(
|
|
{0, 1, 2}
|
|
)
|
|
# Matching is case-insensitive and empty terms match without marking.
|
|
assert _match_path("USERS", ["users"]) == frozenset({0})
|
|
assert _match_path("", ["anything"]) == frozenset()
|
|
|
|
|
|
def test_match_value_substring_for_strings_full_for_scalars():
|
|
assert _match_value("lice", "alice@example.com", "alice@example.com")
|
|
assert not _match_value("bob", "alice@example.com", "alice@example.com")
|
|
# Booleans, numbers and null match only in full.
|
|
assert _match_value("true", True, "true")
|
|
assert not _match_value("tru", True, "true")
|
|
assert _match_value("42", 42, "42")
|
|
assert not _match_value("4", 42, "42")
|
|
assert _match_value("null", None, "null")
|
|
# Wildcards match the whole value text of any type.
|
|
assert _match_value("tru*", True, "true")
|
|
assert _match_value("4*", 42, "42")
|
|
# Containers do not match; their leaves are matched individually.
|
|
assert not _match_value("email", {"email": "a@b.c"}, '{"email": "a@b.c"}')
|
|
# An empty term matches anything.
|
|
assert _match_value("", True, "true")
|
|
|
|
|
|
def test_mark_spans_merges_overlaps_and_adjacents():
|
|
assert mark_spans("alice@example.com", [(0, 5), (3, 11)]) == (
|
|
f"{MARK}alice@examp{UNMARK}le.com"
|
|
)
|
|
assert mark_spans("aab", [(0, 1), (1, 3)]) == f"{MARK}aab{UNMARK}"
|
|
assert mark_spans("ab", [(1, 1), (2, 2)]) == "ab"
|
|
|
|
|
|
def test_mark_spans_offsets_into_styled_text():
|
|
styled = "\x1b[32mal\x1b[0mlice"
|
|
# A span crossing an escape sequence wraps each escape-free run.
|
|
assert mark_spans(styled, [(0, 3)]) == (
|
|
f"\x1b[32m{MARK}al{UNMARK}\x1b[0m{MARK}l{UNMARK}ice"
|
|
)
|
|
# A span covering everything wraps each escape-free run separately.
|
|
assert mark_spans(styled, [(0, 6)]) == (
|
|
f"\x1b[32m{MARK}al{UNMARK}\x1b[0m{MARK}lice{UNMARK}"
|
|
)
|
|
assert mark_spans(styled, []) == styled
|
|
|
|
|
|
def test_find_spans_case_insensitive_occurrences():
|
|
assert _find_spans("Alice likes ALICE", "alice") == [(0, 5), (12, 17)]
|
|
assert _find_spans("nope", "alice") == []
|
|
assert _find_spans("anything", "") == []
|
|
|
|
|
|
def test_pattern_parse_forms():
|
|
bare = GrepPattern.parse("alice")
|
|
assert bare.term == "alice" and bare.path_term is None
|
|
|
|
pair = GrepPattern.parse("users.alice.age=30")
|
|
assert pair.term is None
|
|
assert pair.path_term == "users.alice.age"
|
|
assert pair.value_term == "30"
|
|
|
|
value_only = GrepPattern.parse("=alice@example.com")
|
|
assert value_only.path_term == "" and value_only.value_term == "alice@example.com"
|
|
|
|
path_only = GrepPattern.parse("users.alice=")
|
|
assert path_only.path_term == "users.alice" and path_only.value_term == ""
|
|
|
|
# Split on the first '=' only; the value may contain '='.
|
|
multi = GrepPattern.parse("key=a=b")
|
|
assert multi.path_term == "key" and multi.value_term == "a=b"
|
|
|
|
|
|
def _patterns(*raws: str) -> list[GrepPattern]:
|
|
return [GrepPattern.parse(raw) for raw in raws]
|
|
|
|
|
|
def test_evaluate_bare_term_against_path_value_action_user():
|
|
record = ChangeRecord(
|
|
ts=TS, a="create_user", u="admin", diff={"users": {"alice": {"age": 30}}}
|
|
)
|
|
assert evaluate(record, {}, _patterns("users.alice"))
|
|
assert evaluate(record, {}, _patterns("30"))
|
|
assert evaluate(record, {}, _patterns("CREATE_user"))
|
|
assert evaluate(record, {}, _patterns("admin"))
|
|
assert evaluate(record, {}, _patterns("age=30"))
|
|
assert not evaluate(record, {}, _patterns("bob"))
|
|
assert not evaluate(record, {}, _patterns("3")) # not a full number match
|
|
|
|
|
|
def test_evaluate_unescapes_dollar_keys():
|
|
record = ChangeRecord(ts=TS, a="set", diff={"$$config": 5})
|
|
assert evaluate(record, {}, _patterns("$config=5"))
|
|
|
|
|
|
def test_evaluate_path_value_form_requires_same_line():
|
|
record = ChangeRecord(ts=TS, a="set", diff={"a": {"x": 1}, "b": {"y": 2}})
|
|
previous = {"a": {"x": 0}, "b": {"y": 0}}
|
|
assert evaluate(record, previous, _patterns("a.x=1"))
|
|
# 'a' matches one line's path, '2' another line's value: no match.
|
|
assert not evaluate(record, previous, _patterns("a=2"))
|
|
|
|
|
|
def test_evaluate_all_patterns_same_record_any_line():
|
|
record = ChangeRecord(ts=TS, a="set", diff={"a": {"x": 1}, "b": {"y": 2}})
|
|
previous = {"a": {"x": 0}, "b": {"y": 0}}
|
|
assert evaluate(record, previous, _patterns("a.x", "=2"))
|
|
assert not evaluate(record, previous, _patterns("a.x", "=2", "missing"))
|
|
|
|
|
|
def test_evaluate_matches_deleted_content_by_previous_value():
|
|
record = ChangeRecord(ts=TS, a="delete_user", diff={"users": {"$delete": "alice"}})
|
|
previous = {"users": {"alice": {"email": "alice@example.com"}}}
|
|
assert evaluate(record, previous, _patterns("alice@example.com"))
|
|
assert evaluate(record, previous, _patterns("users.alice.email"))
|
|
assert not evaluate(record, {}, _patterns("alice@example.com"))
|
|
|
|
|
|
def test_highlighter_marks_exactly_the_matched_regions():
|
|
record = ChangeRecord(
|
|
ts=TS,
|
|
a="set",
|
|
diff={
|
|
"users": {"alice": {"email": "same@x.com"}, "bob": {"email": "same@x.com"}}
|
|
},
|
|
)
|
|
previous = {
|
|
"users": {"alice": {"email": "old@x.com"}, "bob": {"email": "same@x.com"}}
|
|
}
|
|
hl = evaluate(record, previous, _patterns("users.alice.email"))
|
|
assert hl is not None
|
|
# Path-side match: the matched elements are lit, the value is not.
|
|
assert hl.path("alice", "users.alice") == f"{MARK}alice{UNMARK}"
|
|
assert hl.value("same@x.com", "users.alice.email") == "same@x.com"
|
|
# Bob's identical value is a different path: untouched.
|
|
assert hl.path("bob", "users.bob") == "bob"
|
|
assert hl.value("same@x.com", "users.bob.email") == "same@x.com"
|
|
|
|
hl = evaluate(record, previous, _patterns("=same@x.com"))
|
|
assert hl is not None
|
|
# Both lines genuinely match the value: both are marked.
|
|
assert hl.value("same@x.com", "users.alice.email") == f"{MARK}same@x.com{UNMARK}"
|
|
assert hl.value("same@x.com", "users.bob.email") == f"{MARK}same@x.com{UNMARK}"
|
|
assert hl.path("alice", "users.alice") == "alice"
|
|
|
|
|
|
def test_highlighter_merges_overlapping_needles_from_different_patterns():
|
|
record = ChangeRecord(ts=TS, a="set", diff={"email": "alice@example.com"})
|
|
hl = evaluate(record, {}, _patterns("alice", "lice@exam"))
|
|
assert hl is not None
|
|
assert hl.value("alice@example.com", "email") == (
|
|
f"{MARK}alice@exam{UNMARK}ple.com"
|
|
)
|
|
|
|
|
|
def test_highlighter_marks_delete_marker_on_removed_content_match():
|
|
record = ChangeRecord(ts=TS, a="delete_user", diff={"users": {"$delete": "bob"}})
|
|
previous = {"users": {"bob": {"email": "bob@example.com"}}}
|
|
hl = evaluate(record, previous, _patterns("bob@example.com"))
|
|
assert hl is not None
|
|
# The matched content is gone: the deletion marker is lit, not the path.
|
|
assert hl.path("users", "users") == "users"
|
|
assert hl.path("bob", "users.bob") == "bob"
|
|
assert hl.delete("✗", "users.bob") == f"{MARK}✗{UNMARK}"
|
|
assert hl.delete("✗", "users.alice") == "✗"
|
|
|
|
# A path-side match lights the genuinely matched elements of the anchor,
|
|
# and the marker for the element below it.
|
|
hl = evaluate(record, previous, _patterns("users.bob.email"))
|
|
assert hl is not None
|
|
assert hl.path("users", "users") == f"{MARK}users{UNMARK}"
|
|
assert hl.path("bob", "users.bob") == f"{MARK}bob{UNMARK}"
|
|
assert hl.delete("✗", "users.bob") == f"{MARK}✗{UNMARK}"
|
|
|
|
# A path-side match of one displayed element lights only that element.
|
|
hl = evaluate(record, previous, _patterns("bob"))
|
|
assert hl is not None
|
|
assert hl.path("users", "users") == "users"
|
|
assert hl.path("bob", "users.bob") == f"{MARK}bob{UNMARK}"
|
|
|
|
|
|
def test_highlighter_meta_marks_action_and_user():
|
|
record = ChangeRecord(ts=TS, a="create_user", u="admin", diff={"x": 1})
|
|
hl = evaluate(record, {}, _patterns("create", "ADM"))
|
|
assert hl is not None
|
|
assert hl.meta("create_user", "action") == f"{MARK}create{UNMARK}_user"
|
|
assert hl.meta("admin", "user") == f"{MARK}adm{UNMARK}in"
|
|
assert hl.meta("extra", "other") == "extra"
|
|
|
|
|
|
def test_matches_snapshot_uses_change_record_matching():
|
|
state = {"users": {"alice": {"email": "alice@example.com", "age": 30}}}
|
|
assert matches_snapshot(state, _patterns("users.alice"))
|
|
assert matches_snapshot(state, _patterns("alice@example"))
|
|
assert matches_snapshot(state, _patterns("age=30"))
|
|
assert matches_snapshot(state, _patterns("users.alice", "=30"))
|
|
assert not matches_snapshot(state, _patterns("bob"))
|
|
assert not matches_snapshot(state, _patterns("3")) # not a full number match
|
|
assert not matches_snapshot(state, _patterns("alice", "missing"))
|
|
assert not matches_snapshot({}, _patterns("alice"))
|
|
|
|
|
|
def test_matches_snapshot_supports_logfmt_prettified_values():
|
|
state = {"when": 1767225600}
|
|
|
|
def logfmt(value, path):
|
|
return "2026-01-01" if value == 1767225600 else None
|
|
|
|
assert matches_snapshot(state, _patterns("2026"), logfmt=logfmt)
|
|
assert matches_snapshot(state, _patterns("1767225600"), logfmt=logfmt)
|
|
assert not matches_snapshot(state, _patterns("2026"))
|
|
|
|
|
|
def test_entry_dataclass_holds_anchor_for_deletes():
|
|
entry = _Entry(["a", "b"], 1, "1", anchor=["a"])
|
|
assert entry.anchor == ["a"]
|
|
|
|
|
|
def _write_db(path, changes, state=None):
|
|
serializer = JsonSerializer()
|
|
framer = LineFramer()
|
|
snapshot = Snapshot(ts=TS, v=1, state=state or {})
|
|
data = framer.frame_snapshot(serializer.encode(snapshot), record_offset=0)
|
|
for change in changes:
|
|
data += framer.frame_change(serializer.encode(change), record_offset=0)
|
|
path.write_bytes(data)
|
|
|
|
|
|
def _sample_changes():
|
|
return [
|
|
ChangeRecord(
|
|
ts=TS,
|
|
a="create_alice",
|
|
u="admin",
|
|
diff={
|
|
"users": {
|
|
"alice": {"email": "alice@example.com", "admin": True},
|
|
}
|
|
},
|
|
),
|
|
ChangeRecord(
|
|
ts=TS,
|
|
a="update_alice",
|
|
u="admin",
|
|
diff={"users": {"alice": {"age": 30}}},
|
|
),
|
|
ChangeRecord(
|
|
ts=TS,
|
|
a="create_bob",
|
|
u="bob",
|
|
diff={"users": {"bob": {"email": "bob@example.com"}}},
|
|
),
|
|
]
|
|
|
|
|
|
def _run_cli(tmp_path, capsys, monkeypatch, changes, *args, color=False, state=None):
|
|
if color:
|
|
monkeypatch.setenv("FORCE_COLOR", "1")
|
|
monkeypatch.delenv("NO_COLOR", raising=False)
|
|
else:
|
|
monkeypatch.setenv("NO_COLOR", "1")
|
|
monkeypatch.delenv("FORCE_COLOR", raising=False)
|
|
path = tmp_path / "test.kantadb"
|
|
_write_db(path, changes, state=state)
|
|
code = main([str(path), *args])
|
|
assert code == 0
|
|
return capsys.readouterr().err
|
|
|
|
|
|
def test_cli_grep_filters_records(tmp_path, capsys, monkeypatch):
|
|
"""Only matching change records are printed."""
|
|
err = _run_cli(tmp_path, capsys, monkeypatch, _sample_changes(), "--grep", "alice")
|
|
assert "create_alice" in err
|
|
assert "update_alice" in err
|
|
assert "create_bob" not in err
|
|
assert "bob@example.com" not in err
|
|
|
|
|
|
def test_cli_grep_prints_entire_transaction(tmp_path, capsys, monkeypatch):
|
|
"""A match prints the whole record, not just the matching line."""
|
|
err = _run_cli(
|
|
tmp_path, capsys, monkeypatch, _sample_changes(), "--grep", "users.alice.email"
|
|
)
|
|
assert "create_alice" in err
|
|
# Non-matching lines of the same record are printed too.
|
|
assert "admin" in err and "true" in err
|
|
# The update record does not contain the path.
|
|
assert "update_alice" not in err
|
|
|
|
|
|
def test_cli_grep_repeated_patterns_must_all_match(tmp_path, capsys, monkeypatch):
|
|
"""Repeated --grep options are ANDed within the same record."""
|
|
err = _run_cli(
|
|
tmp_path,
|
|
capsys,
|
|
monkeypatch,
|
|
_sample_changes(),
|
|
"--grep",
|
|
"alice",
|
|
"--grep",
|
|
"=alice@example.com",
|
|
)
|
|
assert "create_alice" in err
|
|
# update_alice matches 'alice' but has no email value.
|
|
assert "update_alice" not in err
|
|
|
|
err = _run_cli(
|
|
tmp_path,
|
|
capsys,
|
|
monkeypatch,
|
|
_sample_changes(),
|
|
"--grep",
|
|
"alice",
|
|
"--grep",
|
|
"bob",
|
|
)
|
|
assert "create_alice" not in err
|
|
assert "create_bob" not in err
|
|
|
|
|
|
def test_cli_grep_no_match_exits_zero(tmp_path, capsys, monkeypatch):
|
|
"""No matching records is not an error; non-matching snapshots are hidden."""
|
|
err = _run_cli(tmp_path, capsys, monkeypatch, _sample_changes(), "--grep", "nobody")
|
|
assert "snapshot s0" not in err
|
|
assert "create_alice" not in err
|
|
assert "create_bob" not in err
|
|
|
|
|
|
def test_cli_grep_snapshot_prints_only_when_state_matches(
|
|
tmp_path, capsys, monkeypatch
|
|
):
|
|
"""Snapshots are matched against their full state like change records."""
|
|
state = {"users": {"alice": {"email": "alice@example.com", "age": 30}}}
|
|
|
|
err = _run_cli(
|
|
tmp_path,
|
|
capsys,
|
|
monkeypatch,
|
|
_sample_changes(),
|
|
"--grep",
|
|
"alice",
|
|
state=state,
|
|
)
|
|
assert "snapshot s0" in err
|
|
|
|
err = _run_cli(
|
|
tmp_path,
|
|
capsys,
|
|
monkeypatch,
|
|
_sample_changes(),
|
|
"--grep",
|
|
"users.alice.age=30",
|
|
state=state,
|
|
)
|
|
assert "snapshot s0" in err
|
|
|
|
# A pattern matching nothing in the state suppresses the snapshot.
|
|
err = _run_cli(
|
|
tmp_path,
|
|
capsys,
|
|
monkeypatch,
|
|
_sample_changes(),
|
|
"--grep",
|
|
"bob",
|
|
state=state,
|
|
)
|
|
assert "snapshot s0" not in err
|
|
|
|
# Repeated patterns are ANDed within the snapshot state too.
|
|
err = _run_cli(
|
|
tmp_path,
|
|
capsys,
|
|
monkeypatch,
|
|
_sample_changes(),
|
|
"--grep",
|
|
"alice",
|
|
"--grep",
|
|
"missing",
|
|
state=state,
|
|
)
|
|
assert "snapshot s0" not in err
|
|
|
|
|
|
def test_cli_grep_path_value_forms(tmp_path, capsys, monkeypatch):
|
|
"""The 'path=value', 'path=' and '=value' forms restrict the match side."""
|
|
err = _run_cli(
|
|
tmp_path, capsys, monkeypatch, _sample_changes(), "--grep", "users.*.admin=true"
|
|
)
|
|
assert "create_alice" in err
|
|
assert "create_bob" not in err
|
|
|
|
err = _run_cli(
|
|
tmp_path, capsys, monkeypatch, _sample_changes(), "--grep", "users.alice.age="
|
|
)
|
|
assert "update_alice" in err
|
|
assert "create_alice" not in err
|
|
|
|
err = _run_cli(
|
|
tmp_path, capsys, monkeypatch, _sample_changes(), "--grep", "=bob@example.com"
|
|
)
|
|
assert "create_bob" in err
|
|
assert "create_alice" not in err
|
|
|
|
|
|
def test_cli_grep_path_elements_match_in_full(tmp_path, capsys, monkeypatch):
|
|
"""'users' does not match a 'foousers' element; 'us*' does."""
|
|
changes = [ChangeRecord(ts=TS, a="trap", diff={"foousers": {"note": "x"}})]
|
|
err = _run_cli(tmp_path, capsys, monkeypatch, changes, "--grep", "users")
|
|
assert "trap" not in err
|
|
|
|
err = _run_cli(tmp_path, capsys, monkeypatch, changes, "--grep", "*users")
|
|
assert "trap" in err
|
|
|
|
|
|
def _shared_email_changes():
|
|
return [
|
|
ChangeRecord(
|
|
ts=TS,
|
|
a="create_alice",
|
|
u="admin",
|
|
diff={"users": {"alice": {"email": "shared@example.com", "admin": True}}},
|
|
),
|
|
ChangeRecord(
|
|
ts=TS,
|
|
a="create_bob",
|
|
u="admin",
|
|
diff={"users": {"bob": {"email": "shared@example.com"}}},
|
|
),
|
|
ChangeRecord(
|
|
ts=TS,
|
|
a="delete_bob",
|
|
u="admin",
|
|
diff={"users": {"$delete": "bob"}},
|
|
),
|
|
]
|
|
|
|
|
|
def _lines_with(err: str, text: str) -> list[str]:
|
|
return [line for line in err.splitlines() if text in strip_ansi(line)]
|
|
|
|
|
|
def test_cli_highlight_marks_exactly_the_matched_regions(tmp_path, capsys, monkeypatch):
|
|
"""A path-side match lights only the matched elements, not the value."""
|
|
err = _run_cli(
|
|
tmp_path,
|
|
capsys,
|
|
monkeypatch,
|
|
_shared_email_changes(),
|
|
"--grep",
|
|
"users.alice.email",
|
|
color=True,
|
|
)
|
|
(alice_line,) = _lines_with(err, "shared@example.com")
|
|
assert f"{MARK}alice{UNMARK}" in alice_line
|
|
assert f"{MARK}email{UNMARK}" in alice_line
|
|
# The value itself did not match, so it is not highlighted.
|
|
assert f"{MARK}shared@example.com{UNMARK}" not in alice_line
|
|
(header_line,) = _lines_with(err, "users =")
|
|
assert f"{MARK}users{UNMARK}" in header_line
|
|
assert "create_bob" not in err
|
|
|
|
|
|
def test_cli_highlight_value_matches_on_all_matching_lines(
|
|
tmp_path, capsys, monkeypatch
|
|
):
|
|
"""A value-side match lights the value on every line that genuinely matched."""
|
|
err = _run_cli(
|
|
tmp_path,
|
|
capsys,
|
|
monkeypatch,
|
|
_shared_email_changes(),
|
|
"--grep",
|
|
"=shared@example.com",
|
|
color=True,
|
|
)
|
|
lines = _lines_with(err, "shared@example.com")
|
|
assert len(lines) == 2 # alice's and bob's records both matched
|
|
for line in lines:
|
|
assert f"{MARK}shared@example.com{UNMARK}" in line
|
|
# The deletion of bob matched by its previous (removed) value: the
|
|
# deletion marker is lit, not the deleted path.
|
|
(delete_line,) = _lines_with(err, "✗")
|
|
assert f"{MARK}✗{UNMARK}" in delete_line
|
|
assert f"{MARK}bob{UNMARK}" not in delete_line
|
|
assert f"{MARK}users{UNMARK}" not in delete_line
|
|
|
|
|
|
def test_cli_highlight_merges_overlapping_matches(tmp_path, capsys, monkeypatch):
|
|
"""Overlapping matches from different patterns form one continuous mark."""
|
|
err = _run_cli(
|
|
tmp_path,
|
|
capsys,
|
|
monkeypatch,
|
|
_shared_email_changes(),
|
|
"--grep",
|
|
"shared",
|
|
"--grep",
|
|
"red@exam",
|
|
color=True,
|
|
)
|
|
lines = _lines_with(err, "shared@example.com")
|
|
assert len(lines) == 2 # both records genuinely match both patterns
|
|
for line in lines:
|
|
assert f"{MARK}shared@exam{UNMARK}ple.com" in line
|
|
|
|
|
|
def test_cli_highlight_full_scalar_and_header_fields(tmp_path, capsys, monkeypatch):
|
|
"""Scalars are marked in full; matched action/user substrings are marked."""
|
|
err = _run_cli(
|
|
tmp_path,
|
|
capsys,
|
|
monkeypatch,
|
|
_shared_email_changes(),
|
|
"--grep",
|
|
"admin=true",
|
|
"--grep",
|
|
"create_al",
|
|
color=True,
|
|
)
|
|
(line,) = _lines_with(err, "shared@example.com")
|
|
assert f"{MARK}true{UNMARK}" in line
|
|
(header,) = _lines_with(err, "create_alice")
|
|
assert f"{MARK}create_al{UNMARK}ice" in header
|
|
# Only the record matching both patterns is printed.
|
|
assert "create_bob" not in err
|
|
assert "delete_bob" not in err
|
|
|
|
|
|
def test_evaluate_matches_prettified_and_raw_value_forms():
|
|
"""With logfmt, both the raw and the prettified form of a value match."""
|
|
record = ChangeRecord(ts=TS, a="set", diff={"when": 1767225600})
|
|
|
|
def logfmt(value, path):
|
|
return "2026-01-01" if value == 1767225600 else None
|
|
|
|
# The prettified form matches; the needle is located in the display text.
|
|
hl = evaluate(record, {}, _patterns("2026"), logfmt=logfmt)
|
|
assert hl is not None
|
|
assert hl.value("2026-01-01", "when") == f"{MARK}2026{UNMARK}-01-01"
|
|
# The raw form matches too; its needle is absent from the displayed
|
|
# text, so the whole displayed value is marked.
|
|
hl = evaluate(record, {}, _patterns("1767225600"), logfmt=logfmt)
|
|
assert hl is not None
|
|
assert hl.value("2026-01-01", "when") == f"{MARK}2026-01-01{UNMARK}"
|
|
# Without logfmt the raw value is matched and marked precisely.
|
|
hl = evaluate(record, {}, _patterns("1767225600"))
|
|
assert hl.value("1767225600", "when") == f"{MARK}1767225600{UNMARK}"
|
|
# Neither form matches.
|
|
assert evaluate(record, {}, _patterns("1999"), logfmt=logfmt) is None
|
|
|
|
|
|
def test_evaluate_matches_prettified_user():
|
|
"""The user field matches both the raw id and the logfmt-resolved name."""
|
|
|
|
def logfmt(value, path):
|
|
return "Alice Admin" if path == _USER_PATH else None
|
|
|
|
record = ChangeRecord(ts=TS, a="set", u="u123", diff={"x": 1})
|
|
hl = evaluate(record, {}, _patterns("alice"), logfmt=logfmt)
|
|
assert hl is not None
|
|
assert hl.meta("Alice Admin", "user") == f"{MARK}Alice{UNMARK} Admin"
|
|
# A raw-only user match marks the whole displayed name.
|
|
hl = evaluate(record, {}, _patterns("u123"), logfmt=logfmt)
|
|
assert hl is not None
|
|
assert hl.meta("Alice Admin", "user") == f"{MARK}Alice Admin{UNMARK}"
|
|
# The action is never prettified.
|
|
assert hl.meta("set", "action") == "set"
|
|
|
|
|
|
def test_evaluate_without_logfmt_matches_raw_only():
|
|
record = ChangeRecord(ts=TS, a="set", u="u123", diff={"when": 1767225600})
|
|
assert evaluate(record, {}, _patterns("1767225600"))
|
|
assert evaluate(record, {}, _patterns("2026")) is None
|
|
assert evaluate(record, {}, _patterns("alice")) is None
|
|
|
|
|
|
def test_cli_grep_matches_prettified_forms_with_kanta_object(
|
|
tmp_path, capsys, monkeypatch
|
|
):
|
|
"""End to end: a -k object's logfmt formatter doubles the match surface."""
|
|
db_path = tmp_path / "test.kantadb"
|
|
module = tmp_path / "dbmod.py"
|
|
module.write_text(
|
|
"from typing import Any\n"
|
|
"from kanta import Kanta\n"
|
|
f"kanta = Kanta({str(db_path)!r}, {{}}, type=dict)\n"
|
|
"@kanta.logfmt\n"
|
|
"def pretty(value: Any, path: str) -> str | None:\n"
|
|
" if path == 'when':\n"
|
|
" return 'Nov 3, 2025'\n"
|
|
" if path == '$user':\n"
|
|
" return 'Alice Admin'\n"
|
|
" return None\n"
|
|
)
|
|
changes = [ChangeRecord(ts=TS, a="set", u="u123", diff={"when": 1767225600})]
|
|
kanta_args = ("-k", str(module))
|
|
|
|
# A term matching only the prettified value finds the record.
|
|
err = _run_cli(
|
|
tmp_path, capsys, monkeypatch, changes, *kanta_args, "--grep", "nov", color=True
|
|
)
|
|
(line,) = _lines_with(err, "Nov 3, 2025")
|
|
assert f"{MARK}Nov{UNMARK} 3, 2025" in line
|
|
|
|
# A term matching only the raw value marks the whole prettified display.
|
|
err = _run_cli(
|
|
tmp_path,
|
|
capsys,
|
|
monkeypatch,
|
|
changes,
|
|
*kanta_args,
|
|
"--grep",
|
|
"1767225600",
|
|
color=True,
|
|
)
|
|
(line,) = _lines_with(err, "Nov 3, 2025")
|
|
assert f"{MARK}Nov 3, 2025{UNMARK}" in line
|
|
|
|
# The prettified user matches, and the raw id marks the whole display.
|
|
err = _run_cli(
|
|
tmp_path,
|
|
capsys,
|
|
monkeypatch,
|
|
changes,
|
|
*kanta_args,
|
|
"--grep",
|
|
"alice",
|
|
color=True,
|
|
)
|
|
(header,) = _lines_with(err, "Alice Admin")
|
|
assert f"{MARK}Alice{UNMARK} Admin" in header
|
|
err = _run_cli(
|
|
tmp_path,
|
|
capsys,
|
|
monkeypatch,
|
|
changes,
|
|
*kanta_args,
|
|
"--grep",
|
|
"u123",
|
|
color=True,
|
|
)
|
|
(header,) = _lines_with(err, "Alice Admin")
|
|
assert f"{MARK}Alice Admin{UNMARK}" in header
|
|
|
|
# Without -k there is no prettified form to match.
|
|
err = _run_cli(tmp_path, capsys, monkeypatch, changes, "--grep", "nov")
|
|
assert not _lines_with(err, "u123")
|
|
err = _run_cli(tmp_path, capsys, monkeypatch, changes, "--grep", "1767225600")
|
|
assert _lines_with(err, "u123")
|