diff --git a/docs/developer/gui.md b/docs/developer/gui.md index c922ce7..4b5c9a9 100644 --- a/docs/developer/gui.md +++ b/docs/developer/gui.md @@ -141,6 +141,14 @@ library's. - The account number is fixed at 0, which is the command line's default. The restore screen says so, and points anyone whose wallet record shows another number at `ms32 wallet --account N`. +- The window offers no checksum completer, and does not tell the operator that + 13 or 15 trailing `?`, depending on card length, would be one. It is aimed at + someone whose seed comes from the operating system, for whom a Book worksheet + never arises, so advertising the route there would be all cost. The route is + reachable anyway, so `_guess_gate_page` speaks to a person completing new data + as well as to a person recovering a damaged card, and forbids replacing a + checksum outright. `docs/user/guide.md` documents the route for the command + line, which is where the worksheet audience already is. - Restoring asks the operator to *check* the wallet-identity fields against their record rather than copy them onto it, and shows no creation date. A restore is the only moment the program can show that the cards entered belong diff --git a/docs/user/gui.md b/docs/user/gui.md index 3566a39..31e9035 100644 --- a/docs/user/gui.md +++ b/docs/user/gui.md @@ -93,8 +93,19 @@ compare it character by character before you accept it. If too much is missing, the window stops and asks you to type `YES` in capitals first. That is not a formality: with that little checksum left, a repair can look -correct without being correct, and any earlier mistake gets locked in with -nothing left to detect it. If the funds matter, stop there and get help. +correct without being correct. The screen says so twice over, because two +different people reach it. If you are filling in the last squares of a backup you +are making by hand, it tells you to check every character against what you wrote, +since completing the squares locks any earlier mistake in for good. If you are +recovering a damaged card, it tells you the answer may simply be wrong, and that +you may have to try likely misreadings one at a time. If the funds matter, stop +there and get help. + +**Never erase a card's last characters to make it check out.** A card that fails +its check is telling you something is wrong. Replacing the ending hides that +mistake inside a result that now looks valid, and you lose the one signal that +would have found it. Type what the card actually says, `?` included, and let the +window work from that. If more than one repair fits, the window shows none of them. Check the card again. diff --git a/src/codex32_gui/pages.py b/src/codex32_gui/pages.py index b529356..9adf143 100644 --- a/src/codex32_gui/pages.py +++ b/src/codex32_gui/pages.py @@ -1132,7 +1132,7 @@ def following() -> Adw.NavigationPage: def _guess_gate_page( view: Adw.NavigationView, following: Callable[[], Adw.NavigationPage] ) -> Adw.NavigationPage: - """Invariant 5: disclose nothing about the candidate until literal YES is typed.""" + """Invariant 5: disclose nothing until YES; whole-checksum completion reaches this gate too.""" field = Gtk.Entry(placeholder_text="YES") show = _button("Show the guess", lambda: view.push(following()), style="destructive-action") show.set_sensitive(False) @@ -1140,9 +1140,23 @@ def _guess_gate_page( content = _column( _title("This repair would be a guess"), _note( - "So much of this card is unreadable that codex32 can fill in the blanks in a way that looks " - "correct without being correct. If any earlier character is also wrong, that mistake gets " - "locked in and the card becomes wrong forever, with nothing left to detect it.", + "So much of this card is unreadable that codex32 can fill in the blanks in a way that " + "looks correct without being correct.", + "warning", + ), + _note( + "If you are filling in the last squares of a backup you are making by hand, check every " + "character you have typed against what you wrote down before you go on. Nothing can " + "detect a mistake made earlier: filling in the squares locks it in for good." + ), + _note( + "If you are recovering a damaged card, the answer may simply be wrong. If it does not " + "restore your wallet, you may have to try likely misreadings one at a time." + ), + _note( + "Never erase a card's last characters to make it check out. A card that fails its check " + "is telling you something is wrong, and replacing the ending hides that mistake instead " + "of finding it.", "warning", ), _note("If the funds matter, stop here and get help instead."), diff --git a/tests/test_gui_reading.py b/tests/test_gui_reading.py index 1641922..36c56f6 100644 --- a/tests/test_gui_reading.py +++ b/tests/test_gui_reading.py @@ -1,7 +1,7 @@ """What the graphical entry field makes of typed text, without a display.""" import pytest -from data.bip93_vectors import VECTOR_2, VECTOR_3 +from data.bip93_vectors import VECTOR_2, VECTOR_3, VECTOR_5 from codex32_gui.reading import ( PREFIX, @@ -127,10 +127,11 @@ def test_a_repair_that_repeats_an_accepted_card_is_not_offered() -> None: assert isinstance(repair(SHARE_A[:-1] + "Q", 48, ("a",)), str) -def test_a_card_with_too_little_checksum_left_demands_the_warning() -> None: +@pytest.mark.parametrize(("card", "checksum_length"), ((SHARE_C, 13), (VECTOR_5["secret_s"], 15))) +def test_a_card_with_too_little_checksum_left_demands_the_warning(card: str, checksum_length: int) -> None: from codex32 import CorrectionCandidate - found = repair(SHARE_C[:-13] + "?" * 13, None) + found = repair(card[:-checksum_length] + "?" * checksum_length, None) assert isinstance(found, CorrectionCandidate) assert found.low_checksum_discrimination diff --git a/tools/gui_walkthrough.py b/tools/gui_walkthrough.py index b6b4a44..a1a7b43 100644 --- a/tools/gui_walkthrough.py +++ b/tools/gui_walkthrough.py @@ -137,6 +137,8 @@ def do_activate(self) -> None: self.candidate, self.back_to_entry, self.accepted_repair, + self.completion_entry, + self.completion_gate, self.preflight, self.network, self.letters, @@ -278,6 +280,57 @@ def accepted_repair(self) -> bool: press(page, "Done") return True + def completion_entry(self) -> bool: + """Thirteen unreadable characters at the end are a whole checksum.""" + page = self.page() + if page.get_title() != "codex32": + return False + rows(page)[3].emit("activated") + page = self.page() + field = field_of(page) + field.set_text(SHARE_C[:-13] + "?" * 13) + settle() + fix = button(page, "Suggest a repair") + check("completing a checksum is offered as a repair", fix.get_sensitive()) + fix.emit("clicked") + return True + + def completion_gate(self) -> bool: + page = self.page() + if page.get_title() != "Warning" or button(page, "Show the guess") is None: + return False + shown = labels(page) + check("nothing about the guess is disclosed yet", card(page) == "", card(page)) + check( + "the gate addresses someone completing new data", + any("making by hand" in text and "locks it in" in text for text in shown), + [text for text in shown if "hand" in text], + ) + check( + "and someone recovering a damaged card", + any("may simply be wrong" in text for text in shown), + [text for text in shown if "wrong" in text], + ) + check( + "and forbids replacing a checksum outright", + any("Never erase" in text for text in shown), + [text for text in shown if "Never" in text], + ) + show = button(page, "Show the guess") + check("the guess stays hidden until YES is typed", not show.get_sensitive()) + entry = next(item for item in walk(page) if isinstance(item, Gtk.Entry)) + for typed in ("yes", "Yes", "YES please", "Y"): + entry.set_text(typed) + settle() + check(f"{typed!r} does not open the gate", not show.get_sensitive()) + entry.set_text("YES") + settle() + check("literal YES opens it", show.get_sensitive()) + press(page, "Cancel") + settle() + self.view.replace([pages.home(self.view)]) + return True + def preflight(self) -> bool: page = self.page() if page.get_title() != "codex32":