diff --git a/docs/developer/api.md b/docs/developer/api.md index 6c7c8011..1b829ef7 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -113,7 +113,7 @@ documentation and enforcement update. installed as `codex32[gui]` and started by `codex32-gui`. It is a client of the surface above and of the private Core adapter; nothing in `src/codex32/` imports it, and the base install keeps its property of having no third-party runtime -dependency. It carries its own budget of 1,800 logical review lines, separate +dependency. It carries its own budget of 2,050 logical review lines, separate from the 5,000 above. Its own boundaries are documented in [`gui.md`](gui.md) and enforced by `tests/test_gui_boundaries.py`. diff --git a/docs/developer/gui.md b/docs/developer/gui.md index c922ce7a..38e13279 100644 --- a/docs/developer/gui.md +++ b/docs/developer/gui.md @@ -12,28 +12,34 @@ Design and screen mock-ups: `docs/planning/gui-plan.md` and | File | Logical lines | What it holds | |---|---:|---| -| `reading.py` | 131 | What a field of codex32 text means, and the repair policy. No toolkit. | -| `wallet_setup.py` | 243 | Every Bitcoin Core call, including the passphrase and the relock. No toolkit. | -| `work.py` | 67 | One background operation at a time. | -| `entry.py` | 73 | The `Gtk.Entry` subclass that applies `reading.py`. | -| `pages.py` | 1225 | One function per screen: widgets and wording, no decisions. | -| `app.py` | 43 | The window. | +| `reading.py` | 154 | What a field of codex32 text means, and the repair policy. No toolkit. | +| `wallet_setup.py` | 263 | Every Bitcoin Core call, including the passphrase and the relock. No toolkit. | +| `work.py` | 65 | One background operation at a time. | +| `entry.py` | 111 | The `Gtk.Entry` subclasses that apply `reading.py`, keep a selection off the primary clipboard, and refuse pasted text. | +| `pages.py` | 1344 | One function per screen: widgets and wording, no decisions. | +| `app.py` | 40 | The window. | | `style.py` | 40 | The stylesheet, as a string. | -| `__init__.py`, `__main__.py` | 26 | Version pinning, the accessibility setting, and the entry point. | - -The first three are where review effort belongs: they are the only modules that -decide anything, they total 441 lines, and none of them imports a toolkit, so -`tests/test_gui_reading.py` and `tests/test_gui_wallet_setup.py` cover them -without a display and run in ordinary CI. `tools/gui_walkthrough.py` drives the -real widgets through every task under a throwaway X server and is the cheapest -way to see the screens without a desktop. - -The package carries its own budget of 2,000 logical lines, separate from the +| `__init__.py`, `__main__.py` | 28 | Version pinning, the accessibility setting, and the entry point. | + +The first three are where review effort belongs: they decide what text means, +what Bitcoin Core is told, and when work runs. They total 482 lines, and none of +them imports a toolkit, so `tests/test_gui_reading.py` and +`tests/test_gui_wallet_setup.py` cover them without a display and run in +ordinary CI. `entry.py` comes next: its primary-clipboard guard keeps a +selected card off the clipboard, and its paste refusal makes the read-back come +from the paper. `tests/test_gui_entry.py` and `tests/test_gui_read_back.py` +check both under a display. `tools/gui_walkthrough.py` drives the real widgets through every task +under a throwaway X server and is the cheapest way to see the screens without a +desktop. + +The package carries its own budget of 2,050 logical lines, separate from the 5,000 the installed library keeps, and `tests/test_gui_boundaries.py` enforces it. The plan proposed 1,000 before the screens were written and the budget was 1,800 before the security review of 2026-09-19; that review's remediations are about 250 lines, and the rest of the difference is user-facing wording in -`pages.py`, which is the first priority this program was built for. +`pages.py`, which is the first priority this program was built for. It was +2,000 until the card read-back rework of 2026-10-07, which added fixed +correction boxes, group alignment and paste refusal. ## Claims, and how to check each one diff --git a/src/codex32_gui/app.py b/src/codex32_gui/app.py index 07835bce..81503d95 100644 --- a/src/codex32_gui/app.py +++ b/src/codex32_gui/app.py @@ -31,11 +31,7 @@ def do_activate(self) -> None: view = Adw.NavigationView() view.push(pages.home(view)) window = Adw.ApplicationWindow( - application=self, - title="codex32", - default_width=880, - default_height=620, - content=view, + application=self, title="codex32", default_width=880, default_height=620, content=view ) window.present() diff --git a/src/codex32_gui/entry.py b/src/codex32_gui/entry.py index 093b58fc..4fa1a95b 100644 --- a/src/codex32_gui/entry.py +++ b/src/codex32_gui/entry.py @@ -1,10 +1,10 @@ -"""The one field that interprets codex32 keystrokes.""" +"""The fields that interpret codex32 keystrokes.""" from __future__ import annotations from dataclasses import replace -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 ( @@ -21,7 +21,39 @@ ) -class Codex32Entry(Gtk.Entry): +class PrivateEntry(Gtk.Entry): + """An entry whose selected text does not stay on the primary clipboard.""" + + def __init__(self, **properties: object) -> None: + super().__init__(**properties) + self._watching = 0 + self.connect("realize", lambda _entry: self._watch(True)) + self.connect("unrealize", lambda _entry: self._watch(False)) + + def _watch(self, start: bool) -> None: + # Selecting text hands it to the primary selection, where a clipboard + # manager would copy the card into a history file on disk. GTK publishes + # it again on focus and on any selection, not only when the selection + # moves, so the clipboard itself is watched while this entry is on screen + # and the copy is taken back once GTK has finished. The selection still works. + primary = self.get_primary_clipboard() + if start: + self._watching = primary.connect("changed", self._unpublish) + elif self._watching: + primary.disconnect(self._watching) + self._watching = 0 + + def _unpublish(self, primary: Gdk.Clipboard) -> None: + if primary.is_local() and primary.get_content() is not None and self.get_selection_bounds(): + GLib.idle_add(self._drop, primary) + + @staticmethod + def _drop(primary: Gdk.Clipboard) -> bool: + primary.set_content(None) + return False + + +class Codex32Entry(PrivateEntry): """A Gtk.Entry that can only ever hold grouped uppercase codex32 text.""" __gtype_name__ = "Codex32Entry" @@ -30,14 +62,12 @@ def __init__(self, *, accepted: tuple[str, ...] = (), length: int | None = None) super().__init__() self._accepted, self._length, self._pending = accepted, length, 0 self._limit = (length or TEXT_LENGTHS[-1]) + SLACK - self._dropped, self._rewriting, self._unpublishing = "", False, 0 + self._dropped, self._rewriting = "", False self.set_hexpand(True) self.add_css_class("card-entry") self.set_input_hints(Gtk.InputHints.NO_SPELLCHECK | Gtk.InputHints.NO_EMOJI) self.set_text(PREFIX) self.connect("changed", self._schedule) - self.connect("notify::selection-bound", self._unpublish) - self.connect("notify::cursor-position", self._unpublish) def prefill(self, text: str) -> None: """Offer a known header that the operator may still overtype.""" @@ -56,21 +86,6 @@ def clear(self) -> None: self._dropped = "" self.set_text(PREFIX) - def _unpublish(self, *_arguments: object) -> None: - # Selecting text in a Gtk.Entry hands it to the primary selection, where a - # clipboard manager would copy the card into a history file on disk. The - # selection still works; only the copy that leaves the program is taken - # back, once GTK has finished publishing it. - if self.get_selection_bounds() and not self._unpublishing: - self._unpublishing = GLib.idle_add(self._drop_primary) - - def _drop_primary(self) -> bool: - self._unpublishing = 0 - display = self.get_display() - if display is not None: - display.get_primary_clipboard().set_content(None) - return False - def _schedule(self, _entry: Gtk.Entry) -> None: # One edit reaches the buffer as a deletion and then an insertion. Reformat # once the whole edit has settled, so no intermediate state is rewritten, @@ -94,3 +109,38 @@ def _reformat(self) -> bool: self._rewriting = False self.set_position(kept + max(kept - 1, 0) // GROUP) return False + + +class GroupEntry(PrivateEntry): + """One group of a card being corrected in place, shown in capitals.""" + + __gtype_name__ = "GroupEntry" + + def __init__(self, text: str) -> None: + super().__init__( + text=text, width_chars=GROUP + 1, max_width_chars=GROUP + 1, max_length=GROUP + SLACK + ) + self.add_css_class("card-entry") + self.set_input_hints(Gtk.InputHints.NO_SPELLCHECK | Gtk.InputHints.NO_EMOJI) + refuse_paste(self) + self.connect("changed", lambda _entry: GLib.idle_add(self._capitals)) + + def _capitals(self) -> bool: + position, text = self.get_position(), self.get_text() + if text != text.upper(): + self.set_text(text.upper()) + self.set_position(position) + return False + + +def refuse_paste(entry: Gtk.Entry) -> None: + """Take only keystrokes, so a card is read back from the paper and not pasted from elsewhere.""" + 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) # Primary paste + 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): # Dragged-in text + text.remove_controller(controller) diff --git a/src/codex32_gui/pages.py b/src/codex32_gui/pages.py index a73de64f..3b92e284 100644 --- a/src/codex32_gui/pages.py +++ b/src/codex32_gui/pages.py @@ -29,7 +29,7 @@ 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 +from codex32_gui.entry import Codex32Entry, GroupEntry, refuse_paste from codex32_gui.wallet_setup import BitcoinCore Artifact = Share | Secret @@ -98,11 +98,7 @@ class Record: def _page( - title: str, - content: Gtk.Widget, - *, - actions: Gtk.Widget | None = None, - can_pop: bool = True, + title: str, content: Gtk.Widget, *, actions: Gtk.Widget | None = None, can_pop: bool = True ) -> Adw.NavigationPage: scroller = Gtk.ScrolledWindow(hscrollbar_policy=Gtk.PolicyType.NEVER, vexpand=True) scroller.set_child(Adw.Clamp(maximum_size=700, child=content, margin_start=18, margin_end=18)) @@ -389,9 +385,7 @@ def home(view: Adw.NavigationView) -> Adw.NavigationPage: def _connect( - view: Adw.NavigationView, - chain: str | None, - then: Callable[[BitcoinCore], Adw.NavigationPage], + view: Adw.NavigationView, chain: str | None, then: Callable[[BitcoinCore], Adw.NavigationPage] ) -> None: """Discover Bitcoin Core before any entropy is drawn or any card is read.""" page = _working(view, "Bitcoin Core", "Looking for Bitcoin Core on this computer…") @@ -412,20 +406,15 @@ def follow(found: BitcoinCore | tuple[str, ...]) -> Adw.NavigationPage: def _network_page( - view: Adw.NavigationView, - options: Sequence[str], - then: Callable[[BitcoinCore], Adw.NavigationPage], + view: Adw.NavigationView, options: Sequence[str], then: Callable[[BitcoinCore], Adw.NavigationPage] ) -> Adw.NavigationPage: group = Adw.PreferencesGroup(title="Bitcoin Core is running on more than one network") buttons = _radio_group(group, [(label, "") for label in options]) content = _column( - _title("Which network?", "Practise on signet. Use mainnet only for coins you cannot replace."), - group, + _title("Which network?", "Practise on signet. Use mainnet only for coins you cannot replace."), group ) action = _button( - "Continue", - lambda: _connect(view, options[_selected(buttons)], then), - style="suggested-action", + "Continue", lambda: _connect(view, options[_selected(buttons)], then), style="suggested-action" ) return _page("Network", content, actions=_actions(action)) @@ -487,45 +476,81 @@ def _read_back_page( ) -> Adw.NavigationPage: """Read the card back from the paper, with the original off the screen.""" field = Codex32Entry(length=len(card.text)) + refuse_paste(field) + boxes: list[Gtk.Label | GroupEntry] = [] # After a mismatch: one fixed box per group. status = _note("") + comparison = Adw.Bin() # Only what was typed is redrawn; the original stays hidden. accept = _button("Confirm card", lambda: None, style="suggested-action") + def typed() -> str: + return "".join(box.get_text() for box in boxes) if boxes else field.get_text() + def update(*_arguments: object) -> None: - state = field.reading() - accept.set_sensitive(state.complete) + if field.get_text() == reading.PREFIX: # Cleared: the redrawn copy goes too. + comparison.set_child(None) + boxes.clear() + field.set_visible(True) + state = reading.read(reading.normalize(typed()), length=len(card.text)) if boxes else field.reading() # A character a card can never carry is named, never quietly deleted: this - # is the step whose whole purpose is to catch a misread glyph. - fault = state.message if state.level == "error" else "" + # is the step whose whole purpose is to catch a misread glyph. The field has + # already dropped it, so its reading names it while the length is short. + short = state.level == "error" and len(state.text) != state.expected + lookalike = reading.lookalike_fault(typed()) or (state.message if short else "") + fault = lookalike or (state.message if state.level == "error" else "") + # Any other missing or extra character may be submitted: its group is then the one highlighted. + accept.set_sensitive(not lookalike and abs(len(state.text) - state.expected) <= reading.SLACK) counted = f"{len(state.text)} of {state.expected} characters" _say(status, fault or counted, "error" if fault else "") + def correct(pieces: tuple[tuple[str, bool], ...]) -> None: + # Each group gets a fixed box, so no character moves between groups. Only + # the wrong ones can be typed in, and Tab passes over the locked ones. + grid = Gtk.Grid(column_spacing=8, row_spacing=8, halign=Gtk.Align.CENTER) + boxes[:] = [GroupEntry(piece) if not right else Gtk.Label(label=piece) for piece, right in pieces] + for number, box in enumerate(boxes): + box.add_css_class("card-group") + if isinstance(box, GroupEntry): + box.add_css_class("guessed") + box.connect("changed", update) + box.connect("activate", lambda _box: check() if accept.get_sensitive() else None) + grid.attach(box, number % 6, number // 6, 1, 1) + comparison.set_child(grid) + field.set_visible(False) + next((box for box in boxes if isinstance(box, GroupEntry)), field).grab_focus() + def check() -> None: + entered = reading.normalize(typed()) try: - result = confirm(reading.normalize(field.get_text())) + result = confirm(entered) except CodexError as error: _failure(view, error, CARDS_SAFE) return - if not result.accepted: - groups = result.mismatched_groups - where = f"Group {min(groups)}" if groups else "What you typed" - _say(status, f"{where} does not match. Check it against your card.", "error") + if not result.accepted: # A retry keeps every box where it is and only rechecks the open ones. + groups = reading.grouped(card.text.upper()).split() + kept = ["".join(box.get_text().upper().split()) for box in boxes] # As confirm() reads them + retried = tuple((text, text == want) for text, want in zip(kept, groups)) + correct(retried or reading.aligned(card.text.upper(), entered)) + update() + _say(status, "The highlighted groups do not match. Re-read them from the card.", "error") return field.clear() after() 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." + "pen is caught now rather than years from now. Pasting is turned off." ), field, status, + comparison, _note( - "Spaces and capitals do not matter, and you may try as many times as you like. Correct only " - "the group named above; the rest stays as you typed it." + "Spaces and capitals do not matter, and you may try as many times as you like. After a " + "mismatch only the highlighted groups can be changed; Tab moves between them." ), ) page = _page( @@ -533,7 +558,7 @@ def check() -> None: content, actions=_actions(_button("Show the card again", view.pop), accept), ) - _forget_when_gone(view, page, field.clear) + _forget_when_gone(view, page, field.clear) # Clearing fires update, which drops the comparison. update() return page @@ -548,8 +573,7 @@ def _abandon(view: Adw.NavigationView, page: Adw.NavigationPage) -> None: dialog.set_response_appearance("stop", Adw.ResponseAppearance.DESTRUCTIVE) dialog.set_default_response("keep") dialog.connect( - "response", - lambda _dialog, response: view.replace([home(view)]) if response == "stop" else None, + "response", lambda _dialog, response: view.replace([home(view)]) if response == "stop" else None ) dialog.present(page) @@ -579,21 +603,15 @@ def _layout_page(view: Adw.NavigationView, core: BitcoinCore) -> Adw.NavigationP adjustment=Gtk.Adjustment(lower=2, upper=9, step_increment=1, value=2), ) total = Adw.SpinRow( - title="Cards in total", - adjustment=Gtk.Adjustment(lower=2, upper=31, step_increment=1, value=3), - ) - size = Adw.ComboRow( - title="Seed size", - model=Gtk.StringList.new([label for _length, label in SEED_SIZES]), + title="Cards in total", adjustment=Gtk.Adjustment(lower=2, upper=31, step_increment=1, value=3) ) + size = Adw.ComboRow(title="Seed size", model=Gtk.StringList.new([label for _length, label in SEED_SIZES])) # Neither number may leave the other impossible. needed.connect( - "notify::value", - lambda row, _spec: total.set_value(max(total.get_value(), row.get_value())), + "notify::value", lambda row, _spec: total.set_value(max(total.get_value(), row.get_value())) ) total.connect( - "notify::value", - lambda row, _spec: needed.set_value(min(needed.get_value(), row.get_value())), + "notify::value", lambda row, _spec: needed.set_value(min(needed.get_value(), row.get_value())) ) for row in (needed, total, size): custom.add(row) @@ -662,11 +680,7 @@ def _unshared_page(view: Adw.NavigationView, core: BitcoinCore, secret: MasterSe def _next_card( - view: Adw.NavigationView, - core: BitcoinCore, - ceremony: CreationCeremony, - position: int, - count: int, + view: Adw.NavigationView, core: BitcoinCore, ceremony: CreationCeremony, position: int, count: int ) -> None: page = _working(view, f"Card {position + 1} of {count}", "Drawing this card from the operating system…") @@ -685,11 +699,7 @@ def follow(card: Share) -> Adw.NavigationPage: def _card_confirmed( - view: Adw.NavigationView, - core: BitcoinCore, - ceremony: CreationCeremony, - position: int, - count: int, + view: Adw.NavigationView, core: BitcoinCore, ceremony: CreationCeremony, position: int, count: int ) -> None: if position + 1 < count: _next_card(view, core, ceremony, position + 1, count) @@ -929,8 +939,7 @@ def go() -> None: "Unlock", content, actions=_actions( - _button("I unlocked it myself", check), - _button("Unlock and finish", go, style="suggested-action"), + _button("I unlocked it myself", check), _button("Unlock and finish", go, style="suggested-action") ), ) _forget_when_gone(view, page, lambda: _empty(field)) diff --git a/src/codex32_gui/reading.py b/src/codex32_gui/reading.py index e8e1cf3d..70e3a80a 100644 --- a/src/codex32_gui/reading.py +++ b/src/codex32_gui/reading.py @@ -79,6 +79,32 @@ def grouped(text: str) -> str: return " ".join(text[start : start + GROUP] for start in range(0, len(text), GROUP)) +def aligned(expected: str, typed: str) -> tuple[tuple[str, bool], ...]: + """Split what was typed into the card's groups, each with whether it matches. + + A missing or extra character marks only the group it falls in, not every + group after it. The fewest wrong groups win; among those the leftmost groups + stay right, and a wrong group keeps as close to its own size as it can. + """ + wanted = [expected[start : start + GROUP] for start in range(0, len(expected), GROUP)] + one = 1 << len(wanted) + 10 # Outweighs every choice of which groups are wrong, which outweighs sizes. + best = {(len(wanted), len(typed)): (0, -len(typed))} # On a tie, the earlier group takes more. + for group, want in reversed(list(enumerate(wanted))): + miss, size = one + (one >> group + 1), len(want) + for at in range(len(typed) + 1): + options = [ + (best[group + 1, end][0] + (typed[at:end] != want) * (miss + abs(end - at - size)), -end) + for end in range(at, min(at + GROUP + SLACK, len(typed)) + 1) + if (group + 1, end) in best + ] + if options: + best[group, at] = min(options) + ends = [0] + for group in range(len(wanted)): + ends.append(-best[group, ends[-1]][1]) + return tuple((typed[at:end], typed[at:end] == want) for at, end, want in zip(ends, ends[1:], wanted)) + + def header_fault(text: str, accepted: tuple[str, ...] = ()) -> str: """Report a header that cannot belong to any card, before more is typed.""" body = text[len(PREFIX) :] diff --git a/src/codex32_gui/wallet_setup.py b/src/codex32_gui/wallet_setup.py index 11abc2c5..b17ea9a4 100644 --- a/src/codex32_gui/wallet_setup.py +++ b/src/codex32_gui/wallet_setup.py @@ -253,13 +253,7 @@ def unlock(core: BitcoinCore, name: str, passphrase: str) -> None: """ line = _passphrase(passphrase) + "\n" try: - core._rpc( - "-stdinwalletpassphrase", - "walletpassphrase", - str(UNLOCK_SECONDS), - wallet=name, - stdin=line, - ) + core._rpc("-stdinwalletpassphrase", "walletpassphrase", str(UNLOCK_SECONDS), wallet=name, stdin=line) except UnicodeEncodeError: raise BitcoinCoreError(_UNSENDABLE) from None except BitcoinCoreError as error: diff --git a/src/codex32_gui/work.py b/src/codex32_gui/work.py index 3bcfa947..29528b4e 100644 --- a/src/codex32_gui/work.py +++ b/src/codex32_gui/work.py @@ -75,9 +75,7 @@ def deliver(outcome: Result | Exception) -> bool: def _busy[Result]( - view: Adw.NavigationView, - page: Adw.NavigationPage, - done: Callable[[Result | Exception], None], + view: Adw.NavigationView, page: Adw.NavigationPage, done: Callable[[Result | Exception], None] ) -> bool: if showing(view, page): done(RuntimeError(_BUSY)) diff --git a/tests/test_gui_boundaries.py b/tests/test_gui_boundaries.py index 2687a490..3f16e541 100644 --- a/tests/test_gui_boundaries.py +++ b/tests/test_gui_boundaries.py @@ -33,7 +33,7 @@ } ) CORE_ADAPTER = "codex32._bitcoin_core" -BUDGET = 2000 +BUDGET = 2050 def _package() -> Path: diff --git a/tests/test_gui_entry.py b/tests/test_gui_entry.py new file mode 100644 index 00000000..7c93d6db --- /dev/null +++ b/tests/test_gui_entry.py @@ -0,0 +1,37 @@ +"""The codex32 field keeps a selected card off the primary clipboard.""" + +from __future__ import annotations + +import pytest + +gi = pytest.importorskip("gi") +try: + gi.require_version("Gtk", "4.0") + gi.require_version("Gdk", "4.0") + from gi.repository import Gdk, GLib, Gtk +except (ImportError, ValueError): + pytest.skip("GTK 4 is 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) + +from data.bip93_vectors import VECTOR_2 + +from codex32_gui.entry import Codex32Entry + + +def _settle() -> None: + while GLib.MainContext.default().iteration(False): + pass + + +def test_selecting_in_the_field_leaves_nothing_on_the_primary_clipboard() -> None: + field = Codex32Entry() + field.prefill(VECTOR_2["share_A"]) + window = Gtk.Window(child=field) + window.present() + _settle() + + field.select_region(0, -1) + _settle() + assert window.get_display().get_primary_clipboard().get_content() is None + window.destroy() diff --git a/tests/test_gui_read_back.py b/tests/test_gui_read_back.py new file mode 100644 index 00000000..8db4b191 --- /dev/null +++ b/tests/test_gui_read_back.py @@ -0,0 +1,205 @@ +"""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 pages._compare(SHARE, text) + + 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.prefill(text) + 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 test_enter_confirms_only_a_card_of_about_the_right_length() -> None: + _page, field, typed, finished = _read_back(ConfirmationResult(True)) + + field.prefill(SHARE[:20]) + field.emit("activate") + assert typed == [] and finished == [] + + field.prefill(SHARE) + field.emit("activate") + assert typed == [SHARE.upper()] and finished == [True] + + +def test_a_lookalike_is_named_and_must_be_fixed_before_the_card_is_compared() -> None: + page, field, typed, _finished = _read_back() + field.set_text(SHARE[:30].upper() + "B" + SHARE[31:].upper()) + _settle() + field.emit("activate") + assert typed == [] and field.get_visible() + assert any("never contains B" in label.get_label() for label in _widgets(page, Gtk.Label)) + + wrong = CARD.copy() + wrong[4] = "QQQQ" + field.prefill("".join(wrong)) + field.emit("activate") + [box] = [box for box in _boxes(page) if isinstance(box, GroupEntry)] + box.set_text("Q1QQ") + box.emit("activate") + assert len(typed) == 1 and isinstance(_boxes(page)[4], GroupEntry) + + +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] + assert any("highlighted groups do not match" in label.get_label() for label in _widgets(page, Gtk.Label)) + + +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() + assert boxes[3].get_text() == CARD[3] # Shown in capitals, like the card. + boxes[3].emit("activate") + assert finished == [True] and field.get_text() == reading.PREFIX + assert list(_widgets(page, Gtk.Grid)) == [] and field.get_visible() + + +def test_tab_moves_only_between_the_wrong_groups() -> 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() + + boxes[2].grab_focus() + page.child_focus(Gtk.DirectionType.TAB_FORWARD) + assert window.get_focus().get_parent() is boxes[9] + window.destroy() + + +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 list(_widgets(page, Gtk.Grid)) == [] and field.get_visible() + field.prefill(SHARE[:20]) + _settle() + assert reading.normalize(field.get_text()) == SHARE[:20].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] diff --git a/tests/test_gui_reading.py b/tests/test_gui_reading.py index 1641922f..5e2bc17e 100644 --- a/tests/test_gui_reading.py +++ b/tests/test_gui_reading.py @@ -5,6 +5,7 @@ from codex32_gui.reading import ( PREFIX, + aligned, expected_length, grouped, header_fault, @@ -159,3 +160,18 @@ def test_several_confusable_characters_are_all_named() -> None: assert "B or O" in fault assert "B is probably 8" in fault assert "O is probably 0" in fault + + +def test_a_slip_at_a_group_boundary_keeps_the_left_group_right() -> None: + assert aligned("ABCDEFGH", "ABCDXEFGH") == (("ABCD", True), ("XEFGH", False)) + assert aligned("ABCDDEFG", "ABCDEFG") == (("ABCD", True), ("EFG", False)) + + +def test_the_fewest_groups_are_marked_wrong() -> None: + card = "ABCDEFGHJKLMNPQR" + assert aligned(card, "ABCDFGHJKLMNXPQR") == ( + ("ABCD", True), + ("FGH", False), + ("JKLM", True), + ("NXPQR", False), + )