diff --git a/docs/developer/gui.md b/docs/developer/gui.md index 06cc7f26..b0bbef53 100644 --- a/docs/developer/gui.md +++ b/docs/developer/gui.md @@ -20,7 +20,8 @@ cryptography, entropy source, socket, or file storage. Review `reading.py`, `wallet_setup.py`, and `work.py` first. Their behavior is covered without a display. `tools/gui_walkthrough.py` exercises the real GTK screens under Xvfb. `tests/test_gui_boundaries.py` enforces a separate 2,250 -logical-line GUI budget. +logical-line GUI budget, including the Bails restore-identity checks and fixed +read-back boxes. The library retains its existing budget. ## Security boundaries @@ -54,6 +55,17 @@ The entry widget has three internal modes: - **Read-back:** starts empty, normalizes only spacing and ASCII case, and never reveals expected text after a mismatch. +Read-back uses `codex32._confirmation._entered_groups`, shared with the CLI. +After a mismatch, matching groups lock; only the entered text is shown. Arrows +cross editable-group boundaries, Ctrl+arrows skip groups, Tab cycles, and +Home/End reach the first/last editable group. Paste and drop are disabled. +Invalid characters and missing groups are compared directly. Input over twice +the card's length marks every group wrong without aligning it. While typing, +each correct group locks once the next group is typed, and the field turns red +when its first group still mismatches a further group later. +`tests/test_gui_read_back.py` exercises these behaviors under GTK. Review +`entry.py` for keyboard and clipboard handling. + Enter activates the enabled primary action. Card displays use four four-character groups per row, matching `docs/user/recovery-card.html`. Ordinary repair suggestions are unmarked; explicit correction may mark changed diff --git a/docs/user/gui.md b/docs/user/gui.md index c349ede5..7a766334 100644 --- a/docs/user/gui.md +++ b/docs/user/gui.md @@ -63,8 +63,11 @@ The recommended layout is three cards where any two recover the wallet. One card can be lost, and one card alone reveals nothing. Copy each card to paper, hide the on-screen original, then type the paper copy -back. Read-back starts completely empty, including `MS1`. A mismatch highlights -only the groups you typed differently; the expected text stays hidden. +back. Read-back starts empty, including `MS1`, and does not accept paste. Correct +groups lock as you type, and the box turns red when its first group is wrong. +After a mismatch, correct the highlighted boxes from your paper card; matching +groups are locked. Arrows, Ctrl+arrows, Tab, Home and End move between editable boxes. +The expected text stays hidden. Press Enter to confirm. After all cards are confirmed, write the displayed master fingerprint on your wallet record, then re-enter it in the GUI. Wallet selection stays unavailable diff --git a/src/codex32_gui/entry.py b/src/codex32_gui/entry.py index 5285eaa5..20ed2398 100644 --- a/src/codex32_gui/entry.py +++ b/src/codex32_gui/entry.py @@ -3,9 +3,9 @@ from __future__ import annotations from dataclasses import replace -from typing import Literal, cast +from typing import Literal -from gi.repository import GLib, Gtk +from gi.repository import Gdk, GLib, Gtk from codex32.profiles.ms32 import TEXT_LENGTHS from codex32_gui.reading import ( @@ -19,7 +19,6 @@ normalize, normalize_readback, read, - readback, ) EntryMode = Literal["import", "correct", "readback"] @@ -40,8 +39,6 @@ def __init__( allow_header_erasures: bool = False, ) -> None: super().__init__() - if mode == "readback" and length is None: - raise ValueError("read-back entry requires the card length") self._accepted, self._length, self._mode = accepted, length, mode self._expected_header, self._allow_header_erasures, self._pending = ( expected_header, @@ -60,6 +57,8 @@ def __init__( self.connect("changed", self._schedule) self.connect("notify::selection-bound", self._unpublish) self.connect("notify::cursor-position", self._unpublish) + if mode == "readback": + refuse_paste(self) GLib.idle_add(self.set_position, -1) def prefill(self, text: str) -> None: @@ -72,8 +71,6 @@ def prefill(self, text: str) -> None: def reading(self) -> Reading: """Return the current reading of this field.""" - if self._mode == "readback": - return readback(self.get_text(), cast(int, self._length)) state = read(normalize(self.get_text()), accepted=self._accepted, length=self._length) if fault := self._header_fault(): state = replace(state, artifact=None, message=fault, level="error") @@ -89,8 +86,6 @@ def clear(self) -> None: self.set_position(-1) def _header_fault(self, raw: str | None = None) -> str: - if self._mode == "readback": - return "" value = self.get_text() if raw is None else raw compact = "".join(value.split()).upper() text = compact if compact.startswith(PREFIX) else normalize(value) @@ -132,7 +127,7 @@ def _reformat(self) -> bool: return False duplicated_prefix = self._mode == "import" and compact.startswith(PREFIX + PREFIX) source = compact[len(PREFIX) :] if duplicated_prefix else raw - canonical = normalizer(source)[: self._limit] + canonical = normalizer(source)[: None if self._mode == "readback" else self._limit] shown = grouped(canonical) self._dropped = "" if self._mode == "readback" else lookalike_fault(raw) fault = self._header_fault(canonical) if self._mode == "import" else "" @@ -162,3 +157,53 @@ def _rewrite(self, shown: str, position: int) -> None: self.set_text(shown) self._rewriting = False self.set_position(position) + + +class GroupEntry(Codex32Entry): + """Only the operator's transcription of one mismatched group.""" + + def __init__(self, text: str) -> None: + super().__init__(length=GROUP, mode="readback") + self.set_properties(width_chars=5, max_width_chars=5, hexpand=False, text=text) + + +def refuse_paste(entry: Gtk.Entry) -> None: + """Require read-back from paper, including in correction boxes.""" + text = entry.get_delegate() + text.connect("paste-clipboard", lambda text: text.stop_emission_by_name("paste-clipboard")) + middle = Gtk.GestureClick(button=2, propagation_phase=Gtk.PropagationPhase.CAPTURE) + middle.connect("pressed", lambda gesture, *_: gesture.set_state(Gtk.EventSequenceState.CLAIMED)) + entry.add_controller(middle) + controllers = text.observe_controllers() + for controller in [controllers.get_item(i) for i in range(controllers.get_n_items())]: + if isinstance(controller, Gtk.DropTarget): + text.remove_controller(controller) + + +def navigate_groups(entries: list[GroupEntry]) -> None: + """Navigate editable groups while leaving character movement to GTK.""" + + def key(controller: Gtk.EventControllerKey, value: int, _code: int, state: Gdk.ModifierType) -> bool: + entry = controller.get_widget() + current, back = entries.index(entry), value in (Gdk.KEY_Left, Gdk.KEY_KP_Left, Gdk.KEY_ISO_Left_Tab) + if value in (Gdk.KEY_Tab, Gdk.KEY_ISO_Left_Tab): + target = (current + (-1 if back or state & Gdk.ModifierType.SHIFT_MASK else 1)) % len(entries) + elif value in (Gdk.KEY_Home, Gdk.KEY_End): + target = 0 if value == Gdk.KEY_Home else len(entries) - 1 + elif value in (Gdk.KEY_Left, Gdk.KEY_Right, Gdk.KEY_KP_Left, Gdk.KEY_KP_Right) and ( + state & Gdk.ModifierType.CONTROL_MASK + or entry.get_position() == (0 if back else len(entry.get_text())) + ): + target = current + (-1 if back else 1) + if not 0 <= target < len(entries): + return False + else: + return False + entries[target].grab_focus() + entries[target].set_position(-1 if back or value == Gdk.KEY_End else 0) + return True + + for entry in entries: + controller = Gtk.EventControllerKey(propagation_phase=Gtk.PropagationPhase.CAPTURE) + controller.connect("key-pressed", key) + entry.add_controller(controller) diff --git a/src/codex32_gui/pages.py b/src/codex32_gui/pages.py index ab4d9159..3b3eab8a 100644 --- a/src/codex32_gui/pages.py +++ b/src/codex32_gui/pages.py @@ -26,10 +26,11 @@ generate_master_seed, recover_secret, ) +from codex32._confirmation import _entered_groups from codex32.errors import CodexError from codex32.generation import ORDINARY_INDICES from codex32_gui import ARTWORK, reading, wallet_setup, work -from codex32_gui.entry import Codex32Entry, EntryMode +from codex32_gui.entry import Codex32Entry, EntryMode, GroupEntry, navigate_groups from codex32_gui.wallet_setup import BitcoinCore Artifact = Share | Secret @@ -326,19 +327,6 @@ def _describe(artifact: Artifact) -> str: ) -def _compare(expected: str, entered: str) -> ConfirmationResult: - """Report which four-character groups do not match, by the library's own rule.""" - observed, wanted = "".join(entered.split()).lower(), expected.lower() - span = (max(len(observed), len(wanted)) + reading.GROUP - 1) // reading.GROUP - mismatched = tuple( - group + 1 - for group in range(span) - if observed[group * reading.GROUP : (group + 1) * reading.GROUP] - != wanted[group * reading.GROUP : (group + 1) * reading.GROUP] - ) - return ConfirmationResult(not mismatched, mismatched) - - def _guessed(observed: str, corrected: str) -> frozenset[int]: """Mark the four-character windows a correction changed, so they can be compared.""" changed: set[int] = set() @@ -504,67 +492,109 @@ def _read_back_page( confirm: Callable[[str], ConfirmationResult], after: Callable[[], None], ) -> Adw.NavigationPage: - """Read the card back from the paper, with the original off the screen.""" + """Read from paper with the CLI's alignment; `confirm` sees only an exact match.""" field = Codex32Entry(length=len(card.text), mode="readback") - status = _note("") - comparison = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - accept = _button("Confirm card", lambda: None, style="suggested-action") - - def clear_comparison() -> None: - while (child := comparison.get_first_child()) is not None: - comparison.remove(child) + boxes: list[Gtk.Label | GroupEntry] = [] + status, comparison, wanted = _note(""), Adw.Bin(), card.text.upper() + accept = _button("Confirm card", lambda: check(), style="suggested-action") + + def clear_boxes() -> None: + previous = boxes.copy() + boxes.clear() + comparison.set_child(None) + field.set_visible(True) + for box in previous: + box.clear() if isinstance(box, GroupEntry) else box.set_text("") + + def place(box: Gtk.Label | GroupEntry) -> None: + if not isinstance(grid := comparison.get_child(), Gtk.Grid): + comparison.set_child(grid := Gtk.Grid(column_spacing=8, row_spacing=8, halign=Gtk.Align.CENTER)) + box.add_css_class("card-group") + grid.attach(box, len(boxes) % 4, len(boxes) // 4, 1, 1) + boxes.append(box) + + def entered() -> str: + return "".join(box.get_text() for box in boxes) + (field.get_text() if field.get_visible() else "") def update(*_arguments: object) -> None: - clear_comparison() - state = field.reading() - accept.set_sensitive(state.complete) - _say(status, state.message, state.level) + if field._rewriting: + return + if not field.get_text() and not field.get_visible(): + clear_boxes() + text = reading.normalize_readback(entered()) + accept.set_sensitive(bool(text)) + _say(status, f"{len(text)} of {len(card.text)} characters") + if field.get_visible(): + GLib.idle_add(lock) + + def lock() -> bool: + # Lock each correct group once the next is typed; flag a wrong one a group later. + text, done = reading.normalize_readback(field.get_text()), 4 * len(boxes) + taken = 0 + while len(text) >= taken + 8 and text[taken : taken + 4] == wanted[done + taken : done + taken + 4]: + place(Gtk.Label(label=text[taken : taken + 4])) + taken += 4 + if taken: + field._rewrite(reading.grouped(text[taken:]), max(field.get_position() - taken * 5 // 4, 0)) + wrong = len(text) >= taken + 12 and text[taken : taken + 4] != wanted[done + taken : done + taken + 4] + (field.add_css_class if wrong else field.remove_css_class)("error") + if wrong: + _say(status, "The first group in the box does not match your card.", "error") + return False def check() -> None: - try: - state = field.reading() - result = confirm(state.text) - except CodexError as error: - _failure(view, error, CARDS_SAFE) - return - if not result.accepted: - groups = frozenset(group - 1 for group in result.mismatched_groups) - clear_comparison() - comparison.append(_card(state.text, groups, highlight_class="mismatch")) - plural = "s" if len(groups) != 1 else "" - message = f"Highlighted group{plural} {'do' if plural else 'does'} not match. Re-read the card." - _say(status, message, "error") - return + text = reading.normalize_readback(entered()) + if text == wanted: + try: + result = confirm(text) + except CodexError as error: + _failure(view, error, CARDS_SAFE) + return + if result.accepted: + clear_page() + after() + return + if field.get_visible(): + pieces, changed = _entered_groups(text, card.text) + else: + pieces = [reading.normalize_readback(box.get_text()) for box in boxes] + changed = { + index for index, piece in enumerate(pieces) if piece != wanted[index * 4 : index * 4 + 4] + } + clear_boxes() + for index, piece in enumerate(pieces): + piece = reading.normalize_readback(piece) + box = GroupEntry(piece) if index in changed else Gtk.Label(label=piece) + if isinstance(box, GroupEntry): + box.add_css_class("mismatch") + box.connect("changed", update) + box.connect("activate", lambda _box: check() if accept.get_sensitive() else None) + place(box) + editable = [box for box in boxes if isinstance(box, GroupEntry)] + navigate_groups(editable) + field.set_visible(False) + if editable: + editable[0].grab_focus() + _say(status, "Re-read the highlighted groups from your card.", "error") + + def clear_page() -> None: field.clear() - after() + clear_boxes() - accept.connect("clicked", lambda _button: check()) field.connect("activate", lambda _entry: check() if accept.get_sensitive() else None) field.connect("changed", update) - content = _column( - _title("Now type it back from the card", _counted(card.header.index.upper(), position, count)), - _note( - "The original is no longer on screen. Read from the card you just wrote, so a slip of the " - "pen is caught now rather than years from now." - ), - field, - status, - comparison, - _note( - "Spaces and capitals do not matter. After a mismatch, only what you typed is shown; the " - "original stays hidden. Re-read the highlighted groups from your card and try again." - ), - ) page = _page( _counted(card.header.index.upper(), position, count), - content, + _column( + _title("Now type it back from the card", _counted(card.header.index.upper(), position, count)), + _note("Read every character from your paper card, including MS1. Pasting is turned off."), + comparison, + field, + status, + _note("Correct groups lock as you type. Use arrows, Ctrl+arrows, Tab, Home or End to move."), + ), actions=_actions(_button("Show the card again", view.pop), accept), ) - - def clear_page() -> None: - field.clear() - clear_comparison() - _forget_when_gone(view, page, clear_page) update() return page @@ -687,7 +717,7 @@ def _unshared_page(view: Adw.NavigationView, core: BitcoinCore, secret: MasterSe card=secret, position=0, count=1, - confirm=lambda text: _compare(secret.text, text), + confirm=lambda _text: ConfirmationResult(True, ()), after=lambda: _identity(view, core, secret), cancel=lambda page: _abandon(view, page), ) @@ -1618,7 +1648,7 @@ def _derive(view: Adw.NavigationView, found: tuple[Artifact, ...], letter: str) card=share, position=0, count=None, - confirm=lambda text: _compare(share.text, text), + confirm=lambda _text: ConfirmationResult(True, ()), after=lambda: _replace(view, _share_done_page(view, share)), cancel=lambda _page: view.replace([home(view)]), ), diff --git a/src/codex32_gui/reading.py b/src/codex32_gui/reading.py index 6a8503c8..0aeca5e1 100644 --- a/src/codex32_gui/reading.py +++ b/src/codex32_gui/reading.py @@ -149,16 +149,6 @@ def read(text: str, *, accepted: tuple[str, ...] = (), length: int | None = None return Reading(artifact.text, artifact, "Every character checks out.", "success", expected, 0, False) -def readback(raw: str, length: int) -> Reading: - """Describe an independently re-entered card without interpreting or repairing it.""" - text = normalize_readback(raw) - unreadable = text.count("?") - fault = lookalike_fault(text) - level = "error" if fault else "" - message = fault or f"{len(text)} of {length} characters" - return Reading(text, None, message, level, length, unreadable, False) - - def repair(text: str, length: int | None, excluded: tuple[str, ...] = ()) -> CorrectionCandidate | str: """Ask the library for one repair, and offer nothing at all when it is unsure. diff --git a/tests/test_gui_read_back.py b/tests/test_gui_read_back.py new file mode 100644 index 00000000..286d6b80 --- /dev/null +++ b/tests/test_gui_read_back.py @@ -0,0 +1,278 @@ +"""The card read-back page: Enter submits, and a mismatch opens only the wrong groups.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +gi = pytest.importorskip("gi") +try: + gi.require_version("Gtk", "4.0") + gi.require_version("Adw", "1") + gi.require_version("Gdk", "4.0") + from gi.repository import Adw, Gdk, GLib, Gtk +except (ImportError, ValueError): + pytest.skip("GTK 4 and libadwaita are unavailable", allow_module_level=True) +if not Gtk.init_check() or Gdk.Display.get_default() is None: + pytest.skip("no display for GTK", allow_module_level=True) +Adw.init() + +from data.bip93_vectors import VECTOR_2 + +from codex32 import parse_codex32 +from codex32.generation import ConfirmationResult +from codex32_gui import pages, reading +from codex32_gui.entry import GroupEntry + +SHARE = VECTOR_2["share_A"] + + +def _widgets(root: Gtk.Widget, kind: type) -> Iterator[Gtk.Widget]: + child = root.get_first_child() + while child is not None: + if isinstance(child, kind): + yield child + yield from _widgets(child, kind) + child = child.get_next_sibling() + + +def _read_back( + result: ConfirmationResult | None = None, +) -> tuple[Gtk.Widget, pages.Codex32Entry, list[str], list[bool]]: + typed: list[str] = [] + finished: list[bool] = [] + + def confirm(text: str) -> ConfirmationResult: + typed.append(text) + return result or ConfirmationResult(text == SHARE.upper(), ()) + + view = Adw.NavigationView() + page = pages._read_back_page(view, parse_codex32(SHARE), 1, 3, confirm, lambda: finished.append(True)) + view.push(page) + return page, next(_widgets(page, pages.Codex32Entry)), typed, finished + + +def _settle() -> None: + while GLib.MainContext.default().iteration(False): + pass + + +def _boxes(page: Gtk.Widget) -> list[Gtk.Widget]: + [grid] = list(_widgets(page, Gtk.Grid)) + return [box for box in _widgets(grid, Gtk.Widget) if isinstance(box, Gtk.Label | GroupEntry)] + + +def _submit(text: str) -> tuple[Gtk.Widget, pages.Codex32Entry, list[Gtk.Widget], list[bool]]: + page, field, _typed, finished = _read_back() + field.set_text(text) + _settle() + field.emit("activate") + return page, field, _boxes(page), finished + + +def _groups(text: str) -> list[str]: + return [text[start : start + reading.GROUP] for start in range(0, len(text), reading.GROUP)] + + +CARD = _groups(SHARE.upper()) + + +def _press(box: Gtk.Widget, key: int, modifiers: int = 0) -> bool: + controllers = box.observe_controllers() + found = (controllers.get_item(i) for i in range(controllers.get_n_items())) + controller = next(item for item in found if isinstance(item, Gtk.EventControllerKey)) + return bool(controller.emit("key-pressed", key, 0, Gdk.ModifierType(modifiers))) + + +def test_readback_starts_empty_and_enter_confirms_the_whole_card() -> None: + _page, field, typed, finished = _read_back(ConfirmationResult(True)) + + assert field.get_text() == "" + field.set_text(SHARE) + field.emit("activate") + assert typed == [SHARE.upper()] and finished == [True] + + +def test_invalid_characters_are_compared_and_kept_in_their_group() -> None: + wrong = CARD.copy() + wrong[7] = "B1!O" + _page, _field, boxes, finished = _submit("".join(wrong)) + assert not finished + assert isinstance(boxes[7], GroupEntry) + assert boxes[7].get_text() == "B1!O" + assert [index for index, box in enumerate(boxes) if isinstance(box, GroupEntry)] == [7] + + +def test_a_mismatch_locks_the_right_groups_and_opens_only_the_wrong_ones() -> None: + wrong = CARD.copy() + wrong[1], wrong[11] = "QQQQ", "PPPP" + _page, field, boxes, finished = _submit("".join(wrong)) + + assert finished == [] and not field.get_visible() + assert [box.get_text() for box in boxes] == wrong + assert [i for i, box in enumerate(boxes) if isinstance(box, GroupEntry)] == [1, 11] + + +def test_a_missing_and_an_extra_character_open_only_their_own_groups() -> None: + slipped = CARD.copy() + slipped[2], slipped[6] = CARD[2][1:], CARD[6][:2] + "Q" + CARD[6][2:] + _page, _field, boxes, _finished = _submit("".join(slipped)) + + assert [i for i, box in enumerate(boxes) if isinstance(box, GroupEntry)] == [2, 6] + assert [box.get_text() for box in boxes] == slipped + + +def test_correcting_the_open_boxes_confirms_the_card() -> None: + wrong = CARD.copy() + wrong[3] = "QQQQ" + page, field, boxes, finished = _submit("".join(wrong)) + + boxes[3].set_text(CARD[3].lower()) + _settle() + boxes[3].emit("activate") + assert finished == [True] and field.get_text() == "" + assert list(_widgets(page, Gtk.Grid)) == [] and field.get_visible() + + +def test_the_read_back_refuses_pasted_and_dropped_text() -> None: + wrong = CARD.copy() + wrong[5] = "QQQQ" + _page, field, boxes, _finished = _submit("".join(wrong)) + field.get_clipboard().set("PPPP") + _settle() + + for entry in (field, boxes[5]): + before, text = entry.get_text(), entry.get_delegate() + text.emit("paste-clipboard") + _settle() + assert entry.get_text() == before + controllers = text.observe_controllers() + assert not any( + isinstance(controllers.get_item(i), Gtk.DropTarget) for i in range(controllers.get_n_items()) + ) + + +def test_clearing_puts_the_single_field_back() -> None: + wrong = CARD.copy() + wrong[0] = "MS1Q" + page, field, boxes, _finished = _submit("".join(wrong)) + field.clear() + assert all(box.get_text() == "" for box in boxes) + assert list(_widgets(page, Gtk.Grid)) == [] and field.get_visible() + field.set_text(SHARE[:7]) + _settle() + assert reading.normalize_readback(field.get_text()) == SHARE[:7].upper() + + +def test_a_retry_keeps_every_box_where_it_is() -> None: + wrong = CARD.copy() + wrong[0], wrong[2] = "MS1Q", "QQQQ" + page, _field, boxes, _finished = _submit("".join(wrong)) + + boxes[0].set_text("MS12N") # One character too many, next to the locked group + boxes[0].emit("activate") + boxes = _boxes(page) + assert [i for i, box in enumerate(boxes) if isinstance(box, GroupEntry)] == [0, 2] + assert [box.get_text() for box in boxes[:2]] == ["MS12N", CARD[1]] + + +def test_a_retry_ignores_spaces_typed_into_a_box() -> None: + wrong = CARD.copy() + wrong[3], wrong[8] = "QQQQ", "PPPP" + page, _field, boxes, _finished = _submit("".join(wrong)) + + boxes[3].set_text(CARD[3][:2] + " " + CARD[3][2:]) + boxes[3].emit("activate") + assert [i for i, box in enumerate(_boxes(page)) if isinstance(box, GroupEntry)] == [8] + + +@pytest.mark.parametrize( + ("key", "modifiers", "position", "target", "cursor"), + [ + (Gdk.KEY_Right, 0, 4, 9, 0), + (Gdk.KEY_Left, 0, 0, 2, 4), + (Gdk.KEY_Right, Gdk.ModifierType.CONTROL_MASK, 1, 9, 0), + (Gdk.KEY_Left, Gdk.ModifierType.CONTROL_MASK, 1, 2, 4), + (Gdk.KEY_Home, 0, 2, 2, 0), + (Gdk.KEY_End, 0, 2, 9, 4), + (Gdk.KEY_Tab, 0, 2, 9, 0), + (Gdk.KEY_ISO_Left_Tab, Gdk.ModifierType.SHIFT_MASK, 2, 2, 4), + ], +) +def test_keyboard_navigation_skips_locked_groups( + key: int, modifiers: int, position: int, target: int, cursor: int +) -> None: + wrong = CARD.copy() + wrong[2], wrong[9] = "QQQQ", "PPPP" + page, _field, boxes, _finished = _submit("".join(wrong)) + window = Gtk.Window(child=page.get_parent()) + window.present() + _settle() + start = 9 if key in (Gdk.KEY_Left, Gdk.KEY_ISO_Left_Tab, Gdk.KEY_Home) else 2 + boxes[start].grab_focus() + boxes[start].set_position(position) + assert _press(boxes[start], key, modifiers) + assert window.get_focus().get_parent() is boxes[target] + assert boxes[target].get_position() == cursor + window.destroy() + + +def test_more_than_eight_missing_characters_can_be_compared() -> None: + _page, _field, boxes, finished = _submit(SHARE[:-12]) + assert not finished + assert len(boxes) == len(CARD) + assert any(isinstance(box, GroupEntry) and box.get_text() == "" for box in boxes) + + +def test_input_over_twice_the_card_opens_every_group_without_aligning() -> None: + _page, _field, boxes, finished = _submit("Q" * (2 * len(SHARE) + 1)) + assert not finished and all(isinstance(box, GroupEntry) for box in boxes) + + +def test_correct_groups_lock_while_typing_and_a_wrong_one_turns_red() -> None: + page, field, _typed, _finished = _read_back() + field.set_text("".join(CARD[:3])) + _settle() + assert [box.get_text() for box in _boxes(page)] == CARD[:2] + assert reading.normalize_readback(field.get_text()) == CARD[2] + field.set_text(CARD[2] + "QQQQ" + "".join(CARD[4:6])) + _settle() + assert len(_boxes(page)) == 3 and field.has_css_class("error") + field.set_text(CARD[3]) + _settle() + assert not field.has_css_class("error") + field.set_text("".join(CARD[3:])) + _settle() + field.emit("activate") + assert _finished == [True] + + +def test_selecting_a_correction_box_releases_the_primary_selection() -> None: + wrong = CARD.copy() + wrong[3] = "QQQQ" + page, _field, boxes, _finished = _submit("".join(wrong)) + window = Gtk.Window(child=page.get_parent()) + window.present() + _settle() + boxes[3].grab_focus() + boxes[3].select_region(0, -1) + _settle() + assert window.get_display().get_primary_clipboard().get_content() is None + window.destroy() + + +@pytest.mark.parametrize(("key", "cursor"), [(Gdk.KEY_Left, 0), (Gdk.KEY_Right, 4)]) +def test_arrows_do_not_wrap_when_no_other_group_is_editable(key: int, cursor: int) -> None: + wrong = CARD.copy() + wrong[3] = "QQQQ" + page, _field, boxes, _finished = _submit("".join(wrong)) + window = Gtk.Window(child=page.get_parent()) + window.present() + _settle() + box = boxes[3] + box.grab_focus() + box.set_position(cursor) + assert not _press(box, key) + assert box.get_position() == cursor + window.destroy() diff --git a/tools/gui_walkthrough.py b/tools/gui_walkthrough.py index e4ac8cc8..f4ebf6e5 100644 --- a/tools/gui_walkthrough.py +++ b/tools/gui_walkthrough.py @@ -25,6 +25,7 @@ from codex32 import MasterSeed, parse_codex32 from codex32_gui import app, pages, reading, wallet_setup +from codex32_gui.entry import GroupEntry SHARE_A = "MS12NAMEA320ZYXWVUTSRQPNMLKJHGFEDCAXRPP870HKKQRM" SHARE_C = "MS12NAMECACDEFGHJKLMNPQRSTUVWXYZ023FTR2GDZMPY6PN" @@ -66,7 +67,11 @@ def walk(widget: Any) -> Iterator[Any]: def card(page: Any) -> str: - return "".join(item.get_label() for item in walk(page) if "card-group" in item.get_css_classes()) + return "".join( + item.get_text() if isinstance(item, GroupEntry) else item.get_label() + for item in walk(page) + if "card-group" in item.get_css_classes() + ) def button(page: Any, label: str) -> Any: @@ -699,11 +704,12 @@ def read_back(self) -> bool: field.select_region(0, -1) settle() check("selecting a card does not publish it", _primary_is_empty(), _primary()) - field.set_text(self.made[:-4] + "QQQQ") + check("correct groups lock as they are typed", field.get_text() == self.made[-4:], field.get_text()) + field.set_text("QQQQ") settle() press(page, "Confirm card") mismatches = [ - item.get_label() + item.get_text() if isinstance(item, GroupEntry) else item.get_label() for item in walk(page) if "card-group" in item.get_css_classes() and "mismatch" in item.get_css_classes() ] @@ -717,9 +723,10 @@ def read_back(self) -> bool: card(page) == self.made[:-4] + "QQQQ", card(page), ) - field.set_text(self.made.lower()) + editable = next(item for item in walk(page) if isinstance(item, GroupEntry)) + editable.set_text(self.made[-4:].lower()) settle() - field.emit("activate") + editable.emit("activate") return True def new_card_done(self) -> bool: