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:
2026-10-03 13:00:23 -04:00
parent 47b120f526
commit 5ec2b79d03
16 changed files with 1114 additions and 3245 deletions

View File

@@ -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"

View File

@@ -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

View File

@@ -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()

View File

@@ -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)