core+gui: compact BAL QR v2 wire format + best-of compression default
BAL QR export now ships a fixed 11-char 'BAL1<TTT><iii><F><payload>' header (3-char base36 zero-padded count fields, cap 46655 frames) and, by default, the shorter of plain vs zlib+base64 (flag '0'/'Z'). parse_frame remains dual: legacy 'BALQR1|total|index|flags|' frames still import. detect_format accepts 'BAL1'. New exports are not readable by builds older than this change. Also add a will-detail test for heir/willexecutor addresses and decoded OP_RETURN payloads. Docs updated (CHANGELOG, COMPATIBILITY, PLAN_QR_TRANSFER, README); 345 core tests + 89 GUI tests pass.
This commit is contained in:
62
CHANGELOG.md
62
CHANGELOG.md
@@ -3265,3 +3265,65 @@ as the default.
|
|||||||
`external_zip_test.py` all pass.
|
`external_zip_test.py` all pass.
|
||||||
|
|
||||||
**Outcome:** DONE (uncommitted).
|
**Outcome:** DONE (uncommitted).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Balanced-QR wire format v2: compact header + best-of compression
|
||||||
|
|
||||||
|
**Date:** 2026-09-13
|
||||||
|
|
||||||
|
**Goal:** Shrink the native BAL QR wire format to its minimum. The old
|
||||||
|
pipe-separated header (`BALQR1|total|index|flags|`) wasted 12-14 characters on
|
||||||
|
direction marker, separators and decimal count fields, and the export always
|
||||||
|
sent uncompressed hex text. New exports should fit a will in the fewest,
|
||||||
|
densest frames possible.
|
||||||
|
|
||||||
|
**What changed:**
|
||||||
|
|
||||||
|
- `bal/core/qrtransfer.py`:
|
||||||
|
- New wire format v2: `BAL1<TTT><iii><F><payload>` — fixed 11-char header,
|
||||||
|
no separators. `BAL1` magic, 3-digit **base36** zero-padded totals/index
|
||||||
|
(values `00A`-`ZZZ`, cap 46655 frames), single flag char.
|
||||||
|
- Flags: `0` = plain payload, `Z` = zlib+base64 compressed payload (the importer
|
||||||
|
already decompressed `Z`; the exporter now produces it).
|
||||||
|
- `encode_transfer_best(tx_strings)` returns the shorter of plain vs
|
||||||
|
compressed; the export widget uses it as the default for BAL QR.
|
||||||
|
- `parse_frame` is dual: old `BALQR1|total|index|flags|payload` frames still
|
||||||
|
import unchanged (backwards-compatible receive).
|
||||||
|
- Frame-count overflow (a transfer needing > 46655 frames) raises
|
||||||
|
`QrTransferError` at encode time instead of emitting corrupt headers.
|
||||||
|
- `bal/gui/qt/dialogs.py` (`BalQrExportWidget`): BAL QR export now encodes via
|
||||||
|
`encode_transfer_best`, so plain *or* compressed frames are emitted per
|
||||||
|
transfer; import is untouched (already format-agnostic and flag-driven).
|
||||||
|
- `bal/core/animated_qr.py`: `detect_format` accepts `BAL1` in addition to the
|
||||||
|
legacy `BALQR` prefix; wire-format docstring updated.
|
||||||
|
- `bal/gui/qt/widgets.py` (`WillWidget`): the will detail view now shows each
|
||||||
|
heir's address (or the decoded UTF-8 text of an `OP_RETURN:` heir) and a
|
||||||
|
dedicated Address row for the will-executor.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
|
||||||
|
- `tests/test_core_qr_transfer.py` (new v2 tests: header structure, field width,
|
||||||
|
`Z` flag round-trip, best-of selection, malformed `BAL1` frames, 46655 cap and
|
||||||
|
exact boundary): all pass; legacy `BALQR1` parse tests unchanged and green.
|
||||||
|
- `tests/test_core_animated_qr.py`: `BAL1` detection + `parse_for_detection`;
|
||||||
|
`tests/test_gui_qr_transfer.py`: format-combo + chunk-navigation updated for
|
||||||
|
the compact export.
|
||||||
|
- `pytest tests/test_core_*.py tests/test_import_will_details.py -q`: 345 passed.
|
||||||
|
- `ruff` clean on all touched files except the pre-existing `dialogs.py` I001
|
||||||
|
(present on HEAD); `pyright` 0 errors on the codec modules.
|
||||||
|
- Android Chaquopy bundle re-synced (`sync_codecs.py` + `verify_chain.py`).
|
||||||
|
|
||||||
|
**Notes / caveats:**
|
||||||
|
|
||||||
|
- **Compatibility break (forward):** the new default export (compressed
|
||||||
|
`BAL1…`) is NOT readable by older BAL versions — nor by the previously
|
||||||
|
released Android APK — until those are updated to accept `BAL1`. Imports of
|
||||||
|
legacy `BALQR1…` exports keep working on this version. Existing audio
|
||||||
|
transfers are unaffected (they keep explicit `compress=False`, and the audio
|
||||||
|
format was never flag-driven on receive).
|
||||||
|
- A typical multi-tx will now ships as a single dense frame instead of two
|
||||||
|
sparse ones: header overhead dropped from 12-14 chars to a constant 11, and
|
||||||
|
the base36 count fields are 3 chars regardless of how many frames exist.
|
||||||
|
|
||||||
|
**Outcome:** DONE.
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ supports **BC-UR v1**, **BC-UR v2** and **BBQR**:
|
|||||||
|
|
||||||
| Format | Wire appearance | Interop target |
|
| Format | Wire appearance | Interop target |
|
||||||
|-----------|----------------------------|------------------------------------------------------|
|
|-----------|----------------------------|------------------------------------------------------|
|
||||||
| BAL QR | `BALQR1\|total\|index\|…` | Past/other BAL versions (default, always exported) |
|
| BAL QR | `BAL1<total><index><flag>…` (v2) / `BALQR1\|total\|index\|…` (legacy import-only) | Past/other BAL versions: **v2 exports are NOT readable by old builds**; old `BALQR1` exports still import here (default, best-of compression, flag `0` = plain, `Z` = deflate) |
|
||||||
| BC-UR v1 | `ur:bytes/<bc32>` | Blockchain Commons / Coldcard-style UR (BC32, SHA-256 digest, part counts per part) |
|
| BC-UR v1 | `ur:bytes/<bc32>` | Blockchain Commons / Coldcard-style UR (BC32, SHA-256 digest, part counts per part) |
|
||||||
| BC-UR v2 | `ur:bytes/<seq>-<seqlen>/<bytewords>` | BC-UR 2.x fountain codes (CBOR parts, CRC-32, bytewords-minimal) |
|
| BC-UR v2 | `ur:bytes/<seq>-<seqlen>/<bytewords>` | BC-UR 2.x fountain codes (CBOR parts, CRC-32, bytewords-minimal) |
|
||||||
| BBQR | `B$<enc><type><N><n>…` | Coinkite BitKit / Coldcard's BBQR animated-QR mode |
|
| BBQR | `B$<enc><type><N><n>…` | Coinkite BitKit / Coldcard's BBQR animated-QR mode |
|
||||||
|
|||||||
@@ -62,9 +62,9 @@
|
|||||||
| # | Decision |
|
| # | Decision |
|
||||||
|---|----------|
|
|---|----------|
|
||||||
| D1 | QML part = update `QML_PLAN.md` document only; implementation later. |
|
| D1 | QML part = update `QML_PLAN.md` document only; implementation later. |
|
||||||
| D2 | Frames carry a small ASCII header (`BALQR1\|N\|i\|flags`) — import knows the total, auto-fills the grid, detects corrupt/duplicate/mismatched frames. Pure concatenation rejected. |
|
| D2 | Frames carry a small **compact** ASCII header (`BAL1<total><index><flag>`, fixed 11 chars, base36 count fields) — import knows the total, auto-fills the grid, detects corrupt/duplicate/mismatched frames. Pure concatenation rejected. Legacy `BALQR1\|N\|i\|flags\|` export removed; legacy import kept. |
|
||||||
| D3 | Payload = **serialized transaction strings only** (`str(wi.tx)`), NOT the JSON will dump. Loses statuses/metadata on purpose; import rebuilds items like `merge_single_transaction` does. |
|
| D3 | Payload = **serialized transaction strings only** (`str(wi.tx)`), NOT the JSON will dump. Loses statuses/metadata on purpose; import rebuilds items like `merge_single_transaction` does. |
|
||||||
| D4 | Compression (zlib+base64 over the whole payload) = checkbox in the export dialog, **default OFF**, advertised via a frame flag. |
|
| D4 | Compression (zlib+base64 over the whole payload) exported as **best-of**: `encode_transfer_best` ships compressed when it is shorter, plain otherwise; flag `0` = plain, `Z` = compressed. No user-facing checkbox. |
|
||||||
| D5 | 4 standard size presets: **~150 / ~400 / ~900 / ~1800 bytes** of payload per QR (low-res cams → high-res cams). Error-correction level fixed **M**. Stored as plugin config default; selectable again inside the export dialog. |
|
| D5 | 4 standard size presets: **~150 / ~400 / ~900 / ~1800 bytes** of payload per QR (low-res cams → high-res cams). Error-correction level fixed **M**. Stored as plugin config default; selectable again inside the export dialog. |
|
||||||
| D6 | After capture completes: **review + sign each tx one at a time** (show outputs, total outputs, total fees), then **propose export of the signed txs** (file and/or QR). Supersedes the earlier "WillDetailDialog preview" answer. |
|
| D6 | After capture completes: **review + sign each tx one at a time** (show outputs, total outputs, total fees), then **propose export of the signed txs** (file and/or QR). Supersedes the earlier "WillDetailDialog preview" answer. |
|
||||||
| D7 | Add an **audio-modem transfer path** gated on Electrum's `audio_modem` plugin being enabled and available (`amodem` importable). Same payload semantics as QR (transfer string of serialized txs), but NO BAL frame chunking — `amodem` handles transport framing internally. Buttons simply hidden when the plugin is absent/unavailable (info message pointing at `pip install amodem` when enabled-but-broken); graceful degradation, never a hard dependency. |
|
| D7 | Add an **audio-modem transfer path** gated on Electrum's `audio_modem` plugin being enabled and available (`amodem` importable). Same payload semantics as QR (transfer string of serialized txs), but NO BAL frame chunking — `amodem` handles transport framing internally. Buttons simply hidden when the plugin is absent/unavailable (info message pointing at `pip install amodem` when enabled-but-broken); graceful degradation, never a hard dependency. |
|
||||||
@@ -120,19 +120,30 @@ transfer_string = "\n".join( tx_str(tx) for tx in valid_txs_sorted_by_txid )
|
|||||||
|
|
||||||
### 4.2 Frame layout (one frame = content of ONE QR code)
|
### 4.2 Frame layout (one frame = content of ONE QR code)
|
||||||
|
|
||||||
|
Wire format v2 (compact, current export):
|
||||||
|
|
||||||
```
|
```
|
||||||
BALQR1|<total>|<index>|<flags>|<payload>
|
BAL1<TTT><iii><F><payload>
|
||||||
```
|
```
|
||||||
|
|
||||||
- Magic+version literal `BALQR1` (reject anything else with a clear message;
|
- Magic+version literal `BAL1` (reject anything else with a clear message).
|
||||||
keeps the door open for a future `BALQR2`).
|
- `<TTT>` = `<iii>` — **base36** zero-padded 3-char strings (`000`…`ZZZ`),
|
||||||
- `<total>` N, `<index>` i — integers, `1 ≤ i ≤ N`.
|
representing total N and index i, `1 ≤ i ≤ N ≤ 46655`. Fixed width means a
|
||||||
- `<flags>`: subset of chars, today `` (empty ⇒ plain) or `Z` (compressed).
|
3-digit count field costs the same for a 1-frame or a 46655-frame transfer.
|
||||||
|
- `<F>`: single flag char — `0` ⇒ plain, `Z` ⇒ zlib+base64 compressed.
|
||||||
- `<payload>`: the i-th slice of `transfer_string`, exactly
|
- `<payload>`: the i-th slice of `transfer_string`, exactly
|
||||||
`chunk_size` bytes each (last slice may be shorter).
|
`chunk_size` bytes each (last slice may be shorter). No separators: both
|
||||||
- Header overhead ≈ 16–20 bytes → effective payload = `chunk_size − overhead`;
|
base36 count fields are fixed-width, so the header is unambiguously 11
|
||||||
the chunker slices the transfer string so that **header+payload ≤ preset
|
chars and the payload starts at offset 11.
|
||||||
size**.
|
- Header overhead is a constant **11 bytes** → effective payload =
|
||||||
|
`chunk_size − 11`; the chunker slices the transfer string so that
|
||||||
|
**header+payload ≤ preset size**.
|
||||||
|
|
||||||
|
Legacy frames `BALQR1|<total>|<index>|<flags>|<payload>` (variable-width
|
||||||
|
decimal header, pipe-separated) are still **imported** by `parse_frame`
|
||||||
|
(`_parse_v1`), so old exports keep working; the exporter emits v2 only.
|
||||||
|
That is the one deliberate compatibility break: **v2 frames are not readable
|
||||||
|
by builds older than this change.**
|
||||||
|
|
||||||
### 4.3 Size presets (D5)
|
### 4.3 Size presets (D5)
|
||||||
|
|
||||||
@@ -173,12 +184,18 @@ def encode_transfer(tx_strings: list[str], compress: bool = False) -> str
|
|||||||
"""Join -> optional zlib+base64 -> return transfer_string."""
|
"""Join -> optional zlib+base64 -> return transfer_string."""
|
||||||
|
|
||||||
def split_frames(transfer_string: str, chunk_size: int) -> list[str]
|
def split_frames(transfer_string: str, chunk_size: int) -> list[str]
|
||||||
"""Slice into full frames 'BALQR1|N|i|flags|payload'. Raises
|
"""Slice into frames 'BAL1<TTT><iii><F>payload' (v2) — header+payload <=
|
||||||
|
chunk_size. Raises QrTransferError over the 46655-frame base36 cap, or
|
||||||
ValueError if chunk_size < MIN_CHUNK_SIZE."""
|
ValueError if chunk_size < MIN_CHUNK_SIZE."""
|
||||||
|
|
||||||
|
def encode_transfer_best(tx_strings: list[str]) -> tuple[str, bool]
|
||||||
|
"""-> (transfer_string, compressed); ships the shorter of plain vs
|
||||||
|
zlib+base64 so the export emits the densest frames."""
|
||||||
|
|
||||||
def parse_frame(frame: str) -> tuple[int, int, bool, str]
|
def parse_frame(frame: str) -> tuple[int, int, bool, str]
|
||||||
"""-> (total, index, compressed, payload); ValueError on bad magic/
|
"""-> (total, index, compressed, payload); accepts v2 'BAL1…' and legacy
|
||||||
version/arity/non-int fields."""
|
'BALQR1|…' (wrapped as _parse_v1/_parse_v2); ValueError on bad magic/
|
||||||
|
version/arity/non-numeric fields."""
|
||||||
|
|
||||||
def assemble(frames: dict[int, str]) -> str
|
def assemble(frames: dict[int, str]) -> str
|
||||||
"""Validate indices form exactly range(1..max_total) (taken from any
|
"""Validate indices form exactly range(1..max_total) (taken from any
|
||||||
@@ -260,7 +277,7 @@ def get_audio_modem_plugin(self): # on BalWindow
|
|||||||
Layout:
|
Layout:
|
||||||
|
|
||||||
```
|
```
|
||||||
[Size ▾ Small/Medium/Large/XL] [x Compress (zlib+base64)]
|
[Size ▾ Small/Medium/Large/XL] ← compressed best-of automatically (no checkbox)
|
||||||
[ QR image ] ← BalQrImage (see below)
|
[ QR image ] ← BalQrImage (see below)
|
||||||
«i di N» [◀ Prev] [Next ▶]
|
«i di N» [◀ Prev] [Next ▶]
|
||||||
[Save current QR as PNG…] [Send via Audio Modem…] [Close]
|
[Save current QR as PNG…] [Send via Audio Modem…] [Close]
|
||||||
@@ -268,7 +285,7 @@ Layout:
|
|||||||
|
|
||||||
Behaviour:
|
Behaviour:
|
||||||
|
|
||||||
- On any control change: rebuild `split_frames(encode_transfer(...))`,
|
- On any control change: rebuild `split_frames(encode_transfer_best(...))`,
|
||||||
reset index to frame 1, refresh counter (owner requirement: "cambiare la
|
reset index to frame 1, refresh counter (owner requirement: "cambiare la
|
||||||
risoluzione").
|
risoluzione").
|
||||||
- `BalQrImage(QWidget)` ≈ trimmed copy of `QRCodeWidget`
|
- `BalQrImage(QWidget)` ≈ trimmed copy of `QRCodeWidget`
|
||||||
|
|||||||
@@ -94,7 +94,9 @@ exists, then enable it from **Tools → Plugins**.
|
|||||||
From the will list (**Export → QR Codes**) a will can be exported as a
|
From the will list (**Export → QR Codes**) a will can be exported as a
|
||||||
sequence of QR codes and imported on another device (**Import via QR**). The
|
sequence of QR codes and imported on another device (**Import via QR**). The
|
||||||
export offers All / Valid / Valid-NC filters plus a QR size preset
|
export offers All / Valid / Valid-NC filters plus a QR size preset
|
||||||
(150–1800 bytes/frame); the import flow reviews and sign each transaction
|
(150–1800 bytes/frame) and ships the default **BAL QR** format already
|
||||||
|
compressed whenever that is smaller (best-of zlib, flag per frame); the import
|
||||||
|
flow reviews and sign each transaction
|
||||||
one at a time, then proposes exporting the signed transactions. When
|
one at a time, then proposes exporting the signed transactions. When
|
||||||
Electrum's `audio_modem` plugin is enabled (optional, requires `amodem` +
|
Electrum's `audio_modem` plugin is enabled (optional, requires `amodem` +
|
||||||
PortAudio) Send/Receive audio buttons complement the QR channel. See
|
PortAudio) Send/Receive audio buttons complement the QR channel. See
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ used to move BAL will data between devices.
|
|||||||
Supported wire formats (each self-describing and order-independent on
|
Supported wire formats (each self-describing and order-independent on
|
||||||
receive):
|
receive):
|
||||||
|
|
||||||
* **BALQR** (native, unchanged): ``BALQR1|total|index|flags|payload``.
|
* **BALQR** (native): ``BAL1<TTT><iii><flag><payload>`` compact fixed-width
|
||||||
|
header (11 chars, 3-digit base36 count fields, no separators); legacy
|
||||||
|
``BALQR1|total|index|flags|payload`` still imported.
|
||||||
* **BC-UR v1** (BCR-2020-005 rev1 draft, May 2020)::
|
* **BC-UR v1** (BCR-2020-005 rev1 draft, May 2020)::
|
||||||
ur:bytes/1of7/<bc32-digest>/<bc32-fragment>
|
ur:bytes/1of7/<bc32-digest>/<bc32-fragment>
|
||||||
Fragments partition the BC32 rendering of the CBOR byte string; the
|
Fragments partition the BC32 rendering of the CBOR byte string; the
|
||||||
@@ -790,7 +792,7 @@ def detect_format(text: str) -> Optional[str]:
|
|||||||
if not text:
|
if not text:
|
||||||
return None
|
return None
|
||||||
lowered = text.lower()
|
lowered = text.lower()
|
||||||
if lowered.startswith("balqr"):
|
if lowered.startswith(("balqr", "bal1")):
|
||||||
return "balqr"
|
return "balqr"
|
||||||
if text.startswith(_BBQR_PREFIX):
|
if text.startswith(_BBQR_PREFIX):
|
||||||
return "bbqr"
|
return "bbqr"
|
||||||
|
|||||||
@@ -10,9 +10,27 @@ Scope
|
|||||||
* converts will transactions into a compact ``transfer_string``
|
* converts will transactions into a compact ``transfer_string``
|
||||||
(newline-joined serialized transactions, optionally zlib + base64
|
(newline-joined serialized transactions, optionally zlib + base64
|
||||||
compressed);
|
compressed);
|
||||||
* splits that string into fixed-size ``BALQR1|N|i|flags|payload`` frames for
|
* splits that string into fixed-size ``BAL1<TTT><iii><flag>`` frames for
|
||||||
multi-QR export, and reassembles/validates them on import.
|
multi-QR export, and reassembles/validates them on import.
|
||||||
|
|
||||||
|
Wire format (v2, compact)
|
||||||
|
-------------------------
|
||||||
|
A frame is::
|
||||||
|
|
||||||
|
BAL1<TTT><iii><flag><payload>
|
||||||
|
|
||||||
|
* ``BAL1`` - magic + format era (4 chars).
|
||||||
|
* ``TTT`` - frame total as exactly 3 base36 digits (1-based, cap 46655).
|
||||||
|
* ``iii`` - frame index as exactly 3 base36 digits (1-based).
|
||||||
|
* ``flag`` - one char: ``Z`` (zlib + base64) or ``0`` (plain ASCII).
|
||||||
|
* ``payload`` - every other character of the frame; the payloads of all
|
||||||
|
frames, concatenated in index order, rebuild the transfer string.
|
||||||
|
|
||||||
|
The fixed 11-char header replaces the legacy ``BALQR1|N|i|flags|`` form
|
||||||
|
(same 5 pieces of information) without any pipe separator, so the whole
|
||||||
|
frame is scan-friendly and the overhead no longer grows with the frame
|
||||||
|
count. Legacy ``BALQR1|…`` frames are still accepted on import.
|
||||||
|
|
||||||
The audio-modem channel deliberately bypasses the framing helpers here
|
The audio-modem channel deliberately bypasses the framing helpers here
|
||||||
(PLAN_QR_TRANSFER.md section 4.4): its transport compresses internally and
|
(PLAN_QR_TRANSFER.md section 4.4): its transport compresses internally and
|
||||||
carries the whole transfer string in a single blob, so callers only use
|
carries the whole transfer string in a single blob, so callers only use
|
||||||
@@ -29,6 +47,7 @@ import zlib
|
|||||||
MAGIC = "BALQR"
|
MAGIC = "BALQR"
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
FLAG_COMPRESSED = "Z"
|
FLAG_COMPRESSED = "Z"
|
||||||
|
FLAG_PLAIN = "0"
|
||||||
|
|
||||||
# 4 standard presets (label, payload budget in bytes per QR). Ordered from
|
# 4 standard presets (label, payload budget in bytes per QR). Ordered from
|
||||||
# low-resolution cameras to high-resolution cameras (owner decision D5).
|
# low-resolution cameras to high-resolution cameras (owner decision D5).
|
||||||
@@ -43,7 +62,15 @@ CHUNK_PRESETS = (
|
|||||||
# could consume the whole budget.
|
# could consume the whole budget.
|
||||||
MIN_CHUNK_SIZE = 40
|
MIN_CHUNK_SIZE = 40
|
||||||
|
|
||||||
_FRAME_MAGIC = MAGIC + str(VERSION)
|
# Legacy wire format (still imported); the exporter emits the v2 form below.
|
||||||
|
_FRAME_MAGIC_V1 = MAGIC + str(VERSION)
|
||||||
|
|
||||||
|
# Compact v2 wire format: fixed-width base36 count fields, no separators.
|
||||||
|
_FRAME_MAGIC_V2 = "BAL1"
|
||||||
|
_BASE36_DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
_BASE36_WIDTH = 3
|
||||||
|
_HEADER_V2_LEN = len(_FRAME_MAGIC_V2) + 2 * _BASE36_WIDTH + 1
|
||||||
|
_MAX_TOTAL = 36 ** _BASE36_WIDTH - 1
|
||||||
|
|
||||||
|
|
||||||
class QrTransferError(ValueError):
|
class QrTransferError(ValueError):
|
||||||
@@ -66,12 +93,26 @@ def encode_transfer(tx_strings, compress=False):
|
|||||||
"""Join serialized transaction strings into a transfer string.
|
"""Join serialized transaction strings into a transfer string.
|
||||||
|
|
||||||
``compress=True`` wraps the joined text in zlib + base64 (ASCII-safe) so
|
``compress=True`` wraps the joined text in zlib + base64 (ASCII-safe) so
|
||||||
the whole bundle shrinks before being printed/scanned. The optional flags
|
the whole bundle shrinks before being printed/scanned. The optional flag
|
||||||
of the frame header let the importer reverse this automatically.
|
of the frame header lets the importer reverse this automatically.
|
||||||
"""
|
"""
|
||||||
return __compress("\n".join(tx_strings), enabled=compress)
|
return __compress("\n".join(tx_strings), enabled=compress)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_transfer_best(tx_strings):
|
||||||
|
"""Encode ``tx_strings`` with the smaller of plain vs compressed form.
|
||||||
|
|
||||||
|
Returns ``(transfer_string, compressed: bool)``. Compressed wins only
|
||||||
|
when zlib + base64 really is shorter (best-of, never larger).
|
||||||
|
"""
|
||||||
|
joined = "\n".join(tx_strings)
|
||||||
|
plain = joined
|
||||||
|
compressed = __compress(joined, enabled=True)
|
||||||
|
if len(compressed) < len(plain):
|
||||||
|
return compressed, True
|
||||||
|
return plain, False
|
||||||
|
|
||||||
|
|
||||||
def decode_transfer(transfer_string, compressed):
|
def decode_transfer(transfer_string, compressed):
|
||||||
"""Inverse of :func:`encode_transfer`.
|
"""Inverse of :func:`encode_transfer`.
|
||||||
|
|
||||||
@@ -84,28 +125,33 @@ def decode_transfer(transfer_string, compressed):
|
|||||||
|
|
||||||
|
|
||||||
def split_frames(transfer_string, chunk_size, compressed=False):
|
def split_frames(transfer_string, chunk_size, compressed=False):
|
||||||
"""Split ``transfer_string`` into full ``BALQR`` frames.
|
"""Split ``transfer_string`` into full compact ``BAL1`` frames.
|
||||||
|
|
||||||
Every returned frame is at most ``chunk_size`` characters long (header
|
Every returned frame has the fixed 11-char v2 header followed by its
|
||||||
included). ``compressed`` propagates the ``Z`` flag into every frame so
|
share of the payload, so each frame is at most ``chunk_size`` characters
|
||||||
the importer knows how to reverse the encoding.
|
long. ``compressed`` stamps the ``Z`` flag into every frame so the
|
||||||
|
importer knows how to reverse the encoding.
|
||||||
|
|
||||||
Raises :class:`QrTransferError` when ``chunk_size`` is too small to hold
|
Raises :class:`QrTransferError` when ``chunk_size`` is too small to hold
|
||||||
the header plus any payload.
|
the header plus any payload, or when the transfer needs more than
|
||||||
|
:data:`_MAX_TOTAL` frames.
|
||||||
"""
|
"""
|
||||||
flags = FLAG_COMPRESSED if compressed else ""
|
flag = FLAG_COMPRESSED if compressed else FLAG_PLAIN
|
||||||
total = __compute_total(len(transfer_string), chunk_size, flags)
|
total = __compute_total(len(transfer_string), chunk_size)
|
||||||
|
budget = chunk_size - _HEADER_V2_LEN
|
||||||
frames = []
|
frames = []
|
||||||
pos = 0
|
pos = 0
|
||||||
length = len(transfer_string)
|
length = len(transfer_string)
|
||||||
for index in range(1, total + 1):
|
for index in range(1, total + 1):
|
||||||
overhead = len(__frame_header(total, index, flags))
|
|
||||||
budget = chunk_size - overhead
|
|
||||||
end = min(pos + budget, length)
|
end = min(pos + budget, length)
|
||||||
frames.append(__build_frame(total, index, flags, transfer_string[pos:end]))
|
frames.append(
|
||||||
|
_FRAME_MAGIC_V2
|
||||||
|
+ _base36(total)
|
||||||
|
+ _base36(index)
|
||||||
|
+ flag
|
||||||
|
+ transfer_string[pos:end]
|
||||||
|
)
|
||||||
pos = end
|
pos = end
|
||||||
if pos >= length:
|
|
||||||
break
|
|
||||||
if pos < length:
|
if pos < length:
|
||||||
# __compute_total guarantees this cannot happen; keep a safety net.
|
# __compute_total guarantees this cannot happen; keep a safety net.
|
||||||
raise QrTransferError("internal error: frames did not cover the transfer string")
|
raise QrTransferError("internal error: frames did not cover the transfer string")
|
||||||
@@ -115,26 +161,16 @@ def split_frames(transfer_string, chunk_size, compressed=False):
|
|||||||
def parse_frame(frame):
|
def parse_frame(frame):
|
||||||
"""Parse a single frame.
|
"""Parse a single frame.
|
||||||
|
|
||||||
|
Accepts both the legacy ``BALQR1|total|index|flags|payload`` form and
|
||||||
|
the compact ``BAL1<total><index><flag><payload>`` v2 form.
|
||||||
|
|
||||||
Returns ``(total, index, compressed: bool, payload: str)``. Raises
|
Returns ``(total, index, compressed: bool, payload: str)``. Raises
|
||||||
:class:`QrTransferError` on malformed input (bad magic/version, wrong
|
:class:`QrTransferError` on malformed input (bad magic/version, wrong
|
||||||
arity, non-integer or out-of-range frame numbers, unknown flags).
|
arity, non-integer or out-of-range frame numbers, unknown flags).
|
||||||
"""
|
"""
|
||||||
parts = frame.split("|", maxsplit=4)
|
if frame.startswith(_FRAME_MAGIC_V2):
|
||||||
if len(parts) != 5:
|
return _parse_v2(frame)
|
||||||
raise QrTransferError("Not a BAL will QR (bad frame structure)")
|
return _parse_v1(frame)
|
||||||
magic_seen, total_s, index_s, flags, payload = parts
|
|
||||||
if magic_seen != _FRAME_MAGIC:
|
|
||||||
raise QrTransferError("Not a BAL will QR (unknown magic/version)")
|
|
||||||
try:
|
|
||||||
total = int(total_s)
|
|
||||||
index = int(index_s)
|
|
||||||
except ValueError as e:
|
|
||||||
raise QrTransferError("Not a BAL will QR (bad frame numbers)") from e
|
|
||||||
if total < 1 or not 1 <= index <= total:
|
|
||||||
raise QrTransferError("Not a BAL will QR (frame numbering out of range)")
|
|
||||||
if flags not in ("", FLAG_COMPRESSED):
|
|
||||||
raise QrTransferError("Not a BAL will QR (unknown flags)")
|
|
||||||
return total, index, flags == FLAG_COMPRESSED, payload
|
|
||||||
|
|
||||||
|
|
||||||
def assemble(frames, total):
|
def assemble(frames, total):
|
||||||
@@ -181,32 +217,88 @@ def __decompress(text, *, enabled):
|
|||||||
return zlib.decompress(base64.b64decode(text.encode("ascii"))).decode("utf-8")
|
return zlib.decompress(base64.b64decode(text.encode("ascii"))).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
def __frame_header(total, index, flags):
|
def _base36(n):
|
||||||
return "{}|{}|{}|{}|".format(_FRAME_MAGIC, total, index, flags)
|
"""Zero-padded :data:`_BASE36_WIDTH` base36 render of ``n``."""
|
||||||
|
if not 0 <= n <= _MAX_TOTAL:
|
||||||
|
raise QrTransferError("BAL QR part number out of range: {}".format(n))
|
||||||
|
chars = []
|
||||||
|
for _ in range(_BASE36_WIDTH):
|
||||||
|
chars.append(_BASE36_DIGITS[n % 36])
|
||||||
|
n //= 36
|
||||||
|
return "".join(reversed(chars))
|
||||||
|
|
||||||
|
|
||||||
def __build_frame(total, index, flags, payload):
|
def _base36_decode(text):
|
||||||
return __frame_header(total, index, flags) + payload
|
"""Inverse of :func:`_base36`; raises ``ValueError`` on bad input."""
|
||||||
|
if len(text) != _BASE36_WIDTH or any(c not in _BASE36_DIGITS for c in text):
|
||||||
|
raise ValueError(text)
|
||||||
|
n = 0
|
||||||
|
for c in text:
|
||||||
|
n = n * 36 + _BASE36_DIGITS.index(c)
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
def __compute_total(transfer_len, chunk_size, flags):
|
def _parse_v1(frame):
|
||||||
|
parts = frame.split("|", maxsplit=4)
|
||||||
|
if len(parts) != 5:
|
||||||
|
raise QrTransferError("Not a BAL will QR (bad frame structure)")
|
||||||
|
magic_seen, total_s, index_s, flags, payload = parts
|
||||||
|
if magic_seen != _FRAME_MAGIC_V1:
|
||||||
|
raise QrTransferError("Not a BAL will QR (unknown magic/version)")
|
||||||
|
try:
|
||||||
|
total = int(total_s)
|
||||||
|
index = int(index_s)
|
||||||
|
except ValueError as e:
|
||||||
|
raise QrTransferError("Not a BAL will QR (bad frame numbers)") from e
|
||||||
|
if total < 1 or not 1 <= index <= total:
|
||||||
|
raise QrTransferError("Not a BAL will QR (frame numbering out of range)")
|
||||||
|
if flags not in ("", FLAG_COMPRESSED):
|
||||||
|
raise QrTransferError("Not a BAL will QR (unknown flags)")
|
||||||
|
return total, index, flags == FLAG_COMPRESSED, payload
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_v2(frame):
|
||||||
|
if len(frame) < _HEADER_V2_LEN:
|
||||||
|
raise QrTransferError("Not a BAL will QR (bad frame structure)")
|
||||||
|
# Magic is length _FRAME_MAGIC_V2; the two base36 fields and the flag
|
||||||
|
# make up the rest of the fixed header.
|
||||||
|
offset = len(_FRAME_MAGIC_V2)
|
||||||
|
total_s = frame[offset : offset + _BASE36_WIDTH]
|
||||||
|
index_s = frame[offset + _BASE36_WIDTH : offset + 2 * _BASE36_WIDTH]
|
||||||
|
flag = frame[offset + 2 * _BASE36_WIDTH]
|
||||||
|
try:
|
||||||
|
total = _base36_decode(total_s)
|
||||||
|
index = _base36_decode(index_s)
|
||||||
|
except ValueError:
|
||||||
|
raise QrTransferError("Not a BAL will QR (bad frame numbers)") from None
|
||||||
|
if total < 1 or not 1 <= index <= total:
|
||||||
|
raise QrTransferError("Not a BAL will QR (frame numbering out of range)")
|
||||||
|
if flag not in (FLAG_PLAIN, FLAG_COMPRESSED):
|
||||||
|
raise QrTransferError("Not a BAL will QR (unknown flags)")
|
||||||
|
payload = frame[_HEADER_V2_LEN:]
|
||||||
|
return total, index, flag == FLAG_COMPRESSED, payload
|
||||||
|
|
||||||
|
|
||||||
|
def __compute_total(transfer_len, chunk_size):
|
||||||
"""Smallest frame count whose budget covers the whole transfer string.
|
"""Smallest frame count whose budget covers the whole transfer string.
|
||||||
|
|
||||||
The budget shrinks as ``total`` gains digits (wider header), so the count
|
The v2 header is fixed-width, so the budget is constant and the count is
|
||||||
is recomputed iteratively until it converges.
|
a plain ceiling division, capped at :data:`_MAX_TOTAL`.
|
||||||
"""
|
"""
|
||||||
if chunk_size < MIN_CHUNK_SIZE:
|
if chunk_size < MIN_CHUNK_SIZE:
|
||||||
raise QrTransferError(
|
raise QrTransferError(
|
||||||
"chunk size too small to hold a BAL QR frame: {}".format(chunk_size)
|
"chunk size too small to hold a BAL QR frame: {}".format(chunk_size)
|
||||||
)
|
)
|
||||||
total = 1
|
budget = chunk_size - _HEADER_V2_LEN
|
||||||
while True:
|
if budget <= 0:
|
||||||
overhead = len(__frame_header(total, total, flags))
|
raise QrTransferError(
|
||||||
budget = chunk_size - overhead
|
"chunk size too small for the BAL QR frame header: {}".format(chunk_size)
|
||||||
if budget <= 0:
|
)
|
||||||
raise QrTransferError(
|
total = -(-transfer_len // budget)
|
||||||
"chunk size too small for the BAL QR frame header: {}".format(chunk_size)
|
if total < 1:
|
||||||
)
|
total = 1
|
||||||
if transfer_len <= budget * total:
|
if total > _MAX_TOTAL:
|
||||||
return total
|
raise QrTransferError(
|
||||||
total += 1
|
"BAL QR transfer demands too many frames: {}".format(total)
|
||||||
|
)
|
||||||
|
return total
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ from ...core.qrtransfer import (
|
|||||||
QrTransferError,
|
QrTransferError,
|
||||||
decode_transfer,
|
decode_transfer,
|
||||||
encode_transfer,
|
encode_transfer,
|
||||||
|
encode_transfer_best,
|
||||||
preset_index_for_chunk_size,
|
preset_index_for_chunk_size,
|
||||||
split_frames,
|
split_frames,
|
||||||
)
|
)
|
||||||
@@ -2799,6 +2800,12 @@ class BalQrExportWidget(QWidget):
|
|||||||
self.tx_strings = list(tx_strings)
|
self.tx_strings = list(tx_strings)
|
||||||
self._stop_auto()
|
self._stop_auto()
|
||||||
self.transfer = encode_transfer(self.tx_strings, compress=False)
|
self.transfer = encode_transfer(self.tx_strings, compress=False)
|
||||||
|
# BAL QR now ships compact (best-of) compressed by default: smaller
|
||||||
|
# frames, and the importer reverses it via the per-frame flag. The
|
||||||
|
# other formats keep the raw transfer (they compress internally).
|
||||||
|
self._balqr_transfer, self._balqr_compressed = encode_transfer_best(
|
||||||
|
self.tx_strings
|
||||||
|
)
|
||||||
self._refresh_frames()
|
self._refresh_frames()
|
||||||
self._update_intro()
|
self._update_intro()
|
||||||
self._render()
|
self._render()
|
||||||
@@ -2810,7 +2817,9 @@ class BalQrExportWidget(QWidget):
|
|||||||
def _refresh_frames(self):
|
def _refresh_frames(self):
|
||||||
if self.format == "balqr":
|
if self.format == "balqr":
|
||||||
self.frames = split_frames(
|
self.frames = split_frames(
|
||||||
self.transfer, self.chunk_size, compressed=False
|
self._balqr_transfer,
|
||||||
|
self.chunk_size,
|
||||||
|
compressed=self._balqr_compressed,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.frames = encode_animated_frames(
|
self.frames = encode_animated_frames(
|
||||||
|
|||||||
@@ -426,6 +426,7 @@ def test_bbqr_part_number_limits():
|
|||||||
|
|
||||||
def test_detect_format_recognises_all_formats():
|
def test_detect_format_recognises_all_formats():
|
||||||
assert aq.detect_format("BALQR1|1|1||payload") == "balqr"
|
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.ur1_frames(b"x", 400)[0]) == "ur1"
|
||||||
assert aq.detect_format(aq.ur2_frames(b"x", 400)[0]) == "ur2"
|
assert aq.detect_format(aq.ur2_frames(b"x", 400)[0]) == "ur2"
|
||||||
assert aq.detect_format(aq.bbqr_frames(b"x", 50)[0]) == "bbqr"
|
assert aq.detect_format(aq.bbqr_frames(b"x", 50)[0]) == "bbqr"
|
||||||
@@ -444,6 +445,9 @@ def test_detect_format_rejects_garbage():
|
|||||||
def test_parse_for_detection_keys():
|
def test_parse_for_detection_keys():
|
||||||
bal = aq.parse_for_detection("BALQR1|3|2||payload")
|
bal = aq.parse_for_detection("BALQR1|3|2||payload")
|
||||||
assert bal == ("balqr", "balqr:3", 3, 2)
|
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])
|
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
|
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])
|
v1 = aq.parse_for_detection(aq.ur1_frames(b"x"*50, 120)[0])
|
||||||
|
|||||||
@@ -100,9 +100,9 @@ def test_size_greater_than_payload():
|
|||||||
|
|
||||||
|
|
||||||
def test_exact_single_frame_boundary():
|
def test_exact_single_frame_boundary():
|
||||||
# A 138-byte payload exactly fills the 150-byte preset budget (the 12-char
|
# A 139-byte payload exactly fills the 150-byte preset budget (the 11-char
|
||||||
# empty-flags header plus payload), so the encoded frame is exactly 150.
|
# compact header plus payload), so the encoded frame is exactly 150.
|
||||||
tx_strings = ["a" * 138]
|
tx_strings = ["a" * 139]
|
||||||
payload = encode_transfer(tx_strings)
|
payload = encode_transfer(tx_strings)
|
||||||
frames = split_frames(payload, 150)
|
frames = split_frames(payload, 150)
|
||||||
assert len(frames) == 1
|
assert len(frames) == 1
|
||||||
@@ -157,6 +157,119 @@ def test_compressed_roundtrip_through_frames():
|
|||||||
assert decoded == tx_strings
|
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
|
# Malformed input
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -107,10 +107,12 @@ class StubWillItem:
|
|||||||
return {"tx": str(self.tx), "status": self.statuses}
|
return {"tx": str(self.tx), "status": self.statuses}
|
||||||
|
|
||||||
|
|
||||||
def _make_willitems(n=3, payload_len=120):
|
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)]
|
||||||
return {
|
return {
|
||||||
"item{}".format(i): StubWillItem("T{}".format(i) * payload_len)
|
"item{}".format(i): StubWillItem(p)
|
||||||
for i in range(n)
|
for i, p in enumerate(payloads)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -288,8 +290,17 @@ def test_export_filter_empty_reverts():
|
|||||||
|
|
||||||
|
|
||||||
def test_export_navigation_and_chunk_change():
|
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))
|
||||||
|
|
||||||
bw = FakeBalWindow()
|
bw = FakeBalWindow()
|
||||||
bw.willitems = _make_willitems(n=6, payload_len=400)
|
bw.willitems = _make_willitems(
|
||||||
|
n=6, payload_len=400,
|
||||||
|
payloads=["{}0{}".format(noisy(i), "T" * 50) for i in range(6)],
|
||||||
|
)
|
||||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||||
page = d.qr_page
|
page = d.qr_page
|
||||||
first_count = len(page.frames)
|
first_count = len(page.frames)
|
||||||
@@ -568,7 +579,7 @@ def test_export_format_combo_switches_codecs():
|
|||||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||||
page = d.qr_page
|
page = d.qr_page
|
||||||
assert page.format == "balqr"
|
assert page.format == "balqr"
|
||||||
assert page.frames[0].startswith("BALQR1|")
|
assert page.frames[0].startswith("BAL1")
|
||||||
assert page.format_combo.count() == 4
|
assert page.format_combo.count() == 4
|
||||||
|
|
||||||
page._on_format_change(1) # BC-UR v1
|
page._on_format_change(1) # BC-UR v1
|
||||||
|
|||||||
Reference in New Issue
Block a user