feat(rawcli): per-byte base intermixed entry; fix MFC hf.mf; help + strict mixing
Entry now tracks each raw byte's base, so hex and binary can share one line with a clean display: `30` (hex) then Ctrl-/ then `00000100` (binary) shows `30 00000100` and sends 0x30 0x04. The toggle only changes the base of the NEXT byte — it never rewrites what you've typed. Each byte auto-seals at its base width (2 hex / 8 binary); the next digit starts a fresh byte in the current mode; a digit invalid for its byte's base is dropped. Applies to raw byte entry only (function args stay base-10). - entry.type_digit / is_byte_line / reconcile_bases carry the logic (pure, unit-tested); the digit and backspace key bindings maintain session.byte_bases; the toggle just flips the mode. - parser.parse_bytes/parse_line take per-byte bases (explicit 0x/0b still wins). A line mixing a command with raw bytes (`READ 30`) raises instead of transmitting. - completer parses the opcode token in its own base, so `30`+binary still autocompletes the page. - help documents all of it (per-byte base, toggle, auto-seal, base-10 args, no command/byte mix). Also fixes a real bug found on hardware: the MFC catalog called hf.mfc (nonexistent) — it's hf.mf. Hardware-verified on a MIFARE Classic 1K: identify, CHK(0)=FFFFFFFFFFFF, READ(0) returns the manufacturer block, READ(4)/READ(1) the data blocks. Intermixed entry verified end-to-end in the live prompt (30 00000100 -> 0x30 0x04). 1351 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -19,15 +19,26 @@ from .trace_view import render_exchange
|
||||
_HELP = """\
|
||||
rawcli — raw command interface
|
||||
|
||||
help show this help
|
||||
help [cmd] show this help (or one command's usage)
|
||||
identify probe the field; sets HF/LF + transponder, keeps the connection
|
||||
transponder show the identified transponder
|
||||
tlv [bytes] decode / edit a TLV (NDEF) blob
|
||||
close drop the tag connection
|
||||
quit | exit | q leave rawcli
|
||||
<hex/bin ...> send raw bytes to the tag (CRC auto-appended on 14a); trace prints above
|
||||
NAME(args) run a command from the identified tag (READ(4), GET_VERSION()) — listed below
|
||||
|
||||
Type hex as "30 04" or "3004"; Ctrl-/ toggles hex/binary entry (0x/0b). Function-style
|
||||
commands (READ(4), GET_VERSION()) come from the identified tag — listed below."""
|
||||
Entry:
|
||||
* Raw bytes are hex by default ("30 04" or "3004" auto-spaces into bytes).
|
||||
* Ctrl-/ (or Ctrl-t) toggles hex (0x) / binary (0b) entry — shown in the breadcrumb.
|
||||
* Base is tracked PER BYTE: the toggle only changes the base of the NEXT byte, it never
|
||||
rewrites what you've typed. So `30` then toggle then `00000100` gives 30 00000100 and
|
||||
sends 0x30 0x04. A byte auto-seals at its width (2 hex / 8 binary) and the next digit
|
||||
starts a fresh byte. A 0x/0b prefix on one token overrides the mode for that token.
|
||||
* Function-call arguments are base-10 unless prefixed: READ(4) = page 4, READ(0x2B) = hex.
|
||||
* Suggestions (commands, opcodes by value, and the tag's memory map) appear in the
|
||||
completion menu as you type — in whichever base you're entering.
|
||||
* You can't mix a command and raw bytes on one line — that's an error, not a transmit."""
|
||||
|
||||
|
||||
def _connect(port):
|
||||
@@ -99,10 +110,12 @@ def dispatch(state: RawSession, line: str, out=print) -> bool:
|
||||
"""Handle one input line. ``out`` prints a (possibly ANSI-colored) string. Returns False to
|
||||
exit the REPL, True to keep going. Testable without a live prompt (default ``out=print``)."""
|
||||
try:
|
||||
cmd = parse_line(line, state.entry_mode)
|
||||
cmd = parse_line(line, state.entry_mode, state.byte_bases)
|
||||
except ValueError as exc:
|
||||
out(f"rawcli: {exc}")
|
||||
return True
|
||||
finally:
|
||||
state.byte_bases = [] # the line is consumed — start the next one fresh
|
||||
|
||||
if cmd.kind == "control":
|
||||
if cmd.name in ("quit", "exit", "q"):
|
||||
|
||||
@@ -100,7 +100,7 @@ TYPE2 = _catalog(
|
||||
|
||||
# ---- MIFARE Classic ---------------------------------------------------------------------------
|
||||
# Classic reads/writes are gated by a crypto1 auth per sector, so — unlike NTAG — a bare 0x30 does
|
||||
# nothing. These run via hf.mfc, which does the auth + block op in firmware. build() still exposes
|
||||
# nothing. These run via hf.mf, which does the auth + block op in firmware. build() still exposes
|
||||
# the wire opcode (0x30/0xA0) for hex/binary completion. Key defaults to the transport key
|
||||
# FFFFFFFFFFFF / key A; override as READ(<block>, <12-hex key>, A|B).
|
||||
_MFC_KEYTYPE = {"A": 0, "B": 1, "0": 0, "1": 1}
|
||||
@@ -114,7 +114,7 @@ def _kt(keytype) -> int:
|
||||
|
||||
|
||||
def _mfc_read(dev, block, key="FFFFFFFFFFFF", keytype="A"):
|
||||
r = dev.hf.mfc.rdbl(_int(block), key=key, key_type=_kt(keytype))
|
||||
r = dev.hf.mf.rdbl(_int(block), key=key, key_type=_kt(keytype))
|
||||
if isinstance(r, dict) and r.get("success"):
|
||||
return f"block {_int(block)} = {r['data']} (key {str(keytype).upper()})"
|
||||
err = r.get("error") if isinstance(r, dict) else "?"
|
||||
@@ -122,13 +122,13 @@ def _mfc_read(dev, block, key="FFFFFFFFFFFF", keytype="A"):
|
||||
|
||||
|
||||
def _mfc_write(dev, block, data, key="FFFFFFFFFFFF", keytype="A"):
|
||||
r = dev.hf.mfc.wrbl(_int(block), _hex(data).ljust(16, b"\x00")[:16], key=key, key_type=_kt(keytype))
|
||||
r = dev.hf.mf.wrbl(_int(block), _hex(data).ljust(16, b"\x00")[:16], key=key, key_type=_kt(keytype))
|
||||
ok = isinstance(r, dict) and r.get("success")
|
||||
return f"WRITE block {_int(block)} <- {_hex(data).hex()}: {'ok' if ok else 'failed'} (key {str(keytype).upper()})"
|
||||
|
||||
|
||||
def _mfc_chk(dev, block, keytype="A"):
|
||||
r = dev.hf.mfc.chk(_int(block), _MFC_KEYS, key_type=_kt(keytype))
|
||||
r = dev.hf.mf.chk(_int(block), _MFC_KEYS, key_type=_kt(keytype))
|
||||
if isinstance(r, dict) and r.get("found"):
|
||||
return f"block {_int(block)} key {str(keytype).upper()} = {r['key']}"
|
||||
return f"CHK block {_int(block)} key {str(keytype).upper()}: no key from the default list works"
|
||||
|
||||
@@ -70,8 +70,12 @@ class RawCompleter(Completer):
|
||||
catalog = catalog_for(self._session)
|
||||
if catalog is None:
|
||||
return False
|
||||
# parse the opcode byte in ITS base — the base it was typed in, not the current entry mode
|
||||
# (after `30` in hex you toggle to binary to type the page byte; `30` is still hex)
|
||||
bases = getattr(self._session, "byte_bases", []) or []
|
||||
base = bases[0] if bases else getattr(self._session, "entry_mode", "hex")
|
||||
try:
|
||||
b = parse_token(tok, getattr(self._session, "entry_mode", "hex"))
|
||||
b = parse_token(tok, base)
|
||||
except ValueError:
|
||||
return False
|
||||
if len(b) != 1:
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
"""Numeric entry helpers: byte-group spacing for hex/binary input and the prompt_toolkit key
|
||||
bindings that toggle entry mode (Ctrl-/) and auto-space digits as you type.
|
||||
"""Numeric entry: per-byte base-aware input for raw hex/binary, plus the prompt_toolkit key
|
||||
bindings that drive it.
|
||||
|
||||
The pure ``byte_space`` / ``group_size`` functions carry the logic and are unit-tested; the key
|
||||
bindings are thin wiring exercised in the live REPL.
|
||||
Raw byte entry mixes bases **per byte** — each byte remembers the base it was typed in, so
|
||||
``30`` (hex) and ``00000100`` (binary) can sit on one line as ``30 00000100`` and send ``0x30
|
||||
0x04``. The display stays clean (no ``0x``/``0b`` clutter); the base of each byte is tracked
|
||||
alongside the text in ``session.byte_bases``. A byte auto-seals at its base width (2 hex / 8
|
||||
binary) and the next digit starts a fresh byte in the current entry mode; toggling the mode
|
||||
(Ctrl-/) only changes the base of the *next* byte — it never rewrites what you've typed.
|
||||
|
||||
The pure ``type_digit`` / ``group_size`` / ``is_byte_line`` functions carry the logic and are
|
||||
unit-tested; the key bindings are thin wiring exercised in the live REPL.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -16,20 +23,39 @@ def group_size(mode: str) -> int:
|
||||
return 2 if mode == HEX else 8
|
||||
|
||||
|
||||
def byte_space(text: str, mode: str = HEX) -> str:
|
||||
"""Re-group a **pure** run of hex/binary digits into space-separated byte groups.
|
||||
def is_byte_line(text: str) -> bool:
|
||||
"""Whether ``text`` (spaces ignored) is a pure hex/binary byte run — i.e. raw entry, not a
|
||||
command word or function call. Empty is a byte line (you're about to type bytes)."""
|
||||
return all(ch in _DIGITS[HEX] for ch in text.replace(" ", ""))
|
||||
|
||||
Anything that isn't purely digits of the current mode is returned untouched — command words
|
||||
("help", "transponder"), function calls, and explicit ``0x``/``0b`` prefixed tokens are never
|
||||
regrouped. This is what keeps auto-spacing from mangling non-hex input."""
|
||||
low = text.strip().lower()
|
||||
if low.startswith("0x") or low.startswith("0b"):
|
||||
return text # explicit prefix — leave intact
|
||||
compact = text.replace(" ", "")
|
||||
if not compact or any(ch not in _DIGITS[mode] for ch in compact):
|
||||
return text # not a pure numeric run (e.g. a command)
|
||||
n = group_size(mode)
|
||||
return " ".join(compact[i:i + n] for i in range(0, len(compact), n))
|
||||
|
||||
def type_digit(text: str, bases: list[str], mode: str, digit: str) -> tuple[str, list[str]]:
|
||||
"""Apply one digit keystroke to a raw-byte line, base-aware. Returns ``(new_text, new_bases)``.
|
||||
|
||||
A byte seals at its base's width (2 hex / 8 binary); once full, the next digit starts a new
|
||||
byte in ``mode``. A digit that isn't valid for the byte it would land in is dropped (no
|
||||
change), so binary bytes can't gain hex digits and vice-versa."""
|
||||
d = digit.lower()
|
||||
groups = text.split(" ") if text else []
|
||||
aligned = len(bases) == len(groups)
|
||||
# extend the trailing byte if it's still open (present, aligned, below its base width)
|
||||
if groups and aligned and groups[-1] and len(groups[-1]) < group_size(bases[-1]):
|
||||
b = bases[-1]
|
||||
if d in _DIGITS[b]:
|
||||
groups[-1] += digit
|
||||
return " ".join(groups), list(bases)
|
||||
return text, list(bases) # wrong base for this byte — drop
|
||||
# otherwise begin a new byte in the current entry mode
|
||||
if d in _DIGITS[mode]:
|
||||
return (" ".join(groups + [digit]) if groups else digit), list(bases) + [mode]
|
||||
return text, list(bases)
|
||||
|
||||
|
||||
def reconcile_bases(text: str, bases: list[str]) -> list[str]:
|
||||
"""Trim ``bases`` to the number of byte groups left in ``text`` (after a backspace/edit). Only
|
||||
a best-effort end-alignment — enough for append + backspace, which is what raw entry does."""
|
||||
groups = [g for g in text.split(" ") if g]
|
||||
return list(bases[:len(groups)])
|
||||
|
||||
|
||||
#: keys that toggle entry mode. Ctrl-/ is emitted as Ctrl-_ (0x1F) by most terminals; c-t is an
|
||||
@@ -46,25 +72,36 @@ def _bind(kb, key, handler) -> None:
|
||||
|
||||
|
||||
def install_key_bindings(kb, session) -> None:
|
||||
"""Wire the entry-mode toggle (Ctrl-/ , via Ctrl-_ , plus Ctrl-t) and live auto-byte-spacing
|
||||
of digit input onto ``kb``. ``session`` is the :class:`RawSession` whose ``entry_mode`` is
|
||||
toggled and read. Never raises on an unknown key name."""
|
||||
"""Wire the entry-mode toggle (Ctrl-/ via Ctrl-_, plus Ctrl-t) and per-byte base-aware digit
|
||||
entry onto ``kb``. ``session`` is the :class:`RawSession` whose ``entry_mode`` is toggled/read
|
||||
and whose ``byte_bases`` records each raw byte's base. Never raises on an unknown key name."""
|
||||
from prompt_toolkit.document import Document
|
||||
|
||||
def _toggle(event):
|
||||
session.toggle_entry_mode()
|
||||
session.toggle_entry_mode() # only flips the mode — the next byte you type uses it
|
||||
event.app.invalidate() # redraw the breadcrumb
|
||||
|
||||
for key in TOGGLE_KEYS:
|
||||
_bind(kb, key, _toggle)
|
||||
|
||||
def _regroup_after(event):
|
||||
def _digit(event):
|
||||
buf = event.current_buffer
|
||||
buf.insert_text(event.data)
|
||||
# only auto-space a bare digit run (no prefix), matching byte_space's guard
|
||||
grouped = byte_space(buf.text, session.entry_mode)
|
||||
if grouped != buf.text:
|
||||
buf.document = Document(grouped, len(grouped))
|
||||
d = event.data
|
||||
text = buf.text
|
||||
# only base-group a raw byte line; a command/function-call being typed inserts verbatim
|
||||
if not is_byte_line(text.replace(" ", "") + d):
|
||||
buf.insert_text(d)
|
||||
return
|
||||
new_text, session.byte_bases = type_digit(text, session.byte_bases, session.entry_mode, d)
|
||||
buf.document = Document(new_text, len(new_text))
|
||||
|
||||
for ch in sorted(_DIGITS[HEX] | _DIGITS[BIN]):
|
||||
_bind(kb, ch, _regroup_after)
|
||||
_bind(kb, ch, _digit)
|
||||
|
||||
def _backspace(event):
|
||||
buf = event.current_buffer
|
||||
buf.delete_before_cursor()
|
||||
session.byte_bases = reconcile_bases(buf.text, session.byte_bases)
|
||||
|
||||
for key in ("backspace", "c-h"):
|
||||
_bind(kb, key, _backspace)
|
||||
|
||||
@@ -68,11 +68,15 @@ def parse_token(token: str, default_mode: str = HEX) -> bytes:
|
||||
raise ValueError(f"invalid binary token {token!r}") from None
|
||||
|
||||
|
||||
def parse_bytes(text: str, default_mode: str = HEX) -> bytes:
|
||||
"""Parse a whitespace-separated raw payload (intermixed 0x/0b tokens allowed)."""
|
||||
def parse_bytes(text: str, default_mode: str = HEX, bases: list[str] | None = None) -> bytes:
|
||||
"""Parse a whitespace-separated raw payload. Each token is parsed in its own base: an explicit
|
||||
``0x``/``0b`` prefix wins, else ``bases[i]`` (the base that token was typed in, when known),
|
||||
else ``default_mode``. A token that isn't a valid byte raises ``ValueError`` — so a line that
|
||||
mixes a command with raw bytes (``READ 30``) errors here instead of transmitting."""
|
||||
out = bytearray()
|
||||
for tok in text.split():
|
||||
out += parse_token(tok, default_mode)
|
||||
for i, tok in enumerate(text.split()):
|
||||
mode = bases[i] if bases and i < len(bases) else default_mode
|
||||
out += parse_token(tok, mode)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
@@ -80,8 +84,9 @@ def _split_args(inside: str) -> list[str]:
|
||||
return [a.strip() for a in inside.split(",")] if inside.strip() else []
|
||||
|
||||
|
||||
def parse_line(line: str, default_mode: str = HEX) -> Command:
|
||||
"""Classify and parse a full input line."""
|
||||
def parse_line(line: str, default_mode: str = HEX, bases: list[str] | None = None) -> Command:
|
||||
"""Classify and parse a full input line. ``bases`` gives the per-byte base for a raw line
|
||||
(from the live entry tracker); a raw line that isn't pure bytes raises ``ValueError``."""
|
||||
stripped = line.strip()
|
||||
m = _CALL_RE.match(stripped)
|
||||
if m:
|
||||
@@ -91,4 +96,4 @@ def parse_line(line: str, default_mode: str = HEX) -> Command:
|
||||
if verb in CONTROL_VERBS:
|
||||
rest = first[1].split() if len(first) > 1 else []
|
||||
return Command("control", name=verb, args=rest)
|
||||
return Command("raw", payload=parse_bytes(stripped, default_mode))
|
||||
return Command("raw", payload=parse_bytes(stripped, default_mode, bases))
|
||||
|
||||
@@ -32,6 +32,7 @@ class RawSession:
|
||||
self.transponder: str | None = None
|
||||
self.protocol: str = "hf14a" # wire protocol for raw exchanges (set by identify)
|
||||
self.entry_mode: str = HEX
|
||||
self.byte_bases: list[str] = [] # base of each raw byte typed on the current line
|
||||
self.connected: bool = device is not None
|
||||
|
||||
@property
|
||||
|
||||
@@ -9,7 +9,7 @@ from pm3py.cli.main import build_parser, main
|
||||
from pm3py.cli.rawcli.session import RawSession
|
||||
from pm3py.cli.rawcli.trace_view import render_exchange
|
||||
from pm3py.cli.rawcli.parser import parse_token, parse_bytes, parse_line
|
||||
from pm3py.cli.rawcli.entry import byte_space
|
||||
from pm3py.cli.rawcli.entry import type_digit, is_byte_line, reconcile_bases
|
||||
from pm3py.cli.rawcli.identify import identify, clear, name_14a, format_summary
|
||||
from pm3py.cli.rawcli.catalog import TYPE2, ISO15, T5577, LF_READONLY, MFC, catalog_for, catalog_for as _cf
|
||||
from pm3py.cli.rawcli.tlv import parse_tlv, format_tlv
|
||||
@@ -133,28 +133,40 @@ class TestParser:
|
||||
assert c.kind == "raw" and c.payload == b"\x30\x04"
|
||||
|
||||
|
||||
class TestByteSpace:
|
||||
def test_hex_grouping(self):
|
||||
assert byte_space("3004", "hex") == "30 04"
|
||||
assert byte_space("300", "hex") == "30 0"
|
||||
assert byte_space("30 04", "hex") == "30 04" # idempotent
|
||||
class TestEntry:
|
||||
def _type(self, keys):
|
||||
"""Replay (digit, mode) keystrokes through type_digit; return (text, bases)."""
|
||||
text, bases = "", []
|
||||
for digit, mode in keys:
|
||||
text, bases = type_digit(text, bases, mode, digit)
|
||||
return text, bases
|
||||
|
||||
def test_binary_grouping(self):
|
||||
assert byte_space("0011000000000100", "bin") == "00110000 00000100"
|
||||
def test_hex_auto_spacing(self):
|
||||
text, bases = self._type([(d, "hex") for d in "300412"])
|
||||
assert text == "30 04 12" and bases == ["hex", "hex", "hex"]
|
||||
|
||||
def test_prefixed_left_alone(self):
|
||||
assert byte_space("0x3004", "hex") == "0x3004" # explicit prefix not mangled
|
||||
assert byte_space("0b0011", "hex") == "0b0011"
|
||||
def test_binary_auto_spacing(self):
|
||||
text, bases = self._type([(d, "bin") for d in "0011000000000100"])
|
||||
assert text == "00110000 00000100" and bases == ["bin", "bin"]
|
||||
|
||||
def test_leaves_command_words_alone(self):
|
||||
# regression: auto-spacing must not mangle non-hex input (a-f in words used to break it)
|
||||
assert byte_space("help transponder", "hex") == "help transponder"
|
||||
assert byte_space("identify", "hex") == "identify"
|
||||
assert byte_space("close", "hex") == "close" # c,e are hex digits but l,o,s aren't
|
||||
assert byte_space("READ(4)", "hex") == "READ(4)"
|
||||
def test_per_byte_base_mix(self):
|
||||
# 30 in hex, then toggle to binary and type a binary byte -> keeps 30 hex, new byte binary
|
||||
text, bases = self._type([("3", "hex"), ("0", "hex")] + [(d, "bin") for d in "00000100"])
|
||||
assert text == "30 00000100" and bases == ["hex", "bin"]
|
||||
|
||||
def test_spaces_pure_hex(self):
|
||||
assert byte_space("deadbeef", "hex") == "de ad be ef"
|
||||
def test_invalid_digit_dropped(self):
|
||||
# a binary byte can't take a hex digit; a hex-only digit in binary mode is dropped
|
||||
assert type_digit("30 000", ["hex", "bin"], "bin", "3") == ("30 000", ["hex", "bin"])
|
||||
assert type_digit("", [], "bin", "f") == ("", []) # f invalid to start a bin byte
|
||||
|
||||
def test_is_byte_line(self):
|
||||
assert is_byte_line("30 00000100") and is_byte_line("deadbeef") and is_byte_line("")
|
||||
assert not is_byte_line("READ") and not is_byte_line("help") and not is_byte_line("30 x")
|
||||
|
||||
def test_reconcile_bases_after_backspace(self):
|
||||
assert reconcile_bases("30", ["hex", "bin"]) == ["hex"] # binary byte deleted
|
||||
assert reconcile_bases("30 0011", ["hex", "bin"]) == ["hex", "bin"]
|
||||
assert reconcile_bases("", ["hex"]) == []
|
||||
|
||||
|
||||
class TestDispatch:
|
||||
@@ -362,18 +374,18 @@ class TestCatalog:
|
||||
def test_mfc_authenticated_ops(self):
|
||||
# Classic READ/WRITE run via hf.mfc (crypto1 auth in firmware); default key FFFFFFFFFFFF/A
|
||||
dev = MagicMock()
|
||||
dev.hf.mfc.rdbl.return_value = {"success": True, "block": 4, "data": "aa" * 16}
|
||||
dev.hf.mfc.wrbl.return_value = {"success": True, "block": 4}
|
||||
dev.hf.mfc.chk.return_value = {"found": True, "key": "A0A1A2A3A4A5"}
|
||||
dev.hf.mf.rdbl.return_value = {"success": True, "block": 4, "data": "aa" * 16}
|
||||
dev.hf.mf.wrbl.return_value = {"success": True, "block": 4}
|
||||
dev.hf.mf.chk.return_value = {"found": True, "key": "A0A1A2A3A4A5"}
|
||||
assert MFC.get("READ").opcode() == 0x30 and MFC.get("WRITE").opcode() == 0xA0 # hint opcode
|
||||
assert MFC.get("READ").run is not None # executes, not raw exchange
|
||||
MFC.get("READ").run(dev, "4")
|
||||
dev.hf.mfc.rdbl.assert_called_with(4, key="FFFFFFFFFFFF", key_type=0) # default key A
|
||||
dev.hf.mf.rdbl.assert_called_with(4, key="FFFFFFFFFFFF", key_type=0) # default key A
|
||||
MFC.get("READ").run(dev, "4", "A0A1A2A3A4A5", "B")
|
||||
dev.hf.mfc.rdbl.assert_called_with(4, key="A0A1A2A3A4A5", key_type=1) # override key + B
|
||||
dev.hf.mf.rdbl.assert_called_with(4, key="A0A1A2A3A4A5", key_type=1) # override key + B
|
||||
assert "ok" in MFC.get("WRITE").run(dev, "4", "00" * 16)
|
||||
assert "A0A1A2A3A4A5" in MFC.get("CHK").run(dev, "3")
|
||||
dev.hf.mfc.rdbl.return_value = {"success": False, "error": 1}
|
||||
dev.hf.mf.rdbl.return_value = {"success": False, "error": 1}
|
||||
assert "failed" in MFC.get("READ").run(dev, "4") # clear failure line
|
||||
|
||||
def test_iso15_builds(self):
|
||||
@@ -756,3 +768,43 @@ class TestKeyBindings:
|
||||
kb = KeyBindings()
|
||||
install_key_bindings(kb, RawSession())
|
||||
assert len(kb.bindings) >= len(TOGGLE_KEYS)
|
||||
|
||||
def test_toggle_switches_mode_without_touching_buffer(self):
|
||||
# the toggle only flips the entry mode (for the NEXT byte); it never rewrites what's typed
|
||||
from types import SimpleNamespace
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
from prompt_toolkit.buffer import Buffer
|
||||
from pm3py.cli.rawcli.entry import install_key_bindings
|
||||
s = RawSession()
|
||||
kb = KeyBindings()
|
||||
install_key_bindings(kb, s)
|
||||
toggle = next(b for b in kb.bindings
|
||||
if getattr(b.keys[0], "value", b.keys[0]) in ("c-t", "c-_"))
|
||||
buf = Buffer()
|
||||
buf.text = "30"
|
||||
event = SimpleNamespace(current_buffer=buf, app=SimpleNamespace(invalidate=lambda: None), data="")
|
||||
toggle.handler(event)
|
||||
assert s.entry_mode == "bin" and buf.text == "30" # mode flipped, buffer intact
|
||||
|
||||
def test_digit_binding_tracks_per_byte_base(self):
|
||||
# replay keystrokes through the real digit binding: 30 (hex) then binary -> 30 00000100
|
||||
from types import SimpleNamespace
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
from prompt_toolkit.buffer import Buffer
|
||||
from pm3py.cli.rawcli.entry import install_key_bindings
|
||||
s = RawSession()
|
||||
kb = KeyBindings()
|
||||
install_key_bindings(kb, s)
|
||||
digit = {getattr(b.keys[0], "value", b.keys[0]): b for b in kb.bindings}
|
||||
buf = Buffer()
|
||||
app = SimpleNamespace(invalidate=lambda: None)
|
||||
|
||||
def press(ch):
|
||||
digit[ch].handler(SimpleNamespace(current_buffer=buf, app=app, data=ch))
|
||||
for ch in "30":
|
||||
press(ch)
|
||||
assert buf.text == "30" and s.byte_bases == ["hex"]
|
||||
s.toggle_entry_mode()
|
||||
for ch in "00000100":
|
||||
press(ch)
|
||||
assert buf.text == "30 00000100" and s.byte_bases == ["hex", "bin"]
|
||||
|
||||
Reference in New Issue
Block a user