core+gui: BBQR-only QR transfer, disable Audio when modem plugin unavailable
- Drop BALQR / BC-UR v1 / v2 wire formats, keep BBQR only (animated_qr, qrtransfer, dialogs); remove the settings "QR Code Size" selector and persist the export-dialog size choice instead. - Disable the Audio transport radio button in the import/export dialogs when the audio_modem plugin (or its amodem dependency) is unavailable. - Refresh translation catalogs (bal.pot, es_ES, it_IT).
This commit is contained in:
@@ -1,11 +1,9 @@
|
||||
"""
|
||||
Tests for ``bal.core.animated_qr`` (BC-UR v1, BC-UR v2, BBQR interop).
|
||||
Tests for ``bal.core.animated_qr`` (BBQR interop).
|
||||
|
||||
Validates the self-contained codecs against the published spec vectors
|
||||
(BCR-2020-004/005 BC32, BCR-2020-012 bytewords) and against byte-exact
|
||||
output captured from the reference C++ bc-ur encoder (fountain/xoshiro/
|
||||
alias-sampler parity), plus round trips, out-of-order assembly, missing-part
|
||||
fountain solving and malformed-input rejection for all four formats.
|
||||
Validates the self-contained BBQR codec against round trips, out-of-order
|
||||
assembly, compression default/fallback, malformed-input rejection and the
|
||||
format detection / parse_for_detection helpers.
|
||||
|
||||
Run:
|
||||
source electrum/env/bin/activate
|
||||
@@ -22,328 +20,6 @@ import random
|
||||
from bal.core import animated_qr as aq
|
||||
|
||||
|
||||
def _payload(plen: int) -> bytes:
|
||||
"""Deterministic payload matching the C++ reference driver (``(i*7)&0xff``)."""
|
||||
return bytes((i * 7) & 0xFF for i in range(plen))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# BC32 (BCR-2020-004 / bcr-2020-005 rev1 reference implementation vectors)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_bc32_official_vectors():
|
||||
cases = [
|
||||
(b"Hello, world", "fpjkcmr09ss8wmmjd3jq6ax7w9"),
|
||||
(b"Hello world", "fpjkcmr0ypmk7unvvsh4ra4j"),
|
||||
(
|
||||
bytes.fromhex("d934063e82001eec0585ee41ab5d8e4b703a4be1f73aec21e143912c56"),
|
||||
"my6qv05zqq0wcpv9aeq6khvwfdcr5jlp7uawcg0pgwgjc4shjm6xu",
|
||||
),
|
||||
]
|
||||
for payload, encoded in cases:
|
||||
assert aq.bc32_encode(payload) == encoded
|
||||
assert aq.bc32_decode(encoded) == payload
|
||||
|
||||
|
||||
def test_bc32_checksum_rejected():
|
||||
good = aq.bc32_encode(b"Hello, world")
|
||||
corrupted = good[:-1] + ("a" if good[-1] != "a" else "b")
|
||||
try:
|
||||
aq.bc32_decode(corrupted)
|
||||
except aq.AnimatedQrError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected AnimatedQrError for corrupted BC32")
|
||||
|
||||
|
||||
def test_bc32_bad_char_rejected():
|
||||
try:
|
||||
aq.bc32_decode("1" * 26)
|
||||
except aq.AnimatedQrError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected AnimatedQrError for '1' (not in alphabet)")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Bytewords (BCR-2020-012)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_bytewords_minimal_roundtrip():
|
||||
samples = [bytes(range(256)), _payload(59), b"\x00"] + [
|
||||
os.urandom(64) for _ in range(4)
|
||||
]
|
||||
for data in samples:
|
||||
words = aq.bytewords_minimal_encode(data)
|
||||
assert len(words) == (len(data) + 4) * 2 # 2 chars per byte incl. CRC
|
||||
assert aq.bytewords_minimal_decode(words) == data
|
||||
|
||||
|
||||
def test_bytewords_rejects_corrupted_crc():
|
||||
data = _payload(40)
|
||||
words = aq.bytewords_minimal_encode(data)
|
||||
flip = "a" if words[-1] != "a" else "b"
|
||||
try:
|
||||
aq.bytewords_minimal_decode(words[:-1] + flip)
|
||||
except aq.AnimatedQrError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected AnimatedQrError for corrupted CRC")
|
||||
|
||||
|
||||
def test_bytewords_rejects_odd_length():
|
||||
try:
|
||||
aq.bytewords_minimal_decode("abc")
|
||||
except aq.AnimatedQrError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected AnimatedQrError for odd-length bytewords")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# BC-UR v2: byte-exact parity with the reference C++ encoder
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
# Reference frames from the bc-ur C++ fountain encoder
|
||||
# (payload x=(i*7)&0xFF, cbor wrapped, single-part and multipart).
|
||||
REF_V2_SINGLE_12 = "ur:bytes/gsaeatbabzcecndrehetfhfggtoeemhpmo"
|
||||
|
||||
REF_V2_MULTI_59 = [
|
||||
"ur:bytes/2-2/lpaoaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaeeccasket",
|
||||
"ur:bytes/3-2/lpaxaocsfscyrpdpjzbyhdcthdfraeatbabzcecndrehetfhfggtghhpidinjoktkblplkmunyoypdperpryssimryrldt",
|
||||
"ur:bytes/4-2/lpaaaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaefeimteue",
|
||||
"ur:bytes/5-2/lpahaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnssgdaontls",
|
||||
"ur:bytes/6-2/lpamaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnssisescmwt",
|
||||
"ur:bytes/7-2/lpataocsfscyrpdpjzbyhdcthdfraeatbabzcecndrehetfhfggtghhpidinjoktkblplkmunyoypdperprysslsspplgm",
|
||||
"ur:bytes/8-2/lpayaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaeonlrzebg",
|
||||
"ur:bytes/9-2/lpasaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaeaaryknzt",
|
||||
"ur:bytes/10-2/lpbkaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnsslotsfrfn",
|
||||
"ur:bytes/11-2/lpbdaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnssdtwyrstd",
|
||||
"ur:bytes/12-2/lpbnaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaegswnvdin",
|
||||
"ur:bytes/13-2/lpbtaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnsshknlptee",
|
||||
]
|
||||
|
||||
# Reference message for the 59-byte payload: byte-string head (0x58,0x3b) + data.
|
||||
REF_V2_MULTI_59_MSG = bytes([0x58, 0x3B]) + _payload(59)
|
||||
|
||||
|
||||
def test_v2_single_part_matches_reference():
|
||||
frames = aq.ur2_frames(_payload(12), len(REF_V2_SINGLE_12))
|
||||
assert frames == [REF_V2_SINGLE_12]
|
||||
|
||||
|
||||
def test_v2_reference_frames_decode_and_reencode_exactly():
|
||||
message = REF_V2_MULTI_59_MSG
|
||||
fragment_len = -(-len(message) // 2)
|
||||
for frame in REF_V2_MULTI_59:
|
||||
seq, seq_len, message_len, checksum, data = aq.ur2_parse_part(frame)
|
||||
assert seq_len == 2
|
||||
assert message_len == len(message)
|
||||
assert checksum == aq.crc32_int(message)
|
||||
assert len(data) == fragment_len
|
||||
# re-encoding the parsed values reproduces the reference line exactly
|
||||
assert aq._ur2_part_string(seq, seq_len, message_len, checksum, data) == frame
|
||||
# our choose_fragments + partition + xor reproduces the reference data
|
||||
indexes = aq.choose_fragments(seq, seq_len, checksum)
|
||||
assert seq_num_indexes_valid(seq, seq_len, indexes)
|
||||
mixed = aq._mix_fragments(aq._partition_message(message, fragment_len), indexes, fragment_len)
|
||||
assert mixed == data
|
||||
|
||||
|
||||
def seq_num_indexes_valid(seq, seq_len, indexes):
|
||||
# pure part for seq <= seq_len contains exactly fragment seq-1
|
||||
if seq <= seq_len:
|
||||
return indexes == {seq - 1}
|
||||
return set(indexes) <= set(range(seq_len)) and bool(indexes)
|
||||
|
||||
|
||||
def test_v2_multipart_encoder_matches_reference_from_seq2():
|
||||
# Our frames start at seq 1 (spec-aligned); parts seq 2.. must equal the
|
||||
# reference (which starts at seq 2 due to first_seq_num=1).
|
||||
mine = aq.ur2_frames(_payload(59), 120)
|
||||
assert mine[0].split("/", 1)[1].startswith("1-2") or "1-2" in mine[0].split("/")[1]
|
||||
assert mine[1:4] == REF_V2_MULTI_59[:3]
|
||||
|
||||
|
||||
def test_v2_reference_seq7_mix_parity():
|
||||
# Higher-degree mixed parts (seq_len=7) also match: message uses the
|
||||
# reference head 0x58|0x00 for the 256-byte driver payload.
|
||||
message = bytes([0x58, 0x00]) + _payload(256)
|
||||
seq_len = 7
|
||||
fragment_len = -(-len(message) // seq_len)
|
||||
frames = [
|
||||
"ur:bytes/9-7/lpasatcfadaocyfysnjlsrhddaykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtntoxpyprrhrtsttotluovlwdwnsrfejzhd",
|
||||
"ur:bytes/10-7/lpbkatcfadaocyfysnjlsrhddazeahbnbwcycldedlenfsfygrgmhkhniojtkpkelslememkneolpmqzrksasotitsuevwwpwfzswzpmdrvo",
|
||||
"ur:bytes/11-7/lpbdatcfadaocyfysnjlsrhddawkwtbbbefnaefnbebbjojybebnaebndybbbewkwtceaecedyeebebbjobnaebnbeeedybbbeztwproyapd",
|
||||
]
|
||||
for frame in frames:
|
||||
seq, sl, mlen, checksum, data = aq.ur2_parse_part(frame)
|
||||
assert sl == seq_len and mlen == len(message)
|
||||
assert checksum == aq.crc32_int(message)
|
||||
mixed = aq._mix_fragments(
|
||||
aq._partition_message(message, fragment_len),
|
||||
aq.choose_fragments(seq, seq_len, checksum),
|
||||
fragment_len,
|
||||
)
|
||||
assert mixed == data
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# BC-UR v2: sessions / fountain decoding
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_v2_roundtrip_in_order():
|
||||
payload = ("BAL transfer " * 9).encode()
|
||||
frames = aq.ur2_frames(payload, 120)
|
||||
seq_len = int(frames[0].split("/")[1].split("-")[1])
|
||||
assert len(frames) == 2 * seq_len # pure wave + redundant mixed wave
|
||||
session = aq.AnimatedQrSession()
|
||||
for frame in frames:
|
||||
session.add_part(frame)
|
||||
assert session.done
|
||||
assert session.received == session.total
|
||||
text, _ = session.resolve()
|
||||
assert text == payload.decode()
|
||||
|
||||
|
||||
def test_v2_out_of_order_and_duplicate():
|
||||
payload = ("BAL transfer " * 9).encode()
|
||||
frames = aq.ur2_frames(payload, 120)
|
||||
order = list(range(len(frames)))
|
||||
random.Random(11).shuffle(order)
|
||||
session = aq.AnimatedQrSession()
|
||||
for i in order:
|
||||
status = session.add_part(frames[i])
|
||||
assert status in ("ok", "dup")
|
||||
session.add_part(frames[0]) # duplicate of an already-received part
|
||||
assert session.done
|
||||
assert session.resolve()[0] == payload.decode()
|
||||
|
||||
|
||||
def test_v2_solves_without_a_pure_fragment():
|
||||
payload = ("BAL transfer " * 9).encode()
|
||||
frames = aq.ur2_frames(payload, 120)
|
||||
session = aq.AnimatedQrSession()
|
||||
for frame in frames[1:]: # drop the first pure fragment
|
||||
session.add_part(frame)
|
||||
assert session.done
|
||||
assert session.resolve()[0] == payload.decode()
|
||||
|
||||
|
||||
def test_v2_single_part_import():
|
||||
session = aq.AnimatedQrSession()
|
||||
session.add_part(REF_V2_SINGLE_12)
|
||||
assert session.done and session.total == 1
|
||||
assert session.resolve()[0] == _payload(12).decode("latin-1")
|
||||
|
||||
|
||||
def test_v2_conflicting_transfer_rejected():
|
||||
payload_a = b"AAAAAAAAAAAAAAAA"
|
||||
payload_b = b"BBBBBBBBBBBBBBBB"
|
||||
fa = aq.ur2_frames(payload_a, 500)[0]
|
||||
fb = aq.ur2_frames(payload_b, 500)[0]
|
||||
session = aq.AnimatedQrSession()
|
||||
session.add_part(fa)
|
||||
try:
|
||||
session.add_part(fb)
|
||||
except aq.TransferConflictError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected TransferConflictError for a different transfer")
|
||||
|
||||
|
||||
def test_v2_corrupt_crc_rejected():
|
||||
frame = list(REF_V2_MULTI_59[0])
|
||||
idx = len(frame) - 1
|
||||
frame[idx] = "a" if frame[idx] != "a" else "b"
|
||||
try:
|
||||
aq.ur2_parse_part("".join(frame))
|
||||
except aq.AnimatedQrError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected AnimatedQrError for a corrupt v2 part")
|
||||
|
||||
|
||||
def test_v2_session_cap_rejected():
|
||||
part = aq._ur2_part_string(1, 30000, 100, 1234, b"\x00" * 100)
|
||||
session = aq._Ur2Session()
|
||||
try:
|
||||
session.add(part)
|
||||
except aq.SessionLimitError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected SessionLimitError for oversized seq_len")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# BC-UR v1
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_v1_multipart_roundtrip():
|
||||
payload = ("v1 transfer payload " * 6).encode()
|
||||
frames = aq.ur1_frames(payload, 120)
|
||||
assert len(frames) > 1
|
||||
session = aq.AnimatedQrSession()
|
||||
for frame in reversed(frames):
|
||||
session.add_part(frame)
|
||||
assert session.done
|
||||
assert session.resolve()[0] == payload.decode()
|
||||
|
||||
|
||||
def test_v1_single_part_roundtrip():
|
||||
payload = b"hello, bal"
|
||||
frames = aq.ur1_frames(payload, 400)
|
||||
assert len(frames) == 1
|
||||
session = aq.AnimatedQrSession()
|
||||
session.add_part(frames[0])
|
||||
assert session.done and session.total == 1
|
||||
assert session.resolve()[0] == payload.decode()
|
||||
|
||||
|
||||
def test_v1_headerless_single_part_import():
|
||||
# bcr-2020-005 rev1 allows omitting the sequence header + digest entirely.
|
||||
payload = b"hello, bal"
|
||||
message = aq.cbor_byte_string(payload)
|
||||
single = "ur:bytes/" + aq.bc32_encode(message)
|
||||
assert aq.detect_format(single) == "ur1"
|
||||
session = aq.AnimatedQrSession()
|
||||
session.add_part(single)
|
||||
assert session.done
|
||||
assert session.resolve()[0] == payload.decode()
|
||||
|
||||
|
||||
def test_v1_digest_mismatch_rejected():
|
||||
frame = aq.ur1_frames(b"hello, bal", 400)[0]
|
||||
tampered = frame[:-4] + "abcd"
|
||||
session = aq.AnimatedQrSession()
|
||||
session.add_part(tampered)
|
||||
try:
|
||||
session.resolve()
|
||||
except aq.ChecksumError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected ChecksumError for a tampered v1 digest")
|
||||
|
||||
|
||||
def test_v1_part_numbers_validated():
|
||||
for bad in (
|
||||
"ur:bytes/0of1/{}full".format("x" * 51),
|
||||
"ur:bytes/2of1/{}full".format("x" * 51),
|
||||
"ur:bytes/1of0/{}full".format("x" * 51),
|
||||
"ur:bytes/1aof1/{}full".format("x" * 51),
|
||||
):
|
||||
try:
|
||||
aq.ur1_parse_part(bad)
|
||||
except aq.AnimatedQrError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected AnimatedQrError for: {}".format(bad))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# BBQR
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -424,41 +100,22 @@ def test_bbqr_part_number_limits():
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_detect_format_recognises_all_formats():
|
||||
assert aq.detect_format("BALQR1|1|1||payload") == "balqr"
|
||||
assert aq.detect_format("BAL1" + "001" + "001" + "0" + "payload") == "balqr"
|
||||
assert aq.detect_format(aq.ur1_frames(b"x", 400)[0]) == "ur1"
|
||||
assert aq.detect_format(aq.ur2_frames(b"x", 400)[0]) == "ur2"
|
||||
def test_detect_format_recognises_bbqr():
|
||||
assert aq.detect_format(aq.bbqr_frames(b"x", 50)[0]) == "bbqr"
|
||||
assert aq.detect_format(REF_V2_SINGLE_12) == "ur2"
|
||||
assert aq.detect_format("ur:bytes/" + aq.bc32_encode(aq.cbor_byte_string(b"x"))) == "ur1"
|
||||
|
||||
|
||||
def test_detect_format_rejects_garbage():
|
||||
for text in ("", "hello world", "BALQ|1|1||a", "ur:", "ur:txn/xyz"):
|
||||
for text in ("", "hello world", "BALQR1|1|1||payload", "ur:", "ur:bytes/xyz"):
|
||||
assert aq.detect_format(text) is None, text
|
||||
# Lenient prefix probe: a string that merely *starts* with "balqr" is
|
||||
# reported as balqr (the strict parse then rejects it downstream).
|
||||
assert aq.detect_format("BALQRX|1|1||a") == "balqr"
|
||||
|
||||
|
||||
def test_parse_for_detection_keys():
|
||||
bal = aq.parse_for_detection("BALQR1|3|2||payload")
|
||||
assert bal == ("balqr", "balqr:3", 3, 2)
|
||||
# Compact v2 frame (fixed 11-char header) is detected too.
|
||||
bal_v2 = aq.parse_for_detection("BAL1" + "007" + "004" + "0" + "payload")
|
||||
assert bal_v2 == ("balqr", "balqr:7", 7, 4)
|
||||
v2 = aq.parse_for_detection(aq.ur2_frames(b"x"*50, 400)[0])
|
||||
assert v2[0] == "ur2" and v2[2] == 1 and v2[3] == 1
|
||||
v1 = aq.parse_for_detection(aq.ur1_frames(b"x"*50, 120)[0])
|
||||
assert v1[0] == "ur1" and v1[2] > 1 and 1 <= v1[3] <= v1[2]
|
||||
bb = aq.parse_for_detection(aq.bbqr_frames(b"x"*50, 40)[0])
|
||||
bb = aq.parse_for_detection(aq.bbqr_frames(b"x" * 50, 40)[0])
|
||||
assert bb[0] == "bbqr" and bb[2] >= 1 and 0 <= bb[3] < bb[2]
|
||||
|
||||
|
||||
def test_format_names_exist():
|
||||
for fmt in ("balqr", "ur1", "ur2", "bbqr"):
|
||||
assert aq.format_name(fmt)
|
||||
assert aq.format_name("bbqr")
|
||||
assert aq.format_name("nope") == "nope"
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"""
|
||||
Tests for ``bal.core.qrtransfer``.
|
||||
|
||||
Covers the BALQR frame encoding used for will transfer via QR codes /
|
||||
audio modem: encoding, framing, reassembly, malformed input and the preset
|
||||
list (optionally cross-checked against the ``qrcode`` library's EC-M
|
||||
capacity when it is installed).
|
||||
Covers the transfer-string encoding used for will transfer via BBQR QR codes
|
||||
/ audio modem: encoding, decoding and the preset list.
|
||||
|
||||
Run:
|
||||
source electrum/env/bin/activate
|
||||
@@ -18,34 +16,12 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from bal.core.qrtransfer import (
|
||||
CHUNK_PRESETS,
|
||||
MIN_CHUNK_SIZE,
|
||||
InconsistentTotalError,
|
||||
MissingFramesError,
|
||||
QrTransferError,
|
||||
assemble,
|
||||
decode_transfer,
|
||||
encode_transfer,
|
||||
parse_frame,
|
||||
preset_index_for_chunk_size,
|
||||
split_frames,
|
||||
)
|
||||
|
||||
|
||||
def _frames(tx_strings, chunk_size, compress=False):
|
||||
"""Split a payload and return (payload, total, {index: payload})."""
|
||||
payload = encode_transfer(tx_strings, compress=compress)
|
||||
parsed = {}
|
||||
total = None
|
||||
for frame in split_frames(payload, chunk_size, compressed=compress):
|
||||
t, index, _compressed, p = parse_frame(frame)
|
||||
if total is not None:
|
||||
assert total == t
|
||||
total = t
|
||||
parsed[index] = p
|
||||
assert total is not None
|
||||
return payload, total, parsed
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Round trips
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -67,300 +43,6 @@ def test_empty_list_roundtrip():
|
||||
assert decode_transfer(encode_transfer([]), compressed=False) == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Framing
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_single_frame():
|
||||
tx_strings = ["11" * 10]
|
||||
payload = encode_transfer(tx_strings)
|
||||
frames = split_frames(payload, 150)
|
||||
assert len(frames) == 1
|
||||
total, index, compressed, p = parse_frame(frames[0])
|
||||
assert (total, index, compressed) == (1, 1, False)
|
||||
assert p == payload
|
||||
|
||||
|
||||
def test_multiple_frames_reassemble():
|
||||
tx_strings = ["ab" * 100, "cd" * 100] # 600 chars -> multiple frames
|
||||
_payload, total, parsed = _frames(tx_strings, CHUNK_PRESETS[0][1])
|
||||
assert total > 1
|
||||
decoded = decode_transfer(assemble(parsed, total), compressed=False)
|
||||
assert decoded == tx_strings
|
||||
|
||||
|
||||
def test_size_greater_than_payload():
|
||||
tx_strings = ["12" * 5]
|
||||
payload = encode_transfer(tx_strings)
|
||||
frames = split_frames(payload, 1800)
|
||||
assert len(frames) == 1
|
||||
_t, _i, _c, p = parse_frame(frames[0])
|
||||
assert p == payload
|
||||
|
||||
|
||||
def test_exact_single_frame_boundary():
|
||||
# A 139-byte payload exactly fills the 150-byte preset budget (the 11-char
|
||||
# compact header plus payload), so the encoded frame is exactly 150.
|
||||
tx_strings = ["a" * 139]
|
||||
payload = encode_transfer(tx_strings)
|
||||
frames = split_frames(payload, 150)
|
||||
assert len(frames) == 1
|
||||
assert len(frames[0]) == 150
|
||||
_t, _i, _c, p = parse_frame(frames[0])
|
||||
assert p == payload
|
||||
|
||||
|
||||
def test_frames_fit_chunk_size():
|
||||
tx_strings = ["".join("{:02x}".format(i) * 2) for i in range(300)]
|
||||
payload = encode_transfer(tx_strings)
|
||||
for _label, size in CHUNK_PRESETS:
|
||||
for frame in split_frames(payload, size):
|
||||
assert len(frame) <= size, (size, len(frame))
|
||||
|
||||
|
||||
def test_single_tx_larger_than_chunk():
|
||||
# A huge serialized tx must be split over several frames and reassemble
|
||||
# exactly (positional slicing is safe for hex/base64 text).
|
||||
tx_strings = ["7b" * 1000] # 2000 chars
|
||||
payload = encode_transfer(tx_strings)
|
||||
frames = split_frames(payload, 150)
|
||||
assert len(frames) > 1
|
||||
parsed = {parse_frame(f)[1]: parse_frame(f)[3] for f in frames}
|
||||
total = parse_frame(frames[0])[0]
|
||||
assert assemble(parsed, total) == payload
|
||||
|
||||
|
||||
def test_compressed_frames_carry_flag():
|
||||
tx_strings = ["ab" * 40]
|
||||
frames = split_frames(encode_transfer(tx_strings, compress=True), 150, compressed=True)
|
||||
for frame in frames:
|
||||
_t, _i, compressed, _p = parse_frame(frame)
|
||||
assert compressed is True
|
||||
# Plain frames do not.
|
||||
frames_plain = split_frames(encode_transfer(tx_strings), 150)
|
||||
_t, _i, compressed, _p = parse_frame(frames_plain[0])
|
||||
assert compressed is False
|
||||
|
||||
|
||||
def test_compressed_roundtrip_through_frames():
|
||||
tx_strings = ["ab" * 50, "cd" * 50, "12" * 60]
|
||||
payload = encode_transfer(tx_strings, compress=True)
|
||||
parsed = {}
|
||||
total = None
|
||||
for frame in split_frames(payload, 400, compressed=True):
|
||||
t, index, _c, p = parse_frame(frame)
|
||||
total = t
|
||||
parsed[index] = p
|
||||
assert total is not None
|
||||
decoded = decode_transfer(assemble(parsed, total), compressed=True)
|
||||
assert decoded == tx_strings
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Compact v2 wire format ("BAL1")
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_v2_frame_header_structure():
|
||||
frames = split_frames(encode_transfer(["11" * 10]), 150)
|
||||
assert len(frames) == 1
|
||||
frame = frames[0]
|
||||
assert frame.startswith("BAL1")
|
||||
# Fixed 11-char header: magic + 3-char total + 3-char index + 1 flag.
|
||||
assert len(frame) > 11
|
||||
magic, total_s, index_s, flag, payload = (
|
||||
frame[:4],
|
||||
frame[4:7],
|
||||
frame[7:10],
|
||||
frame[10],
|
||||
frame[11:],
|
||||
)
|
||||
assert magic == "BAL1"
|
||||
assert total_s == "001"
|
||||
assert index_s == "001"
|
||||
assert flag == "0"
|
||||
assert payload == "11" * 10
|
||||
total, index, compressed, p = parse_frame(frame)
|
||||
assert (total, index, compressed) == (1, 1, False)
|
||||
assert p == payload
|
||||
|
||||
|
||||
def test_v2_compressed_flag_is_z():
|
||||
frames = split_frames(
|
||||
encode_transfer(["11" * 10], compress=True), 150, compressed=True
|
||||
)
|
||||
assert frames[0][10] == "Z"
|
||||
_t, _i, compressed, _p = parse_frame(frames[0])
|
||||
assert compressed is True
|
||||
|
||||
|
||||
def test_v2_header_fixed_width_high_counts():
|
||||
# A long transfer needs multi-digit counts; the v2 header stays exactly
|
||||
# 11 chars no matter how many frames (3-char base36 zero-padded counts).
|
||||
tx_strings = ["ab" * 300] # 600 chars -> several frames at 150
|
||||
frames = split_frames(encode_transfer(tx_strings), 150)
|
||||
assert len(frames) > 1
|
||||
for frame in frames:
|
||||
# magic(4) + total(3) + index(3) + flag(1) = 11 chars, then payload.
|
||||
assert len(frame) - len(frame[11:]) == 11
|
||||
|
||||
|
||||
def test_v2_max_frame_count():
|
||||
# A transfer needing more than 46655 frames must be rejected (3-char
|
||||
# base36 count fields cannot represent larger totals).
|
||||
from bal.core.qrtransfer import _MAX_TOTAL
|
||||
|
||||
oversized = "A" * (_MAX_TOTAL * (150 - 11) + 1)
|
||||
try:
|
||||
split_frames(oversized, 150)
|
||||
except QrTransferError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected QrTransferError above the frame cap")
|
||||
|
||||
|
||||
def test_v2_boundary_at_max_count():
|
||||
from bal.core.qrtransfer import _MAX_TOTAL
|
||||
|
||||
# Exactly at the cap: must still produce (bounded) frames with 3-char
|
||||
# counts "VVV" (46655) for the highest serialised part.
|
||||
payload = "B" * (_MAX_TOTAL * (150 - 11))
|
||||
frames = split_frames(payload, 150)
|
||||
assert len(frames) == _MAX_TOTAL
|
||||
total, index, _c, _p = parse_frame(frames[-1])
|
||||
assert total == _MAX_TOTAL
|
||||
assert index == _MAX_TOTAL
|
||||
assert frames[-1][:10] == "BAL1" + "ZZZ" + "ZZZ"
|
||||
|
||||
|
||||
def test_encode_transfer_best():
|
||||
from bal.core.qrtransfer import encode_transfer_best
|
||||
|
||||
# Redundant JSON-ish text compresses -> compressed (and longer source
|
||||
# must round-trip unchanged).
|
||||
txs = ['{"a": "%s"}' % ("x" * 300), '{"b": "%s"}' % ("y" * 300)]
|
||||
transfer, compressed = encode_transfer_best(txs)
|
||||
assert compressed is True
|
||||
assert decode_transfer(transfer, compressed) == txs
|
||||
|
||||
# Already-compact input stays plain (never larger than the source).
|
||||
txs_small = ["ab", "cd"]
|
||||
transfer, compressed = encode_transfer_best(txs_small)
|
||||
assert compressed is False
|
||||
assert decode_transfer(transfer, compressed) == txs_small
|
||||
|
||||
|
||||
def test_v2_malformed_frames():
|
||||
bad = (
|
||||
"BAL1", # header only, no fields
|
||||
"BAL1" + "001", # truncated
|
||||
"BAL1" + "G-1" + "001" + "Z" + "p", # non-base36 total
|
||||
"BAL1" + "001" + "G-1" + "Z" + "p", # non-base36 index
|
||||
"BAL1" + "000" + "001" + "Z" + "p", # total 0
|
||||
"BAL1" + "001" + "000" + "Z" + "p", # index 0
|
||||
"BAL1" + "001" + "002" + "Z" + "p", # index beyond total
|
||||
"BAL1" + "001" + "001" + "Q" + "p", # unknown flag
|
||||
)
|
||||
for frame in bad:
|
||||
try:
|
||||
parse_frame(frame)
|
||||
except QrTransferError:
|
||||
continue
|
||||
raise AssertionError("expected QrTransferError for: {!r}".format(frame))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Malformed input
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_parse_bad_magic_and_version():
|
||||
for frame in (
|
||||
"BALQR|1|1||a", # missing version
|
||||
"BALQR2|1|1||a", # unknown version
|
||||
"XXXXX1|1|1||a", # unknown magic
|
||||
):
|
||||
try:
|
||||
parse_frame(frame)
|
||||
except QrTransferError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected QrTransferError for: {}".format(frame))
|
||||
|
||||
|
||||
def test_parse_bad_arity():
|
||||
for frame in ("BALQR1", "BALQR1|1|1|"):
|
||||
try:
|
||||
parse_frame(frame)
|
||||
except QrTransferError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected QrTransferError for: {}".format(frame))
|
||||
|
||||
|
||||
def test_parse_pipe_in_payload_is_folded():
|
||||
# maxsplit keeps the tail (including any inner '|') in the payload part.
|
||||
frame = "BALQR1|1|1||a|b|c"
|
||||
total, index, compressed, payload = parse_frame(frame)
|
||||
assert (total, index, compressed) == (1, 1, False)
|
||||
assert payload == "a|b|c"
|
||||
|
||||
|
||||
def test_parse_bad_numbers():
|
||||
for frame in (
|
||||
"BALQR1|x|1||a",
|
||||
"BALQR1|1|y||a",
|
||||
"BALQR1|0|1||a",
|
||||
"BALQR1|1|0||a",
|
||||
"BALQR1|1|2||a", # index beyond total
|
||||
"BALQR1|-1|1||a",
|
||||
):
|
||||
try:
|
||||
parse_frame(frame)
|
||||
except QrTransferError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected QrTransferError for: {}".format(frame))
|
||||
|
||||
|
||||
def test_parse_bad_flags():
|
||||
try:
|
||||
parse_frame("BALQR1|1|1|Q|payload")
|
||||
except QrTransferError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected QrTransferError for unknown flags")
|
||||
|
||||
|
||||
def test_assemble_missing_frames():
|
||||
try:
|
||||
assemble({1: "a", 3: "c"}, total=3)
|
||||
except MissingFramesError as e:
|
||||
assert e.missing == [2]
|
||||
else:
|
||||
raise AssertionError("expected MissingFramesError")
|
||||
|
||||
|
||||
def test_assemble_index_beyond_total():
|
||||
try:
|
||||
assemble({1: "a", 2: "b"}, total=1)
|
||||
except InconsistentTotalError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected InconsistentTotalError")
|
||||
|
||||
|
||||
def test_assemble_order_and_total_validation():
|
||||
assert assemble({1: "a", 2: "b"}, total=2) == "ab"
|
||||
try:
|
||||
assemble({}, total=0)
|
||||
except QrTransferError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected QrTransferError")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Constants / presets
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -379,46 +61,6 @@ def test_preset_index_for_chunk_size():
|
||||
assert preset_index_for_chunk_size(1800) == 3
|
||||
|
||||
|
||||
def test_min_chunk_size_guard():
|
||||
try:
|
||||
split_frames("x" * 10, MIN_CHUNK_SIZE - 1)
|
||||
except QrTransferError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected QrTransferError for tiny chunk size")
|
||||
|
||||
|
||||
def test_split_frame_headers_consistent():
|
||||
tx_strings = ["ab" * 80]
|
||||
payload = encode_transfer(tx_strings)
|
||||
frames = split_frames(payload, 150)
|
||||
totals = {parse_frame(frame)[0] for frame in frames}
|
||||
assert len(totals) == 1
|
||||
assert totals.pop() == len(frames)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Optional: cross-check presets against the qrcode library (EC level M)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_presets_fit_qrcode_ec_m():
|
||||
"""Every preset budget must render inside a QR at EC level M."""
|
||||
try:
|
||||
import qrcode
|
||||
from qrcode.constants import ERROR_CORRECT_M
|
||||
except ImportError:
|
||||
print("qrcode not installed - skipping capacity check")
|
||||
return
|
||||
for _label, size in CHUNK_PRESETS:
|
||||
# Worst-case frame: header with the largest plausible total/index plus
|
||||
# a full payload of the preset budget.
|
||||
frame = "BALQR1|9999|9999|Z|" + "a" * (size - 14)
|
||||
qr = qrcode.QRCode(error_correction=ERROR_CORRECT_M, border=2)
|
||||
qr.add_data(frame)
|
||||
qr.get_matrix() # raises DataOverflowError if it does not fit
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
if __name__ == "__main__":
|
||||
import traceback
|
||||
|
||||
@@ -52,9 +52,13 @@ class _Cfg:
|
||||
def get(self):
|
||||
return self._value
|
||||
|
||||
def set(self, value, save=True):
|
||||
self._value = value
|
||||
|
||||
|
||||
class FakePlugin:
|
||||
QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1])
|
||||
def __init__(self):
|
||||
self.QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1])
|
||||
|
||||
def read_file(self, path):
|
||||
return _PNG_BYTES
|
||||
@@ -301,6 +305,7 @@ def test_audio_export_page_send():
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
assert d.audio_send_btn.text() == dialogs._("Send")
|
||||
assert d.audio_send_btn.isEnabled()
|
||||
assert d.transport_audio.isEnabled()
|
||||
assert d.kbps_combo.count() > 0
|
||||
|
||||
kbps = int(d.kbps_combo.currentText())
|
||||
@@ -334,6 +339,7 @@ def test_audio_export_page_plugin_missing():
|
||||
bw.willitems = _make_willitems(2)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
assert not d.audio_send_btn.isEnabled()
|
||||
assert not d.transport_audio.isEnabled()
|
||||
assert "not available" in d.audio_warn_label.text()
|
||||
assert len(d.qr_page.frames) >= 1 # QR still usable
|
||||
assert d.file_export_btn.isEnabled()
|
||||
@@ -350,6 +356,7 @@ def test_audio_import_page_build():
|
||||
assert d.kbps_combo.count() > 0
|
||||
assert d.receive_btn.text() == dialogs._("Receive by audio…")
|
||||
assert d.receive_btn.isEnabled()
|
||||
assert d.transport_audio.isEnabled()
|
||||
d.close()
|
||||
|
||||
|
||||
@@ -357,6 +364,7 @@ def test_audio_import_page_plugin_missing():
|
||||
bw = FakeBalWindow(audio_plugin=None)
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
assert not d.receive_btn.isEnabled()
|
||||
assert not d.transport_audio.isEnabled()
|
||||
assert "not available" in d.audio_warn_label.text()
|
||||
assert d.qr_page is not None # QR import still usable
|
||||
d.close()
|
||||
|
||||
@@ -4,9 +4,10 @@ Tests for the QR / audio will-transfer dialogs (``bal.gui.qt.dialogs``).
|
||||
Covers the unified ``WillExportDialog`` (transport radios, stacked pages,
|
||||
QR build/navigation, chunk-size / autoplay, filter revert) and the unified
|
||||
``WillImportDialog`` (QR frame capture, slot grid, complete-review enabling,
|
||||
total mismatch reset, frame assembly -> decode). The wizard and the
|
||||
camera/audio paths need a live wallet/hardware and are exercised only
|
||||
through the shared frame-assembly path here.
|
||||
total mismatch reset, frame assembly -> decode). The QR transport now uses
|
||||
only the BBQR wire format. The wizard and the camera/audio paths need a live
|
||||
wallet/hardware and are exercised only through the shared frame-assembly
|
||||
path here.
|
||||
|
||||
Run:
|
||||
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_qr_transfer.py
|
||||
@@ -14,6 +15,7 @@ Run:
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -23,7 +25,8 @@ from electrum.transaction import Transaction
|
||||
from PyQt6.QtWidgets import QApplication, QMainWindow
|
||||
|
||||
import bal.gui.qt.dialogs as dialogs
|
||||
from bal.core.qrtransfer import CHUNK_PRESETS, encode_transfer, split_frames
|
||||
from bal.core.animated_qr import bbqr_frames
|
||||
from bal.core.qrtransfer import CHUNK_PRESETS, encode_transfer
|
||||
from bal.core.will import WillItem
|
||||
|
||||
_app = QApplication.instance() or QApplication(sys.argv)
|
||||
@@ -46,6 +49,15 @@ _VALID_TX_HEX = (
|
||||
)
|
||||
|
||||
|
||||
def _rand_hex(n):
|
||||
"""Incompressible lowercase hex string of ``n`` random bytes."""
|
||||
return os.urandom(n).hex()
|
||||
|
||||
|
||||
def _bbqr_frames(transfer, budget):
|
||||
return bbqr_frames(transfer.encode("utf-8"), budget, encoding="Z")
|
||||
|
||||
|
||||
class _Cfg:
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
@@ -53,10 +65,14 @@ class _Cfg:
|
||||
def get(self):
|
||||
return self._value
|
||||
|
||||
def set(self, value, save=True):
|
||||
self._value = value
|
||||
|
||||
|
||||
class FakePlugin:
|
||||
# Smallest QR preset: long transfers produce several frames.
|
||||
QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1])
|
||||
def __init__(self):
|
||||
self.QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1])
|
||||
|
||||
def read_file(self, path):
|
||||
return _PNG_BYTES
|
||||
@@ -109,7 +125,7 @@ class StubWillItem:
|
||||
|
||||
def _make_willitems(n=3, payload_len=120, payloads=None):
|
||||
if payloads is None:
|
||||
payloads = ["T{}".format(i) * payload_len for i in range(n)]
|
||||
payloads = [_rand_hex(payload_len) for _ in range(n)]
|
||||
return {
|
||||
"item{}".format(i): StubWillItem(p)
|
||||
for i, p in enumerate(payloads)
|
||||
@@ -129,8 +145,9 @@ def test_export_dialog_builds():
|
||||
assert page.tx_strings
|
||||
assert page.frames
|
||||
assert len(page.frames) >= 1
|
||||
# Frame 1 is shown.
|
||||
# Frame 1 is shown, and it is a BBQR frame.
|
||||
assert page.qr_view.text == page.frames[0]
|
||||
assert page.frames[0].startswith("B$")
|
||||
assert "1" in page.progress_label.text()
|
||||
d.close()
|
||||
|
||||
@@ -196,6 +213,7 @@ def test_export_auto_scroll():
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
assert page.fps_spin is not None
|
||||
assert len(page.frames) > 1
|
||||
assert not page.auto_timer.isActive()
|
||||
|
||||
page.fps_spin.setValue(2)
|
||||
@@ -222,6 +240,7 @@ def test_export_auto_scroll_loop():
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
assert page.loop_check is not None
|
||||
assert len(page.frames) > 1
|
||||
|
||||
# Loop on: reaching the last code wraps back to the first and keeps going.
|
||||
page.loop_check.setChecked(True)
|
||||
@@ -244,9 +263,9 @@ def test_export_auto_scroll_loop():
|
||||
|
||||
def test_export_filter_valid_and_valid_nc():
|
||||
bw = FakeBalWindow()
|
||||
a = StubWillItem("A" * 120, statuses={"VALID": True, "COMPLETE": True})
|
||||
b = StubWillItem("B" * 120, statuses={"VALID": True})
|
||||
c = StubWillItem("C" * 120)
|
||||
a = StubWillItem(_rand_hex(120), statuses={"VALID": True, "COMPLETE": True})
|
||||
b = StubWillItem(_rand_hex(120), statuses={"VALID": True})
|
||||
c = StubWillItem(_rand_hex(120))
|
||||
bw.willitems = {"a": a, "b": b, "c": c}
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
@@ -263,11 +282,11 @@ def test_export_filter_valid_and_valid_nc():
|
||||
|
||||
# "Valid" filter -> only the valid items (a, b).
|
||||
d._on_filter_change(1)
|
||||
assert sorted(page.tx_strings) == ["A" * 120, "B" * 120]
|
||||
assert sorted(page.tx_strings) == sorted([str(a.tx), str(b.tx)])
|
||||
|
||||
# "Valid NC" filter -> only the valid, not-complete item (b).
|
||||
d._on_filter_change(2)
|
||||
assert sorted(page.tx_strings) == ["B" * 120]
|
||||
assert sorted(page.tx_strings) == [str(b.tx)]
|
||||
assert page.qr_view.text == page.frames[0]
|
||||
d.close()
|
||||
|
||||
@@ -276,7 +295,7 @@ def test_export_filter_empty_reverts():
|
||||
bw = FakeBalWindow()
|
||||
# Only a COMPLETE valid item: "Valid NC" selects nothing -> revert.
|
||||
bw.willitems = {
|
||||
"a": StubWillItem("A" * 120, statuses={"VALID": True, "COMPLETE": True})
|
||||
"a": StubWillItem(_rand_hex(120), statuses={"VALID": True, "COMPLETE": True})
|
||||
}
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
messages = []
|
||||
@@ -290,17 +309,11 @@ def test_export_filter_empty_reverts():
|
||||
|
||||
|
||||
def test_export_navigation_and_chunk_change():
|
||||
# Low-redundancy serialized transactions resist deflate, so even the
|
||||
# compressed best-of transfer still needs several frames at the default
|
||||
# chunk and navigation across frames is exercised.
|
||||
def noisy(pad):
|
||||
return "".join("{:02x}".format((pad * 31 + j * 101 + j * j) % 256) for j in range(200))
|
||||
|
||||
# Incompressible serialized transactions resist deflate, so the BBQR
|
||||
# transfer still needs several frames at the default chunk and navigation
|
||||
# across frames is exercised.
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems(
|
||||
n=6, payload_len=400,
|
||||
payloads=["{}0{}".format(noisy(i), "T" * 50) for i in range(6)],
|
||||
)
|
||||
bw.willitems = _make_willitems(n=6, payload_len=400)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
first_count = len(page.frames)
|
||||
@@ -322,6 +335,20 @@ def test_export_navigation_and_chunk_change():
|
||||
d.close()
|
||||
|
||||
|
||||
def test_export_size_change_is_remembered():
|
||||
# Changing the QR size inside the export dialog persists the choice so the
|
||||
# next export starts from the last selected size.
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems(n=6, payload_len=400)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
assert bw.bal_plugin.QR_CHUNK_SIZE.get() == dialogs.CHUNK_PRESETS[0][1]
|
||||
|
||||
page._on_chunk_change(len(dialogs.CHUNK_PRESETS) - 1)
|
||||
assert bw.bal_plugin.QR_CHUNK_SIZE.get() == dialogs.CHUNK_PRESETS[-1][1]
|
||||
d.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# WillImportDialog (QR transport via d.qr_page)
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -333,8 +360,8 @@ def test_import_frame_flow():
|
||||
assert not page.review_btn.isEnabled()
|
||||
assert not page.slot_area.isVisible()
|
||||
|
||||
transfer = encode_transfer(["A" * 120, "B" * 120, "C" * 120])
|
||||
frames = split_frames(transfer, 150)
|
||||
transfer = encode_transfer([_rand_hex(120), _rand_hex(120), _rand_hex(120)])
|
||||
frames = _bbqr_frames(transfer, 150)
|
||||
assert len(frames) > 1
|
||||
|
||||
for frame in frames:
|
||||
@@ -361,12 +388,14 @@ def test_import_assembles_and_decodes():
|
||||
captured["payload"] = payload
|
||||
|
||||
page._finish_import = fake_finish
|
||||
transfer = encode_transfer(["P" * 130, "Q" * 130])
|
||||
for frame in split_frames(transfer, 150):
|
||||
tx_a = _rand_hex(130)
|
||||
tx_b = _rand_hex(130)
|
||||
transfer = encode_transfer([tx_a, tx_b])
|
||||
for frame in _bbqr_frames(transfer, 150):
|
||||
page._add_frame(frame)
|
||||
page._review_and_sign()
|
||||
# The payload is rejoined into an opaque string for autodetect.
|
||||
assert captured["payload"].split("\n") == ["P" * 130, "Q" * 130]
|
||||
assert captured["payload"].split("\n") == [tx_a, tx_b]
|
||||
d.close()
|
||||
|
||||
|
||||
@@ -407,7 +436,7 @@ def test_whole_will_qr_roundtrip():
|
||||
transfer = encode_transfer(page.tx_strings)
|
||||
impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
import_page = impl.qr_page
|
||||
for frame in split_frames(transfer, dialogs.CHUNK_PRESETS[0][1]):
|
||||
for frame in _bbqr_frames(transfer, dialogs.CHUNK_PRESETS[0][1]):
|
||||
import_page._add_frame(frame)
|
||||
assert import_page.review_btn.isEnabled()
|
||||
caught = {}
|
||||
@@ -431,8 +460,10 @@ def test_import_total_mismatch_resets():
|
||||
warnings = []
|
||||
page.show_warning = lambda msg: warnings.append(msg) # type: ignore[assignment]
|
||||
|
||||
frames_a = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150)
|
||||
frames_b = split_frames(encode_transfer(["A" * 120, "B" * 120, "C" * 120]), 150)
|
||||
frames_a = _bbqr_frames(encode_transfer([_rand_hex(120), _rand_hex(120)]), 150)
|
||||
frames_b = _bbqr_frames(
|
||||
encode_transfer([_rand_hex(120), _rand_hex(120), _rand_hex(120)]), 150
|
||||
)
|
||||
for frame in frames_a:
|
||||
page._add_frame(frame)
|
||||
assert page.total == len(frames_a)
|
||||
@@ -451,7 +482,7 @@ def test_import_manual_entry():
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
page = d.qr_page
|
||||
frames = split_frames(encode_transfer(["M" * 120]), 150)
|
||||
frames = _bbqr_frames(encode_transfer(["M" * 20]), 150)
|
||||
page.manual_edit.setText(frames[0])
|
||||
page._add_from_manual()
|
||||
assert page.manual_edit.text() == ""
|
||||
@@ -477,48 +508,48 @@ def _fresh_debounce():
|
||||
def test_qr_import_debounce_pending_then_accept():
|
||||
s = _fresh_debounce()
|
||||
# First sighting of a new identity: pending, not yet stored.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "PAYLOAD1") == "pending"
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "PAYLOAD1") == "pending"
|
||||
assert s["pending_count"] == 1
|
||||
assert s["last_index"] is None
|
||||
# A second stable read of the same identity: accepted.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "PAYLOAD1") == "accept"
|
||||
assert s["last_index"] == 1
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "PAYLOAD1") == "accept"
|
||||
assert s["last_index"] == 0
|
||||
assert s["last_payload"] == "PAYLOAD1"
|
||||
assert s["pending_count"] == 0
|
||||
|
||||
|
||||
def test_qr_import_debounce_re_reading_last_is_ignored():
|
||||
s = _fresh_debounce()
|
||||
dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1")
|
||||
dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1") # accepted
|
||||
# The exporter is still showing frame 1: must be ignored, not accepted.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1") == "ignore"
|
||||
assert s["last_index"] == 1
|
||||
dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1")
|
||||
dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1") # accepted
|
||||
# The exporter is still showing frame 0: must be ignored, not accepted.
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1") == "ignore"
|
||||
assert s["last_index"] == 0
|
||||
assert s["pending_count"] == 0
|
||||
|
||||
|
||||
def test_qr_import_debounce_transition_pending_resets():
|
||||
s = _fresh_debounce()
|
||||
dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1")
|
||||
dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1") # accept frame 1
|
||||
dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1")
|
||||
dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1") # accept frame 0
|
||||
# A new identity interrupts the pending accumulation.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 2, "P2") == "pending"
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 2, "P2") == "accept"
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 1, "P2") == "pending"
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 1, "P2") == "accept"
|
||||
# Same-index duplicate with different payload is treated as new identity.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 2, "P2") == "ignore"
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 1, "P2") == "ignore"
|
||||
|
||||
|
||||
def test_qr_import_debounce_total_mismatch_resets():
|
||||
s = _fresh_debounce()
|
||||
# In-range frame is accepted even though its declared total is ignored.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:3", 3, 2, "P2") == "pending"
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:3", 3, 2, "P2") == "accept"
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:3", 3, 1, "P2") == "pending"
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:3", 3, 1, "P2") == "accept"
|
||||
# A frame that belongs to a different transfer.
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:4", 4, 2, "P2B") == "reset"
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:4", 4, 1, "P2B") == "reset"
|
||||
# Once the policy is rebased on the new transfer, frames resume normally
|
||||
# (the widget clears the debounce while wiping the import).
|
||||
s["key"] = None
|
||||
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:4", 4, 2, "P2B") == "pending"
|
||||
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:4", 4, 1, "P2B") == "pending"
|
||||
|
||||
|
||||
def test_qr_import_handle_scanned_text_autofinish():
|
||||
@@ -528,7 +559,7 @@ def test_qr_import_handle_scanned_text_autofinish():
|
||||
reviewed = []
|
||||
page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment]
|
||||
|
||||
frames = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150)
|
||||
frames = _bbqr_frames(encode_transfer([_rand_hex(120), _rand_hex(120)]), 150)
|
||||
assert len(frames) > 1
|
||||
|
||||
# The camera session is running and every frame needs two stable reads.
|
||||
@@ -557,7 +588,7 @@ def test_qr_import_handle_scanned_text_manual_does_not_autofinish():
|
||||
reviewed = []
|
||||
page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment]
|
||||
|
||||
frames = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150)
|
||||
frames = _bbqr_frames(encode_transfer([_rand_hex(120), _rand_hex(120)]), 150)
|
||||
assert len(frames) > 1
|
||||
assert not page._scanning
|
||||
for frame in frames:
|
||||
@@ -570,54 +601,16 @@ def test_qr_import_handle_scanned_text_manual_does_not_autofinish():
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Animated-QR formats (BC-UR v1/v2, BBQR) via the export/import pages
|
||||
# BBQR import/export round trips
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_export_format_combo_switches_codecs():
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems(n=6, payload_len=400)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
assert page.format == "balqr"
|
||||
assert page.frames[0].startswith("BAL1")
|
||||
assert page.format_combo.count() == 4
|
||||
|
||||
page._on_format_change(1) # BC-UR v1
|
||||
assert page.format == "ur1"
|
||||
assert page.frames[0].startswith("ur:bytes/")
|
||||
assert page.index == 0
|
||||
assert page.qr_view.text == page.frames[0]
|
||||
|
||||
page._on_format_change(2) # BC-UR v2
|
||||
assert page.format == "ur2"
|
||||
assert page.frames[0].startswith("ur:bytes/")
|
||||
|
||||
page._on_format_change(3) # BBQR
|
||||
assert page.format == "bbqr"
|
||||
assert page.frames[0].startswith("B$")
|
||||
d.close()
|
||||
|
||||
|
||||
def test_export_animated_format_frames_fit_budget():
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems(n=6, payload_len=400)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
for index in range(1, 4):
|
||||
page._on_format_change(index)
|
||||
for frame in page.frames:
|
||||
assert len(frame) <= dialogs.CHUNK_PRESETS[0][1]
|
||||
d.close()
|
||||
|
||||
|
||||
def _import_roundtrip_fmt(fmt_index):
|
||||
def test_import_bbqr_roundtrip():
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems(2)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
page._on_format_change(fmt_index)
|
||||
frames = list(page.frames)
|
||||
assert frames
|
||||
assert frames and all(f.startswith("B$") for f in frames)
|
||||
d.close()
|
||||
|
||||
impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
@@ -634,24 +627,11 @@ def _import_roundtrip_fmt(fmt_index):
|
||||
impl.close()
|
||||
|
||||
|
||||
def test_import_ur1_roundtrip():
|
||||
_import_roundtrip_fmt(1)
|
||||
|
||||
|
||||
def test_import_ur2_roundtrip():
|
||||
_import_roundtrip_fmt(2)
|
||||
|
||||
|
||||
def test_import_bbqr_roundtrip():
|
||||
_import_roundtrip_fmt(3)
|
||||
|
||||
|
||||
def test_import_animated_scan_debounce_autofinish():
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems(2)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
page = d.qr_page
|
||||
page._on_format_change(2) # BC-UR v2 fountain
|
||||
frames = list(page.frames)
|
||||
d.close()
|
||||
|
||||
@@ -664,11 +644,8 @@ def test_import_animated_scan_debounce_autofinish():
|
||||
for _rep in range(2):
|
||||
import_page._handle_scanned_text(frame)
|
||||
assert import_page.review_btn.isEnabled()
|
||||
# The fountain transfer's part count is ``len(frames) // 2`` (pure + one
|
||||
# redundant mixed wave).
|
||||
assert import_page.total == len(frames) // 2
|
||||
assert import_page.total == len(frames)
|
||||
assert len(import_page.frames) >= import_page.total
|
||||
assert import_page.review_btn.isEnabled()
|
||||
_app.processEvents()
|
||||
assert reviewed == [True]
|
||||
assert not import_page._scanning
|
||||
@@ -679,7 +656,7 @@ def test_import_garbage_scan_is_ignored():
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
page = d.qr_page
|
||||
for garbage in ("hello world", "12345", "B$ZZ", ""):
|
||||
for garbage in ("hello world", "12345", "B$ZZ", "ur:bytes/xyz", ""):
|
||||
page._handle_scanned_text(garbage)
|
||||
assert not page.frames
|
||||
assert page.total == 0
|
||||
@@ -694,8 +671,10 @@ def test_import_different_animated_transfer_resets():
|
||||
warnings = []
|
||||
page.show_warning = lambda msg: warnings.append(msg) # type: ignore[assignment]
|
||||
|
||||
frames_a = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150)
|
||||
frames_b = split_frames(encode_transfer(["A" * 120, "B" * 120, "C" * 120]), 150)
|
||||
frames_a = _bbqr_frames(encode_transfer([_rand_hex(120), _rand_hex(120)]), 150)
|
||||
frames_b = _bbqr_frames(
|
||||
encode_transfer([_rand_hex(120), _rand_hex(120), _rand_hex(120)]), 150
|
||||
)
|
||||
for frame in frames_a:
|
||||
page._add_frame(frame)
|
||||
assert page.total == len(frames_a)
|
||||
|
||||
Reference in New Issue
Block a user