Files
pm3py/tests/test_rawcli.py
michael 4b5df12a76 test(rawcli): end-to-end live-session regression for byte-line entry + completion
Drive the real PromptSession that run() builds (real key bindings + RawCompleter +
complete_while_typing) via a prompt_toolkit pipe input simulating TTY keystrokes, so
the actual rawcli input stack is exercised, not just the handler in isolation. Asserts
every input path yields the correct accepted line (hex byte line, binary via toggle,
mixed hex/binary, function call, command name) and that the completer is queried live
during binary entry (0 queries before the autocomplete fix, since buf.document= reset
complete_state).

prompt()'s internal asyncio.run() nulls the current-loop pointer; the helper saves and
restores it so the shared-loop `_run` convention in other test files is not poisoned.
Full suite stays green (1383).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:41:34 -07:00

996 lines
49 KiB
Python

"""rawcli Phase 1 — session state, breadcrumb, trace rendering, CLI dispatch. Hardware-free."""
import re
from unittest.mock import MagicMock
import pytest
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 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, _SLIX,
iso15_frame, iso15_flags_decode, catalog_for, catalog_for as _cf)
from pm3py.cli.rawcli.tlv import parse_tlv, format_tlv
from pm3py.cli.rawcli.completer import RawCompleter
from pm3py.cli.rawcli.app import dispatch
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
def _plain(s: str) -> str:
return _ANSI.sub("", s)
class TestBreadcrumb:
def test_default(self):
assert RawSession().breadcrumb() == "[raw / 0x]"
def test_toggle_to_binary(self):
s = RawSession()
assert s.toggle_entry_mode() == "bin"
assert s.entry_prefix == "0b"
assert s.breadcrumb() == "[raw / 0b]"
assert s.toggle_entry_mode() == "hex"
assert s.breadcrumb() == "[raw / 0x]"
def test_identified_fills_segments(self):
s = RawSession()
s.field = "hf"
s.transponder = "NTAG215"
assert s.breadcrumb() == "[raw / hf / NTAG215 / 0x]"
def test_field_only(self):
s = RawSession()
s.field = "lf"
assert s.breadcrumb() == "[raw / lf / 0x]"
def test_colored_matches_plain(self):
s = RawSession()
s.field, s.transponder = "hf", "NTAG213"
assert "\x1b[" in s.colored_breadcrumb() # actually colored
assert _plain(s.colored_breadcrumb()) == s.breadcrumb() # same content, ANSI stripped
def test_colored_mode_color_changes(self):
s = RawSession()
hex_prompt = s.colored_breadcrumb()
s.toggle_entry_mode()
assert hex_prompt != s.colored_breadcrumb() # hex vs binary colored differently
class TestTraceView:
def test_command_and_response_lines(self):
out = _plain(render_exchange(bytes([0x30, 0x04]),
bytes([0x00, 0x01, 0x02, 0x03]),
protocol="hf14a", is_tty=False))
assert "Reader → Tag" in out
assert "Tag → Reader" in out
assert "30 04" in out # command bytes shown
def test_command_only_when_no_response(self):
out = _plain(render_exchange(bytes([0x26]), None, protocol="hf14a", is_tty=False))
assert "Reader → Tag" in out
assert "Tag → Reader" not in out
def test_annotation_present_for_known_command(self):
# 0x30 = 14a READ; the decoder should annotate it (exact text may vary)
out = _plain(render_exchange(bytes([0x30, 0x04]), None, protocol="hf14a", is_tty=False))
assert "READ" in out.upper()
def test_accepts_hex_string_response(self):
# reader raw() returns `data` as a hex STRING — must not crash on bytes()
out = _plain(render_exchange(b"\x60", "0004040201000f03", protocol="hf14a", is_tty=False))
assert "60" in out and "00 04 04" in out
class TestCliDispatch:
def test_rawcli_subcommand_parsed(self):
args = build_parser().parse_args(["rawcli", "--port", "/dev/ttyACM0"])
assert args.command == "rawcli" and args.port == "/dev/ttyACM0"
def test_no_command_prints_help(self, capsys):
rc = main([])
assert rc == 1
assert "rawcli" in capsys.readouterr().out
class TestParser:
def test_hex_tokens(self):
assert parse_token("30") == b"\x30"
assert parse_token("0x3004") == b"\x30\x04"
assert parse_bytes("30 04") == b"\x30\x04"
assert parse_bytes("3004") == b"\x30\x04" # default hex mode
def test_binary_tokens(self):
assert parse_token("0b00110000") == b"\x30"
assert parse_token("00110000", "bin") == b"\x30"
def test_intermixed(self):
assert parse_bytes("0b00110000 0x04") == b"\x30\x04"
assert parse_bytes("0x30 00000100", "bin") == b"\x30\x04" # bare token uses mode
def test_bad_tokens(self):
with pytest.raises(ValueError):
parse_token("303") # odd hex
with pytest.raises(ValueError):
parse_token("0b0011") # not a whole byte
def test_classify_control(self):
c = parse_line("identify")
assert c.kind == "control" and c.name == "identify"
assert parse_line("help ntag215").kind == "control"
def test_classify_call(self):
c = parse_line("READ(4)")
assert c.kind == "call" and c.name == "READ" and c.args == ["4"]
c = parse_line("WRITE(4, 0x00112233)")
assert c.args == ["4", "0x00112233"]
assert parse_line("GET_DATA()").kind == "call"
def test_classify_raw(self):
c = parse_line("30 04")
assert c.kind == "raw" and c.payload == b"\x30\x04"
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_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_binary_auto_spacing(self):
text, bases = self._type([(d, "bin") for d in "0011000000000100"])
assert text == "00110000 00000100" and bases == ["bin", "bin"]
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_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:
def test_quit_returns_false(self):
assert dispatch(RawSession(), "quit") is False
def test_help_prints(self, capsys):
assert dispatch(RawSession(), "help") is True
assert "rawcli" in capsys.readouterr().out
def test_raw_renders_without_device(self, capsys):
assert dispatch(RawSession(), "30 04") is True
assert "30 04" in _plain(capsys.readouterr().out)
def test_bad_hex_reports(self, capsys):
assert dispatch(RawSession(), "0b0011") is True
assert "whole number of bytes" in capsys.readouterr().out
NTAG216_VERSION = bytes.fromhex("0004040201001303")
def _mock_device(scan14=None, scan15=None, version=None):
dev = MagicMock()
dev.hf.iso14a.scan.return_value = scan14 or {"found": False}
dev.hf.iso15.scan.return_value = scan15 or {"found": False}
dev.lf.search.return_value = None
dev.lf.t55.readbl.return_value = {"status": 0}
dev.lf.read.return_value = {"status": 0}
dev.lf.download_samples.return_value = bytes([128] * 2000) # flat -> no LF tag by default
dev.hf.iso14a.raw.return_value = {"raw": (version + b"\x99\x88") if version else None}
return dev
class TestIdentify:
def test_no_device(self):
s = RawSession()
r = identify(s)
assert r["found"] is False and s.field is None
def test_14a_sak_only_name(self):
# SAK != 0 -> named from AN10833 Table 4, no GET_VERSION
s = RawSession(device=_mock_device(
scan14={"found": True, "sak": 0x08, "atqa": "0400", "uid": "01020304"}))
r = identify(s)
assert r["found"] and s.field == "hf" and s.protocol == "hf14a"
assert s.transponder == "MIFARE Classic 1K"
def test_14a_getversion_exact(self):
s = RawSession(device=_mock_device(
scan14={"found": True, "sak": 0x00, "atqa": "4400", "uid": "04a1b2c3d4e5f6"},
version=NTAG216_VERSION))
r = identify(s)
assert s.transponder == "NTAG216" # exact, not "MIFARE UL/NTAG"
assert r["version"] == NTAG216_VERSION.hex()
s.device.hf.iso14a.raw.assert_called_with(b"\x60", flags=0x20) # real GET_VERSION+CRC
def test_14a_getversion_retries_flaky_link(self):
# a lost GET_VERSION shot must not drop us to the generic name — retry recovers the model
s = RawSession(device=_mock_device(
scan14={"found": True, "sak": 0x00, "atqa": "4400", "uid": "04a1b2c3d4e5f6"}))
s.device.hf.iso14a.raw.side_effect = [
{"raw": None}, # 1st shot lost
{"raw": None}, # 2nd shot lost
{"raw": NTAG216_VERSION + b"\x99\x88"}, # 3rd shot lands
]
identify(s)
assert s.transponder == "NTAG216" # recovered, not the generic name
assert s.device.hf.iso14a.raw.call_count == 3
def test_14a_getversion_fails_generic_still_resolves(self):
# GET_VERSION never answers -> generic family name, but the universal Type 2 pages still
# resolve a role (this is the path that silently broke before)
from pm3py.cli.rawcli.memory import page_role
s = RawSession(device=_mock_device(
scan14={"found": True, "sak": 0x00, "atqa": "4400", "uid": "04a1b2c3d4e5f6"}))
s.device.hf.iso14a.raw.return_value = {"raw": None}
identify(s)
assert s.transponder == "MIFARE Ultralight / NTAG"
assert page_role(s.transponder, 4) == "user memory"
def test_15693_when_e0_uid(self):
# E0 04 = NXP ICODE; no READ_SIGNATURE (mock) -> named ICODE SLIX, not generic ISO15693
s = RawSession(device=_mock_device(scan15={"found": True, "uid": "e004010203040506"}))
r = identify(s)
assert r["found"] and s.field == "hf" and s.protocol == "hf15"
assert s.transponder == "ICODE SLIX"
def test_15693_non_nxp_generic(self):
# a non-NXP E0 UID stays generic ISO15693
s = RawSession(device=_mock_device(scan15={"found": True, "uid": "e007010203040506"}))
identify(s)
assert s.transponder == "ISO15693"
def test_15693_rejects_bogus_uid(self):
# a flaky 14a miss can leave a non-E0 "uid" — must NOT be reported as a 15693 tag
s = RawSession(device=_mock_device(scan15={"found": True, "uid": "0000000000003d42"}))
r = identify(s)
assert r["found"] is False and s.transponder is None
def test_no_flat_lf(self):
# nothing on HF and a flat LF envelope must report not-found, not a bogus "LF tag"
assert identify(RawSession(device=_mock_device()))["found"] is False
def test_lf_wiring_sets_label(self, monkeypatch):
# the LF path folds pm3py.lf.identify_lf's result into the session (demod itself is
# covered in test_lf_demod.py); here we assert the rawcli wiring/label/summary.
import pm3py.lf
monkeypatch.setattr(pm3py.lf, "identify_lf", lambda dev, emit=None: {
"found": True, "field": "lf", "protocol": "lf", "chip": "T5577",
"config": "00148040", "emulating": "EM4100 / EM4102",
"emitted": {"protocol": "EM4100", "id_hex": "0102030405"},
"label": "T5577 (EM4100 / EM4102)"})
s = RawSession(device=_mock_device()) # HF finds nothing -> LF path
r = identify(s)
assert r["found"] and s.field == "lf" and s.protocol == "lf"
assert s.transponder == "T5577 (EM4100 / EM4102)"
assert r["config"] == "00148040" and r["id"] == "0102030405"
assert "config 0x00148040" in format_summary(r)
def test_lf_native_credential_label(self, monkeypatch):
import pm3py.lf
monkeypatch.setattr(pm3py.lf, "identify_lf", lambda dev, emit=None: {
"found": True, "field": "lf", "protocol": "lf", "chip": None, "config": None,
"emulating": None, "emitted": {"protocol": "EM4100", "id_hex": "1122334455"},
"label": "EM4100 1122334455"})
s = RawSession(device=_mock_device())
r = identify(s)
assert s.transponder == "EM4100 1122334455"
assert "id 1122334455" in format_summary(r)
def test_identify_clears_stale_result(self):
# a second identify that finds nothing must clear the previous tag from the session
dev = _mock_device(scan14={"found": True, "sak": 0x08, "atqa": "0400", "uid": "b978423d"})
s = RawSession(device=dev)
identify(s)
assert s.transponder == "MIFARE Classic 1K"
dev.hf.iso14a.scan.return_value = {"found": False} # tag removed
identify(s)
assert s.transponder is None and s.field is None # no stale data
def test_sak_names_from_an10833(self):
assert name_14a({"sak": 0x18}) == "MIFARE Classic 4K"
assert name_14a({"sak": 0x20}).startswith("ISO14443-4")
assert name_14a({"sak": 0x99}) == "ISO14443-A (SAK 99)"
def test_clear_forgets_tag(self):
s = RawSession(device=_mock_device(scan14={"found": True, "sak": 0x08, "uid": "01020304"}))
identify(s)
clear(s)
assert s.field is None and s.transponder is None and s.protocol == "hf14a"
class TestVersionId:
def test_exact_models(self):
from pm3py.cli.rawcli.identify import decode_version
assert decode_version(bytes.fromhex("0004040201001303")) == "NTAG216"
assert decode_version(bytes.fromhex("0004040201000f03")) == "NTAG213"
assert "Ultralight EV1" in decode_version(bytes.fromhex("0004030101000b03"))
def test_structural_range_for_unknown(self):
from pm3py.cli.rawcli.identify import decode_version
# unknown NTAG-family version -> product + honest size range (AN10833)
out = decode_version(bytes.fromhex("0004040201001503"))
assert out.startswith("NTAG (") and "B)" in out
def test_static_map_matches_models(self):
# guard against drift between the static version map and the transponder models
from pm3py.cli.rawcli.identify import _VERSION_MODELS
from pm3py.sim import (NTAG210, NTAG212, NTAG213, NTAG215, NTAG216,
MF0UL11, MF0UL21, NT3H2111, NT3H2211)
model_vbytes = {c._version_bytes.hex() for c in
(NTAG210, NTAG212, NTAG213, NTAG215, NTAG216,
MF0UL11, MF0UL21, NT3H2111, NT3H2211)}
assert set(_VERSION_MODELS) == model_vbytes
class TestControlCommands:
def test_identify_command(self, capsys):
s = RawSession(device=_mock_device(
scan14={"found": True, "sak": 0x00, "atqa": "4400", "uid": "04a1b2c3d4e5f6"},
version=NTAG216_VERSION))
dispatch(s, "identify")
out = _plain(capsys.readouterr().out)
assert "NTAG216" in out # exact model in the summary
assert "WUPA" in out and "GET_VERSION" in out # probe exchanges streamed to the trace
assert s.breadcrumb() == "[raw / hf / NTAG216 / 0x]"
def test_transponder_when_none(self, capsys):
dispatch(RawSession(), "transponder")
assert "run 'identify'" in capsys.readouterr().out
def test_close(self, capsys):
s = RawSession(device=_mock_device(scan14={"found": True, "sak": 0x08}))
dispatch(s, "identify")
dispatch(s, "close")
assert "closed" in capsys.readouterr().out
assert s.transponder is None
class TestCatalog:
def test_type2_builds(self):
assert TYPE2.get("READ").build("4") == b"\x30\x04"
assert TYPE2.get("read").build("0x04") == b"\x30\x04" # case + hex arg
assert TYPE2.get("FAST_READ").build("0", "3") == b"\x3A\x00\x03"
assert TYPE2.get("GET_VERSION").build() == b"\x60"
assert TYPE2.get("WRITE").build("4", "01020304") == b"\xA2\x04\x01\x02\x03\x04"
assert TYPE2.get("PWD_AUTH").build("FFFFFFFF") == b"\x1B\xFF\xFF\xFF\xFF"
# two-phase write builds a frame list: command frame + 16-byte data frame
assert TYPE2.get("COMPAT_WRITE").build("4", "0102") == [b"\xA0\x04", b"\x01\x02" + b"\x00" * 14]
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.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.mf.rdbl.assert_called_with(4, key="FFFFFFFFFFFF", key_type=0) # default key A
MFC.get("READ").run(dev, "4", "A0A1A2A3A4A5", "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.mf.rdbl.return_value = {"success": False, "error": 1}
assert "failed" in MFC.get("READ").run(dev, "4") # clear failure line
def test_mfc_datasheet_command_set(self):
# the full MIFARE Classic wire command set is present with datasheet opcodes
opcodes = {n: MFC.get(n).opcode() for n in MFC.names()}
assert opcodes["AUTH_A"] == 0x60 and opcodes["AUTH_B"] == 0x61
assert opcodes["INCREMENT"] == 0xC1 and opcodes["DECREMENT"] == 0xC0
assert opcodes["RESTORE"] == 0xC2 and opcodes["TRANSFER"] == 0xB0
assert opcodes["PERSONALIZE_UID"] == 0x40 and opcodes["SET_MOD_TYPE"] == 0x43
for op, name in [(0x60, "AUTH_A"), (0xC1, "INCREMENT"), (0xB0, "TRANSFER"), (0x40, "PERSONALIZE_UID")]:
assert MFC.by_opcode(op).name == name # raw byte maps back to the command
# every page/block command's first param is a block -> gets the memory map
for n in ("AUTH_A", "AUTH_B", "READ", "WRITE", "INCREMENT", "DECREMENT", "RESTORE", "TRANSFER"):
assert MFC.get(n).params[0] == "block"
def test_mfc_value_ops_run(self):
dev = MagicMock()
dev.hf.mf.rdbl.return_value = {"success": True, "block": 4, "data": "aa" * 16}
dev.hf.mf.value.return_value = {"success": True, "block": 4}
MFC.get("AUTH_A").run(dev, "3")
dev.hf.mf.rdbl.assert_called_with(3, key="FFFFFFFFFFFF", key_type=0) # AUTH_A -> key A
MFC.get("AUTH_B").run(dev, "3", "A0A1A2A3A4A5")
dev.hf.mf.rdbl.assert_called_with(3, key="A0A1A2A3A4A5", key_type=1) # AUTH_B -> key B
MFC.get("INCREMENT").run(dev, "4", "10")
dev.hf.mf.value.assert_called_with(4, 0, value=10, key="FFFFFFFFFFFF", key_type=0)
MFC.get("DECREMENT").run(dev, "4", "3", "A0A1A2A3A4A5", "B")
dev.hf.mf.value.assert_called_with(4, 1, value=3, key="A0A1A2A3A4A5", key_type=1)
MFC.get("TRANSFER").run(dev, "4", "8") # copy block 4 -> 8 (restore+transfer)
dev.hf.mf.value.assert_called_with(4, 2, transfer_block=8, key="FFFFFFFFFFFF", key_type=0)
assert "not yet wired" in MFC.get("PERSONALIZE_UID").run(dev, "2") # EV1 opcode-only
def test_iso15_builds(self):
# 15693 frames are flags | command | [mfg] | params, assembled by iso15_frame
assert ISO15.get("READ_BLOCK").build("4") == b"\x02\x20\x04"
assert ISO15.get("GET_SYSTEM_INFO").build() == b"\x02\x2B"
assert ISO15.get("READ_BLOCK").opcode() == 0x20 # opcode = the command byte, not flags
def test_iso15_frame_builder(self):
assert iso15_frame(0x20, bytes([4])) == b"\x02\x20\x04" # non-addressed, high rate
assert iso15_frame(0xB2, mfg=0x04) == b"\x02\xB2\x04" # NXP custom -> mfg byte
# addressed: flags 0x22, UID appended LSByte-first
f = iso15_frame(0x20, bytes([4]), uid="e004010203040506", addressed=True)
assert f == bytes.fromhex("2220") + bytes.fromhex("e004010203040506")[::-1] + bytes([4])
assert iso15_frame(0x20, bytes([4]), option=True) == b"\x42\x20\x04"
def test_iso15_flags_decode(self):
assert iso15_flags_decode(0x02) == "high rate · non-addressed"
assert iso15_flags_decode(0x22) == "high rate · addressed"
assert "option" in iso15_flags_decode(0x42)
assert "inventory" in iso15_flags_decode(0x26)
def test_slix_catalog(self):
# ICODE SLIX datasheet command set: standard + NXP custom, frames via the header builder
assert _SLIX.get("READ_BLOCK").build("4") == b"\x02\x20\x04"
assert _SLIX.get("GET_RANDOM").build() == b"\x02\xB2\x04" # NXP mfg inserted
assert _SLIX.get("SET_PASSWORD").build("4", "DEADBEEF") == bytes.fromhex("02b30404deadbeef")
assert _SLIX.get("READ_MULTIPLE").build("0", "4") == b"\x02\x23\x00\x03" # count-1 on wire
# opcode = the command byte (frame byte 1), so by_opcode maps correctly
assert _SLIX.get("GET_RANDOM").opcode() == 0xB2
assert _SLIX.by_opcode(0xB2).name == "GET_RANDOM" and _SLIX.by_opcode(0x20).name == "READ_BLOCK"
# dispatch: an identified SLIX (or generic ICODE) resolves to this catalog
s = RawSession(); s.protocol, s.transponder = "hf15", "ICODE SLIX"
assert catalog_for(s) is _SLIX
def test_resolver(self):
s = RawSession()
assert catalog_for(s) is None # nothing identified
s.protocol, s.transponder = "hf14a", "NTAG215"
assert catalog_for(s) is TYPE2
s.transponder = "MIFARE Classic 1K"
assert catalog_for(s).name == "MIFARE Classic"
s.transponder = "MIFARE Mini" # Mini is a Classic -> MFC, not Type 2
assert catalog_for(s).name == "MIFARE Classic"
s.protocol, s.transponder = "hf15", "ISO15693"
assert catalog_for(s) is ISO15
def test_resolver_lf(self):
# identify sets an LF transponder -> the completer/hints get a catalog, not None
s = RawSession()
s.protocol, s.transponder = "lf", "T5577 (EM4100 20260716FF)"
assert catalog_for(s) is T5577 # command-rich emulator
s.transponder = "EM4100 20260716FF" # a native read-only credential
assert catalog_for(s) is LF_READONLY
def test_t5577_opcodes(self):
# build() exposes the downlink opcode for hex/binary completion (execution is via run)
assert T5577.get("READ").build("0")[0] == 0x01
assert T5577.get("WRITE").build("0", "0")[0] == 0x02
assert T5577.get("WAKE").build("0")[0] == 0x03
assert {c.name for c in T5577.commands.values()} == {"READ", "WRITE", "WAKE", "DETECT"}
assert T5577.get("READ").run is not None and LF_READONLY.get("INFO").run is not None
def test_opcode_covers_two_phase_write(self):
# opcode() tolerates data args (which need valid hex) and multi-frame builds
assert TYPE2.get("WRITE").opcode() == 0xA2
assert TYPE2.get("COMPAT_WRITE").opcode() == 0xA0 # first frame's opcode, not None
assert TYPE2.get("GET_VERSION").opcode() == 0x60
assert LF_READONLY.get("INFO").opcode() is None # run-only, no build
def test_call_arg_is_base_ten(self):
# a function-call page argument is base 10 unless prefixed — READ(04)/READ(40) must not crash
assert TYPE2.get("READ").build("04") == b"\x30\x04" # leading-zero decimal
assert TYPE2.get("READ").build("40") == b"\x30\x28" # 40 decimal = page 0x28
assert TYPE2.get("READ").build("0x28") == b"\x30\x28" # hex override
assert TYPE2.get("READ").build("0b101000") == b"\x30\x28" # binary override
assert TYPE2.get("READ").build("4") == b"\x30\x04"
def test_by_opcode(self):
assert TYPE2.by_opcode(0x30).name == "READ"
assert TYPE2.by_opcode(0xA2).name == "WRITE"
assert TYPE2.by_opcode(0x99) is None
class TestFunctionCalls:
def _id(self, sak=0x00):
return RawSession(device=_mock_device(
scan14={"found": True, "sak": sak, "atqa": "4400", "uid": "04010203"}))
def test_call_read_sends_correct_bytes(self, capsys):
s = self._id()
dispatch(s, "identify")
s.device.hf.iso14a.raw.return_value = {"raw": b"\xAA\xBB\xCC\xDD"}
capsys.readouterr()
dispatch(s, "READ(4)")
out = _plain(capsys.readouterr().out)
assert "30 04" in out # the built command hit the trace
s.device.hf.iso14a.raw.assert_called_with(b"\x30\x04", flags=0x20) # CRC appended
def test_two_phase_write_sends_both_frames(self, capsys):
s = self._id() # SAK 0 -> Type 2 catalog
dispatch(s, "identify")
s.device.hf.iso14a.raw.return_value = {"raw": b"\x0A"} # ACK
s.device.hf.iso14a.raw.reset_mock()
dispatch(s, "COMPAT_WRITE(4, 01020304)")
calls = s.device.hf.iso14a.raw.call_args_list
assert len(calls) == 2 # command frame + data frame
assert calls[0].args[0] == b"\xA0\x04"
assert calls[1].args[0] == b"\x01\x02\x03\x04" + b"\x00" * 12
def test_memory_region_stays_out_of_output(self, capsys):
# the memory-location info belongs ONLY in the hint/completion — never printed as output
s = RawSession(device=_mock_device(
scan14={"found": True, "sak": 0x00, "atqa": "4400", "uid": "04a1b2c3d4e5f6"},
version=NTAG216_VERSION))
dispatch(s, "identify")
assert s.transponder == "NTAG216"
s.device.hf.iso14a.raw.return_value = {"raw": b"\x03\x00\xFE\x00" * 4}
capsys.readouterr()
dispatch(s, "READ(4)") # function call
assert "user memory" not in _plain(capsys.readouterr().out)
dispatch(s, "30 04") # raw hex
assert "user memory" not in _plain(capsys.readouterr().out)
def test_call_without_identify(self, capsys):
dispatch(RawSession(), "READ(4)")
assert "identify" in capsys.readouterr().out
def test_unknown_call(self, capsys):
s = self._id(); dispatch(s, "identify"); capsys.readouterr()
dispatch(s, "FLY(4)")
assert "unknown command" in capsys.readouterr().out
def test_bad_args_shows_usage(self, capsys):
s = self._id(); dispatch(s, "identify"); capsys.readouterr()
dispatch(s, "READ()") # missing page arg
assert "usage: READ(page)" in capsys.readouterr().out
def test_help_lists_catalog(self, capsys):
s = self._id(); dispatch(s, "identify"); capsys.readouterr()
dispatch(s, "help")
out = capsys.readouterr().out
assert "READ(page)" in out and "GET_VERSION" in out
def test_help_command_detail(self, capsys):
s = self._id(); dispatch(s, "identify"); capsys.readouterr()
dispatch(s, "help READ")
assert "0x30" in capsys.readouterr().out
class TestTLV:
# NULL + NDEF (a well-known text record "Hi"/en) + TERMINATOR
TEXT_REC = bytes.fromhex("D101055402656E4869") # D1 01 05 T 02 'en' 'Hi'
BLOCK = bytes([0x00, 0x03, len(TEXT_REC)]) + TEXT_REC + bytes([0xFE])
def test_parse_structure(self):
tlvs = parse_tlv(self.BLOCK)
tags = [t.tag for t in tlvs]
assert tags == [0x00, 0x03, 0xFE]
ndef = tlvs[1]
assert ndef.length == len(self.TEXT_REC) and ndef.value == self.TEXT_REC
def test_extended_length(self):
big = bytes([0x03, 0xFF, 0x01, 0x00]) + b"\x00" * 256 + bytes([0xFE])
tlvs = parse_tlv(big)
assert tlvs[0].tag == 0x03 and tlvs[0].length == 256
def test_format_multiline(self):
out = format_tlv(self.BLOCK)
assert "NULL" in out
assert "NDEF MESSAGE len=9" in out
assert "TERMINATOR" in out
assert "\n" in out # genuinely multi-line
assert "" in out # NDEF annotation line present
def test_tlv_command(self, capsys):
dispatch(RawSession(), "tlv " + self.BLOCK.hex())
assert "NDEF MESSAGE" in capsys.readouterr().out
class TestCompleter:
def _complete(self, session, text):
from prompt_toolkit.document import Document
return list(RawCompleter(session).get_completions(Document(text), None))
def test_completes_control_verbs(self):
cs = self._complete(RawSession(), "ide")
assert any(c.text == "identify" for c in cs)
def test_completes_catalog_commands_with_meta(self):
s = RawSession()
s.protocol, s.transponder = "hf14a", "NTAG215" # -> Type 2 catalog
cs = self._complete(s, "REA")
texts = [c.text for c in cs]
assert "READ(" in texts
read = next(c for c in cs if c.text == "READ(")
assert "0x30" in read.display_meta_text # tooltip = help
def test_no_catalog_before_identify(self):
cs = self._complete(RawSession(), "REA")
assert all(c.text != "READ(" for c in cs) # no tag => no catalog commands
def test_help_argument_completes_command_names(self):
s = RawSession()
s.protocol, s.transponder = "hf14a", "NTAG215"
cs = self._complete(s, "help GET")
assert any(c.text == "GET_VERSION" for c in cs)
def _disp(self, comp):
return comp.display[0][1] if comp.display else comp.text
def test_opcode_completion_hex_inserts_raw_byte(self):
# entering raw hex, a matched opcode inserts the RAW BYTE (60), labelled with the command
s = RawSession()
s.protocol, s.transponder = "hf14a", "NTAG213"
cs = self._complete(s, "60") # GET_VERSION opcode
gv = next(c for c in cs if "GET_VERSION" in self._disp(c))
assert gv.text == "60" # not "GET_VERSION("
# a partial digit completes to the full opcode byte
assert "30" in [c.text for c in self._complete(s, "3")] # READ (0x30)
def test_opcode_completion_binary_inserts_raw_byte(self):
s = RawSession()
s.protocol, s.transponder = "hf14a", "NTAG213"
s.entry_mode = "bin"
cs = self._complete(s, "01100000") # 0x60 in binary
gv = next(c for c in cs if "GET_VERSION" in self._disp(c))
assert gv.text == "01100000" # raw binary byte, not the command name
def test_iso15_position_aware_completion(self):
# 15693 raw frame: byte 0 = flags menu (decoded), byte 1 = command, byte 2 = NXP mfg
s = RawSession()
s.protocol, s.transponder = "hf15", "ICODE SLIX"
flags = {c.text: c.display_meta_text for c in self._complete(s, "")}
assert flags["02"] == "high rate · non-addressed"
assert flags["22"] == "high rate · addressed"
cmds = {c.text: self._disp(c) for c in self._complete(s, "02 ")} # after flags -> command
assert "READ_BLOCK" in cmds["20"] and "GET_RANDOM" in cmds["B2"]
assert "04" in [c.text for c in self._complete(s, "02 B2 ")] # custom -> mfg byte
assert self._complete(s, "02 20 ") == [] # standard cmd -> no mfg
# function-call form still gets the block map, command names still complete by letters
assert any("user memory" in (c.display_meta_text or "") for c in self._complete(s, "READ_BLOCK("))
assert any("GET_RANDOM" in self._disp(c) for c in self._complete(s, "get_r"))
# binary flags render as 8 bits
s.entry_mode = "bin"
assert any(c.text == "00000010" for c in self._complete(s, ""))
def test_mfc_opcode_completion(self):
# the new MIFARE Classic datasheet opcodes surface by hex AND binary value, inserting the byte
s = RawSession()
s.protocol, s.transponder = "hf14a", "MIFARE Classic 1K"
for word, name, byte in [("c1", "INCREMENT", "C1"), ("60", "AUTH_A", "60"), ("b0", "TRANSFER", "B0")]:
cs = self._complete(s, word)
hit = next(c for c in cs if name in self._disp(c))
assert hit.text == byte # inserts the raw opcode byte
s.entry_mode = "bin"
cs = self._complete(s, "11000001") # 0xC1 in binary
assert any("INCREMENT" in self._disp(c) for c in cs)
s.entry_mode = "hex"
# the block argument gets the memory map for these commands too
assert any("manufacturer" in (c.display_meta_text or "")
for c in self._complete(s, "INCREMENT("))
def test_raw_page_byte_completion(self):
# after a page-command opcode in raw entry, the page byte gets the memory map (raw bytes)
s = RawSession()
s.protocol, s.transponder = "hf14a", "NTAG213"
by_text = {c.text: c.display_meta_text for c in self._complete(s, "30 ")}
assert by_text["04"] == "user memory" # inserts raw byte 04, not 0x04
assert by_text["28"] == "dynamic lock bytes"
assert "PWD" in by_text["2B"]
# narrows as you type the byte
assert [c.text for c in self._complete(s, "30 2")] == ["28", "29", "2A", "2B", "2C"]
# a non-page opcode (GET_VERSION) gets no page menu
assert self._complete(s, "60 ") == []
def test_page_arg_lists_memory_map(self):
# typing the page argument suggests the tag's memory landmarks with their roles
s = RawSession()
s.protocol, s.transponder = "hf14a", "NTAG216"
cs = self._complete(s, "READ(")
by_text = {c.text: c.display_meta_text for c in cs}
assert by_text["0x00"] == "UID / serial number"
assert by_text["0x03"] == "Capability Container (CC)"
assert by_text["0x04"] == "user memory"
assert "PWD" in by_text["0xE5"]
def test_page_arg_filters_by_partial(self):
s = RawSession()
s.protocol, s.transponder = "hf14a", "NTAG216"
texts = [c.text for c in self._complete(s, "READ(0xE")]
assert texts == ["0xE2", "0xE3", "0xE4", "0xE5", "0xE6"] # only the E-page landmarks
# a decimal/bare-hex partial matches too
assert [c.text for c in self._complete(s, "READ(4")] == ["0x04"]
def test_page_arg_second_arg_untouched(self):
# a comma ends the page argument — the data argument gets no memory suggestions
s = RawSession()
s.protocol, s.transponder = "hf14a", "NTAG216"
assert self._complete(s, "WRITE(0x04, ") == []
def test_page_arg_t5577_blocks(self):
s = RawSession()
s.protocol, s.transponder = "lf", "T5577 / ATA5577"
by_text = {c.text: c.display_meta_text for c in self._complete(s, "READ(")}
assert "config" in by_text["0x00"]
assert "password" in by_text["0x07"]
def test_page_arg_mfc_block_map(self):
# MIFARE Classic gets its block map in the dropdown, same as NTAG pages
s = RawSession()
s.protocol, s.transponder = "hf14a", "MIFARE Classic 1K"
by_text = {c.text: c.display_meta_text for c in self._complete(s, "READ(")}
assert "manufacturer" in by_text["0x00"]
assert by_text["0x03"].startswith("sector 0 trailer")
assert "0x3F" in by_text # block 63, sector 15 trailer
# raw byte form works too
assert "manufacturer" in {c.text: c.display_meta_text for c in self._complete(s, "30 ")}["00"]
def test_page_arg_binary_mode(self):
# in binary entry mode the landmarks render as full 8-bit values (each bit visible)
s = RawSession()
s.protocol, s.transponder = "hf14a", "NTAG216"
s.entry_mode = "bin"
by_text = {c.text: c.display_meta_text for c in self._complete(s, "READ(")}
assert by_text["0b00000100"] == "user memory" # 0x04
assert "PWD" in by_text["0b11100101"] # 0xE5
assert "0x04" not in by_text # not hex form in binary mode
# a binary partial filters bit-prefix-wise
assert [c.text for c in self._complete(s, "READ(0b111001")] == \
["0b11100100", "0b11100101", "0b11100110"] # 0xE4..0xE6
def test_page_arg_prefix_overrides_mode(self):
# a 0b prefix forces binary rendering even though the session is in hex mode
s = RawSession()
s.protocol, s.transponder = "hf14a", "NTAG216" # default hex
assert [c.text for c in self._complete(s, "READ(0b000001")] == ["0b00000100"]
class TestMemoryHint:
def _sess(self, tp="NTAG213"):
s = RawSession()
s.protocol, s.transponder = "hf14a", tp
return s
def test_page_role(self):
from pm3py.cli.rawcli.memory import page_role
assert page_role("NTAG213", 0) == "UID / serial number"
assert page_role("NTAG213", 1) == "UID / serial number"
assert "static lock" in page_role("NTAG213", 2) # page 2 is lock/internal, not UID
assert page_role("NTAG213", 3) == "Capability Container (CC)"
assert page_role("NTAG213", 4) == "user memory"
assert page_role("NTAG213", 0x28) == "dynamic lock bytes"
assert "AUTH0" in page_role("NTAG213", 0x29) # CFG0
assert page_role("NTAG213", 0x2A).startswith("CFG1")
assert "PWD" in page_role("NTAG213", 0x2B)
assert "PACK" in page_role("NTAG213", 0x2C)
assert page_role("NTAG216", 0x04) == "user memory" # different IC, different pages
assert "AUTH0" in page_role("NTAG216", 0xE3)
def test_page_role_family_differences(self):
from pm3py.cli.rawcli.memory import page_role
# NTAG 213/215/216 have the NFC counter bits in CFG1; 210/212 don't
assert "NFC_CNT_EN" in page_role("NTAG213", 0x2A)
assert "NFC_CNT_EN" not in page_role("NTAG210", 0x11) # CFG1, no counter
assert "NFC_CNT_EN" not in page_role("NTAG212", 0x26)
# NTAG has the ASCII mirror in CFG0; Ultralight EV1 does not
assert "MIRROR" in page_role("NTAG213", 0x29)
assert "MIRROR" not in page_role("MIFARE Ultralight EV1 (MF0UL11)", 0x10)
# page 3 is CC on NTAG, OTP on Ultralight
assert page_role("MIFARE Ultralight EV1 (MF0UL11)", 3) == "OTP (one-time programmable)"
assert page_role("MIFARE Ultralight EV1 (MF0UL21)", 3).startswith("OTP")
# UL21 has a dynamic lock page (like NTAG212); UL11 doesn't (like NTAG210)
assert page_role("MIFARE Ultralight EV1 (MF0UL21)", 0x24) == "dynamic lock bytes"
def test_generic_type2_fallback(self):
from pm3py.cli.rawcli.memory import landmark_pages, page_role
# only the family is known (flaky/absent GET_VERSION) -> the shared header still resolves
for name in ("MIFARE Ultralight / NTAG", "NTAG (144 B)"):
assert page_role(name, 4) == "user memory"
assert page_role(name, 0) == "UID / serial number"
assert "static lock" in page_role(name, 2)
assert page_role(name, 3) == "Capability Container (CC) / OTP" # family unknown
assert page_role(name, 0x20) is None # model-specific -> not guessed
assert [p for p, _ in landmark_pages(name)] == [0, 2, 3, 4] # header, no config
assert page_role("MIFARE DESFire", 4) is None # no map for this tag -> nothing
def test_t5577_block_roles(self):
from pm3py.cli.rawcli.memory import page_role
tp = "T5577 (EM4100 20260716FF)"
assert "config" in page_role(tp, 0)
assert "password" in page_role(tp, 7)
assert "data" in page_role(tp, 3)
def test_mfc_block_roles(self):
from pm3py.cli.rawcli.memory import page_role, landmark_pages
# 1K: block 0 manufacturer, every 4th block a sector trailer, rest data
assert "manufacturer" in page_role("MIFARE Classic 1K", 0)
assert page_role("MIFARE Classic 1K", 1) == "data block (sector 0)"
assert page_role("MIFARE Classic 1K", 3).startswith("sector 0 trailer")
assert "Key A" in page_role("MIFARE Classic 1K", 3) and "Key B" in page_role("MIFARE Classic 1K", 3)
assert page_role("MIFARE Classic 1K", 63).startswith("sector 15 trailer")
assert page_role("MIFARE Classic 1K", 64) is None # out of range
# 4K large sectors 32-39 are 16 blocks each (trailer = last block)
assert page_role("MIFARE Classic 4K", 143).startswith("sector 32 trailer")
assert page_role("MIFARE Classic 4K", 255).startswith("sector 39 trailer")
assert page_role("MIFARE Classic 4K", 254) == "data block (sector 39)"
# Mini is a 5-sector Classic
assert page_role("MIFARE Mini", 19).startswith("sector 4 trailer")
assert page_role("MIFARE Mini", 20) is None
# landmarks = block 0 + every trailer
assert [b for b, _ in landmark_pages("MIFARE Classic 1K")] == [0, 3, 7, 11, 15, 19, 23,
27, 31, 35, 39, 43, 47, 51, 55, 59, 63]
assert len(landmark_pages("MIFARE Classic 4K")) == 41 # block 0 + 40 sectors
def test_layouts_match_models(self):
from pm3py.cli.rawcli.memory import _LAYOUTS
from pm3py.sim import NTAG210, NTAG212, NTAG213, NTAG215, NTAG216
for cls, name in [(NTAG210, "NTAG210"), (NTAG212, "NTAG212"), (NTAG213, "NTAG213"),
(NTAG215, "NTAG215"), (NTAG216, "NTAG216")]:
L = _LAYOUTS[name]
assert (L["cfg0"], L["cfg1"], L["pwd"], L["pack"]) == \
(cls._cfg0_page, cls._cfg1_page, cls._pwd_page, cls._pack_page)
assert L["fam"] == "ntag"
assert L.get("cnt", False) == cls._has_nfc_counter # CFG1 NFC-counter bits
def test_ultralight_layouts_match_models(self):
from pm3py.cli.rawcli.memory import _LAYOUTS
from pm3py.sim import MF0UL11, MF0UL21
for cls, name in [(MF0UL11, "MIFARE Ultralight EV1 (MF0UL11)"),
(MF0UL21, "MIFARE Ultralight EV1 (MF0UL21)")]:
L = _LAYOUTS[name]
assert (L["cfg0"], L["cfg1"], L["pwd"], L["pack"]) == \
(cls._cfg0_page, cls._cfg1_page, cls._pwd_page, cls._pack_page)
assert L["fam"] == "ul" # page 3 is OTP, no ASCII mirror
class TestKeyBindings:
def test_install_does_not_raise(self):
# regression: "c-/" is not a valid prompt_toolkit key and used to crash launch
from prompt_toolkit.key_binding import KeyBindings
from pm3py.cli.rawcli.entry import install_key_bindings, TOGGLE_KEYS
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"]
def test_digit_binding_fires_completion_trigger_for_raw_bytes(self):
# regression: raw byte entry must append via insert_text (which fires completion), not
# replace buf.document (which resets complete_state and silently killed the live
# autocomplete menu during hex/binary byte entry). on_text_insert fires from insert_text,
# never from a document= swap, so it is a faithful proxy for "the menu would refresh".
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()
s.toggle_entry_mode() # binary: regroups every 8 digits (worst case)
kb = KeyBindings()
install_key_bindings(kb, s)
digit = {getattr(b.keys[0], "value", b.keys[0]): b for b in kb.bindings}
buf = Buffer()
fired = []
buf.on_text_insert += lambda _b: fired.append(1)
app = SimpleNamespace(invalidate=lambda: None)
for ch in "00000100":
digit[ch].handler(SimpleNamespace(current_buffer=buf, app=app, data=ch))
assert buf.text == "00000100"
assert len(fired) == 8 # one insert_text per digit (0 with document=)
class TestRawcliLiveSession:
"""End-to-end through the REAL PromptSession run() builds (real key bindings + RawCompleter +
complete_while_typing), driven by a prompt_toolkit pipe input that simulates TTY keystrokes.
Exercises the actual rawcli input stack; no device needed for the input layer."""
@staticmethod
def _run_keys(keys):
import asyncio
from prompt_toolkit import PromptSession
from prompt_toolkit.input import create_pipe_input
from prompt_toolkit.output import DummyOutput
from prompt_toolkit.key_binding import KeyBindings
from pm3py.cli.rawcli.entry import install_key_bindings
from pm3py.cli.rawcli.completer import RawCompleter
state = RawSession()
comp = RawCompleter(state)
queried = []
_orig = comp.get_completions
def _rec(document, event):
queried.append(document.text)
yield from _orig(document, event)
comp.get_completions = _rec
kb = KeyBindings()
install_key_bindings(kb, state)
# PromptSession.prompt() runs asyncio.run() internally, which nulls the current-loop
# pointer; save/restore it so the shared-loop `_run` convention in other test files isn't
# poisoned (see tests/test_pyws_plugin.py).
try:
prev_loop = asyncio.get_event_loop()
except RuntimeError:
prev_loop = None
try:
with create_pipe_input() as inp:
session = PromptSession(key_bindings=kb, completer=comp, complete_while_typing=True,
input=inp, output=DummyOutput())
inp.send_text(keys + "\r")
line = session.prompt("> ")
finally:
if prev_loop is not None and not prev_loop.is_closed():
asyncio.set_event_loop(prev_loop)
return line, queried
def test_accepted_line_across_input_paths(self):
# the change touched the digit handler; confirm every input path still yields the right line
toggle = "\x14" # Ctrl-t entry-mode toggle
for keys, expect in [("3004", "30 04"),
(toggle + "00000100", "00000100"),
("30" + toggle + "00000100", "30 00000100"),
("read(4)", "read(4)"),
("identify", "identify")]:
line, _ = self._run_keys(keys)
assert line == expect, f"{keys!r} -> {line!r} != {expect!r}"
def test_live_completion_fires_during_binary_entry(self):
# the fix, in the real session: the completer is queried with the byte-line text while
# typing binary (0 queries before the fix, since buf.document= reset complete_state)
line, queried = self._run_keys("\x14" + "00000100")
byte_q = [q for q in queried if q and q.replace(" ", "").strip("01") == ""]
assert line == "00000100"
assert byte_q, "completer never queried for byte-line text — live menu not firing"