change completion API

* InputHelper returns full words, not just suffixes. I liked the fact that
  suffixes made it impossible to violate the "all matches will start with
  your prefix" invariant, but in practice it was fiddly to work with.
* add ih.when_wordlist_is_available(), so the frontend can block (after
  claiming the nameplate) until it can return a complete wordlist to
  readline. This helps the user experience, because readline wasn't really
  built to work with completions that change over time
* make the Wordlist responsible for appending hyphens to all non-final word
  completions. InputHelper remains responsible for hyphens on nameplates.
  This makes the frontend simpler, but I may change it again in the future if
  it helps non-readline GUI frontends.
* CodeInputter: after claiming, wait for the wordlist rather than returning
  an empty list
* PGPWordList: change to match

This has the unfortunate side-effect that e.g. typing "3-yucatan-tu TAB"
shows you completions that include the entire phrase: "3-yucatan-tumor
3-yucatan-tunnel", rather than only mentioning the final word. I'd like to
fix this eventually.
This commit is contained in:
Brian Warner 2017-04-05 18:26:28 -07:00
parent 04926d0be8
commit d331c51c03
8 changed files with 163 additions and 86 deletions

View File

@ -183,41 +183,45 @@ The code-entry Helper object has the following API:
`get_nameplate_completions()` after the response will use the new list. `get_nameplate_completions()` after the response will use the new list.
Calling this after `h.choose_nameplate` will raise Calling this after `h.choose_nameplate` will raise
`AlreadyChoseNameplateError`. `AlreadyChoseNameplateError`.
* `completions = h.get_nameplate_completions(prefix)`: returns * `matches = h.get_nameplate_completions(prefix)`: returns (synchronously) a
(synchronously) a set of suffixes for the given nameplate prefix. For set of completions for the given nameplate prefix, along with the hyphen
example, if the server reports nameplates 1, 12, 13, 24, and 170 are in that always follows the nameplate (and separates the nameplate from the
use, `get_nameplate_completions("1")` will return `{"", "2", "3", "70"}`. rest of the code). For example, if the server reports nameplates 1, 12, 13,
You may want to sort these before displaying them to the user. Raises 24, and 170 are in use, `get_nameplate_completions("1")` will return
`AlreadyChoseNameplateError` if called after `h.choose_nameplate`. `{"1-", "12-", "13-", "170-"}`. You may want to sort these before
displaying them to the user. Raises `AlreadyChoseNameplateError` if called
after `h.choose_nameplate`.
* `h.choose_nameplate(nameplate)`: accepts a string with the chosen * `h.choose_nameplate(nameplate)`: accepts a string with the chosen
nameplate. May only be called once, after which nameplate. May only be called once, after which
`AlreadyChoseNameplateError` is raised. (in this future, this might `AlreadyChoseNameplateError` is raised. (in this future, this might
return a Deferred that fires (with None) when the nameplate's wordlist is return a Deferred that fires (with None) when the nameplate's wordlist is
known (which happens after the nameplate is claimed, requiring a roundtrip known (which happens after the nameplate is claimed, requiring a roundtrip
to the server)). to the server)).
* `completions = h.get_word_completions(prefix)`: return (synchronously) a * `d = h.when_wordlist_is_available()`: return a Deferred that fires (with
set of suffixes for the given words prefix. The possible completions depend None) when the wordlist is known. This can be used to block a readline
upon the wordlist in use for the previously-claimed nameplate, so calling frontend which has just called `h.choose_nameplate()` until the resulting
this before `choose_nameplate` will raise `MustChooseNameplateFirstError`. wordlist is known, which can improve the tab-completion behavior.
* `matches = h.get_word_completions(prefix)`: return (synchronously) a set of
completions for the given words prefix. This will include a trailing hyphen
if more words are expected. The possible completions depend upon the
wordlist in use for the previously-claimed nameplate, so calling this
before `choose_nameplate` will raise `MustChooseNameplateFirstError`.
Calling this after `h.choose_words()` will raise `AlreadyChoseWordsError`. Calling this after `h.choose_words()` will raise `AlreadyChoseWordsError`.
Given a prefix like "su", this returns a set of strings which are Given a prefix like "su", this returns a set of strings which are potential
appropriate to append to the prefix (e.g. `{"pportive", "rrender", matches (e.g. `{"supportive-", "surrender-", "suspicious-"}`. The prefix
"spicious"}`, for expansion into "supportive", "surrender", and should not include the nameplate, but *should* include whatever words and
"suspicious". The prefix should not include the nameplate, but *should* hyphens have been typed so far (the default wordlist uses alternate lists,
include whatever words and hyphens have been typed so far (the default where even numbered words have three syllables, and odd numbered words have
wordlist uses alternate lists, where even numbered words have three two, so the completions depend upon how many words are present, not just
syllables, and odd numbered words have two, so the completions depend upon the partial last word). E.g. `get_word_completions("pr")` will return
how many words are present, not just the partial last word). E.g. `{"processor-", "provincial-", "proximate-"}`, while
`get_word_completions("pr")` will return `{"ocessor", "ovincial", `get_word_completions("opulent-pr")` will return `{"opulent-preclude",
"oximate"}`, while `get_word_completions("opulent-pr")` will return "opulent-prefer", "opulent-preshrunk", "opulent-printer",
`{"eclude", "efer", "eshrunk", "inter", "owler"}`. If the wordlist is not "opulent-prowler"}` (note the lack of a trailing hyphen, because the
yet known, this returns an empty set. It will include an empty string in wordlist is expecting a code of length two). If the wordlist is not yet
the returned set if the prefix is complete (the last word is an exact match known, this returns an empty set. All return values will
for something in the completion list), but will include additional strings `.startwith(prefix)`. The frontend is responsible for sorting the results
if the completion list includes extensions of the last word. The before display.
completions will never include a hyphen: the UI frontend must supply these
if desired. The frontend is also responsible for sorting the results before
display.
* `h.choose_words(words)`: call this when the user is finished typing in the * `h.choose_words(words)`: call this when the user is finished typing in the
code. It does not return anything, but will cause the Wormhole's code. It does not return anything, but will cause the Wormhole's
`w.when_code()` (or corresponding delegate) to fire, and triggers the `w.when_code()` (or corresponding delegate) to fire, and triggers the

View File

@ -2,6 +2,7 @@ from __future__ import print_function, absolute_import, unicode_literals
from zope.interface import implementer from zope.interface import implementer
from attr import attrs, attrib from attr import attrs, attrib
from attr.validators import provides from attr.validators import provides
from twisted.internet import defer
from automat import MethodicalMachine from automat import MethodicalMachine
from . import _interfaces, errors from . import _interfaces, errors
@ -19,11 +20,19 @@ class Input(object):
self._all_nameplates = set() self._all_nameplates = set()
self._nameplate = None self._nameplate = None
self._wordlist = None self._wordlist = None
self._wordlist_waiters = []
def wire(self, code, lister): def wire(self, code, lister):
self._C = _interfaces.ICode(code) self._C = _interfaces.ICode(code)
self._L = _interfaces.ILister(lister) self._L = _interfaces.ILister(lister)
def when_wordlist_is_available(self):
if self._wordlist:
return defer.succeed(None)
d = defer.Deferred()
self._wordlist_waiters.append(d)
return d
@m.state(initial=True) @m.state(initial=True)
def S0_idle(self): pass # pragma: no cover def S0_idle(self): pass # pragma: no cover
@m.state() @m.state()
@ -72,11 +81,12 @@ class Input(object):
self._all_nameplates = all_nameplates self._all_nameplates = all_nameplates
@m.output() @m.output()
def _get_nameplate_completions(self, prefix): def _get_nameplate_completions(self, prefix):
lp = len(prefix)
completions = set() completions = set()
for nameplate in self._all_nameplates: for nameplate in self._all_nameplates:
if nameplate.startswith(prefix): if nameplate.startswith(prefix):
completions.add(nameplate[lp:]) # TODO: it's a little weird that Input is responsible for the
# hyphen on nameplates, but WordList owns it for words
completions.add(nameplate+"-")
return completions return completions
@m.output() @m.output()
def record_all_nameplates(self, nameplate): def record_all_nameplates(self, nameplate):
@ -87,6 +97,11 @@ class Input(object):
from ._rlcompleter import debug from ._rlcompleter import debug
debug(" -record_wordlist") debug(" -record_wordlist")
self._wordlist = wordlist self._wordlist = wordlist
@m.output()
def notify_wordlist_waiters(self, wordlist):
while self._wordlist_waiters:
d = self._wordlist_waiters.pop()
d.callback(None)
@m.output() @m.output()
def no_word_completions(self, prefix): def no_word_completions(self, prefix):
@ -128,7 +143,8 @@ class Input(object):
# wormholes that don't use input_code (i.e. they use allocate_code or # wormholes that don't use input_code (i.e. they use allocate_code or
# generate_code) will never start() us, but Nameplate will give us a # generate_code) will never start() us, but Nameplate will give us a
# wordlist anyways (as soon as the nameplate is claimed), so handle it. # wordlist anyways (as soon as the nameplate is claimed), so handle it.
S0_idle.upon(got_wordlist, enter=S0_idle, outputs=[record_wordlist]) S0_idle.upon(got_wordlist, enter=S0_idle, outputs=[record_wordlist,
notify_wordlist_waiters])
S1_typing_nameplate.upon(got_nameplates, enter=S1_typing_nameplate, S1_typing_nameplate.upon(got_nameplates, enter=S1_typing_nameplate,
outputs=[record_nameplates]) outputs=[record_nameplates])
# but wormholes that *do* use input_code should not get got_wordlist # but wormholes that *do* use input_code should not get got_wordlist
@ -152,7 +168,8 @@ class Input(object):
enter=S2_typing_code_no_wordlist, outputs=[]) enter=S2_typing_code_no_wordlist, outputs=[])
S2_typing_code_no_wordlist.upon(got_wordlist, S2_typing_code_no_wordlist.upon(got_wordlist,
enter=S3_typing_code_yes_wordlist, enter=S3_typing_code_yes_wordlist,
outputs=[record_wordlist]) outputs=[record_wordlist,
notify_wordlist_waiters])
S2_typing_code_no_wordlist.upon(refresh_nameplates, S2_typing_code_no_wordlist.upon(refresh_nameplates,
enter=S2_typing_code_no_wordlist, enter=S2_typing_code_no_wordlist,
outputs=[raise_already_chose_nameplate1]) outputs=[raise_already_chose_nameplate1])
@ -215,6 +232,8 @@ class Helper(object):
return self._input.get_nameplate_completions(prefix) return self._input.get_nameplate_completions(prefix)
def choose_nameplate(self, nameplate): def choose_nameplate(self, nameplate):
self._input.choose_nameplate(nameplate) self._input.choose_nameplate(nameplate)
def when_wordlist_is_available(self):
return self._input.when_wordlist_is_available()
def get_word_completions(self, prefix): def get_word_completions(self, prefix):
return self._input.get_word_completions(prefix) return self._input.get_word_completions(prefix)
def choose_words(self, words): def choose_words(self, words):

View File

@ -9,6 +9,7 @@ from six.moves import input
from attr import attrs, attrib from attr import attrs, attrib
from twisted.internet.defer import inlineCallbacks, returnValue from twisted.internet.defer import inlineCallbacks, returnValue
from twisted.internet.threads import deferToThread, blockingCallFromThread from twisted.internet.threads import deferToThread, blockingCallFromThread
from .errors import KeyFormatError, AlreadyInputNameplateError
errf = None errf = None
if 0: if 0:
@ -68,40 +69,61 @@ class CodeInputter(object):
nameplate = text # partial nameplate = text # partial
# 'text' is one of these categories: # 'text' is one of these categories:
# "": complete on nameplates # "" or "12": complete on nameplates (all that match, maybe just one)
# "12" (multiple matches): complete on nameplates
# "123" (single match): return "123-" (no commit, no refresh) # "123-": if we haven't already committed to a nameplate, commit and
# nope: need to let readline add letters # wait for the wordlist. Then (either way) return the whole wordlist.
# so: return "123-"
# "123-": commit to nameplate (if not already), complete on words # "123-supp": if we haven't already committed to a nameplate, commit
# and wait for the wordlist. Then (either way) return all current
# matches.
if self._committed_nameplate: if self._committed_nameplate:
if not got_nameplate or nameplate != self._committed_nameplate: if not got_nameplate or nameplate != self._committed_nameplate:
# they deleted past the committment point: we can't use # they deleted past the committment point: we can't use
# this. For now, bail, but in the future let's find a # this. For now, bail, but in the future let's find a
# gentler way to encourage them to not do that. # gentler way to encourage them to not do that.
raise ValueError("nameplate (NN-) already entered, cannot go back") raise AlreadyInputNameplateError("nameplate (%s-) already entered, cannot go back" % self._committed_nameplate)
if not got_nameplate: if not got_nameplate:
# we're completing on nameplates: "" or "12" or "123" # we're completing on nameplates: "" or "12" or "123"
self.bcft(ih.refresh_nameplates) # results arrive later self.bcft(ih.refresh_nameplates) # results arrive later
debug(" getting nameplates") debug(" getting nameplates")
completions = self.bcft(ih.get_nameplate_completions, nameplate) completions = self.bcft(ih.get_nameplate_completions, nameplate)
else: # "123-" else: # "123-" or "123-supp"
# time to commit to this nameplate, if they haven't already # time to commit to this nameplate, if they haven't already
if not self._committed_nameplate: if not self._committed_nameplate:
debug(" choose_nameplate(%s)" % nameplate) debug(" choose_nameplate(%s)" % nameplate)
self.bcft(ih.choose_nameplate, nameplate) self.bcft(ih.choose_nameplate, nameplate)
self._committed_nameplate = nameplate self._committed_nameplate = nameplate
# Now we want to wait for the wordlist to be available. If
# the user just typed "12-supp TAB", we'll claim "12" but
# will need a server roundtrip to discover that "supportive"
# is the only match. If we don't block, we'd return an empty
# wordlist to readline (which will beep and show no
# completions). *Then* when the user hits TAB again a moment
# later (after the wordlist has arrived, but the user hasn't
# modified the input line since the previous empty response),
# readline would show one match but not complete anything.
# In general we want to avoid returning empty lists to
# readline. If the user hits TAB when typing in the nameplate
# (before the sender has established one, or before we're
# heard about it from the server), it can't be helped. But
# for the rest of the code, a simple wait-for-wordlist will
# improve the user experience.
self.bcft(ih.when_wordlist_is_available) # blocks on CLAIM
# and we're completing on words now # and we're completing on words now
debug(" getting words") debug(" getting words")
completions = self.bcft(ih.get_word_completions, words) completions = [nameplate+"-"+c
for c in self.bcft(ih.get_word_completions, words)]
# rlcompleter wants full strings # rlcompleter wants full strings
return sorted([text+c for c in completions]) return sorted(completions)
def finish(self, text): def finish(self, text):
if "-" not in text: if "-" not in text:
raise ValueError("incomplete wormhole code") raise KeyFormatError("incomplete wormhole code")
nameplate, words = text.split("-", 1) nameplate, words = text.split("-", 1)
if self._committed_nameplate: if self._committed_nameplate:
@ -109,7 +131,7 @@ class CodeInputter(object):
# they deleted past the committment point: we can't use # they deleted past the committment point: we can't use
# this. For now, bail, but in the future let's find a # this. For now, bail, but in the future let's find a
# gentler way to encourage them to not do that. # gentler way to encourage them to not do that.
raise ValueError("nameplate (NN-) already entered, cannot go back") raise AlreadyInputNameplateError("nameplate (%s-) already entered, cannot go back" % self._committed_nameplate)
else: else:
debug(" choose_nameplate(%s)" % nameplate) debug(" choose_nameplate(%s)" % nameplate)
self._input_helper.choose_nameplate(nameplate) self._input_helper.choose_nameplate(nameplate)

View File

@ -172,7 +172,7 @@ class PGPWordList(object):
completions = set() completions = set()
for word in words: for word in words:
if word.startswith(last_partial_word): if word.startswith(last_partial_word):
suffix = word[lp:] suffix = prefix[:-lp] + word
# append a hyphen if we expect more words # append a hyphen if we expect more words
if count+1 < num_words: if count+1 < num_words:
suffix += "-" suffix += "-"

View File

@ -31,9 +31,10 @@ class WrongPasswordError(WormholeError):
class KeyFormatError(WormholeError): class KeyFormatError(WormholeError):
""" """
The key you entered contains spaces. Magic-wormhole expects keys to be The key you entered contains spaces or was missing a dash. Magic-wormhole
separated by dashes. Please reenter the key you were given separating the expects the numerical nameplate and the code words to be separated by
words with dashes. dashes. Please reenter the key you were given separating the words with
dashes.
""" """
class ReflectionAttack(WormholeError): class ReflectionAttack(WormholeError):
@ -67,6 +68,9 @@ class AlreadyChoseNameplateError(WormholeError):
class AlreadyChoseWordsError(WormholeError): class AlreadyChoseWordsError(WormholeError):
"""The InputHelper was asked to do get_word_completions() after """The InputHelper was asked to do get_word_completions() after
choose_words() was called, or choose_words() was called a second time.""" choose_words() was called, or choose_words() was called a second time."""
class AlreadyInputNameplateError(WormholeError):
"""The CodeInputter was asked to do completion on a nameplate, when we
had already committed to a different one."""
class WormholeClosed(Exception): class WormholeClosed(Exception):
"""Deferred-returning API calls errback with WormholeClosed if the """Deferred-returning API calls errback with WormholeClosed if the
wormhole was already closed, or if it closes before a real result can be wormhole was already closed, or if it closes before a real result can be

View File

@ -336,19 +336,22 @@ class Input(unittest.TestCase):
self.assertIsInstance(helper, _input.Helper) self.assertIsInstance(helper, _input.Helper)
self.assertEqual(events, [("l.refresh",)]) self.assertEqual(events, [("l.refresh",)])
events[:] = [] events[:] = []
d = helper.when_wordlist_is_available()
self.assertNoResult(d)
helper.refresh_nameplates() helper.refresh_nameplates()
self.assertEqual(events, [("l.refresh",)]) self.assertEqual(events, [("l.refresh",)])
events[:] = [] events[:] = []
with self.assertRaises(errors.MustChooseNameplateFirstError): with self.assertRaises(errors.MustChooseNameplateFirstError):
helper.get_word_completions("prefix") helper.get_word_completions("prefix")
i.got_nameplates({"1", "12", "34", "35", "367"}) i.got_nameplates({"1", "12", "34", "35", "367"})
self.assertNoResult(d)
self.assertEqual(helper.get_nameplate_completions(""), self.assertEqual(helper.get_nameplate_completions(""),
{"1", "12", "34", "35", "367"}) {"1-", "12-", "34-", "35-", "367-"})
self.assertEqual(helper.get_nameplate_completions("1"), self.assertEqual(helper.get_nameplate_completions("1"),
{"", "2"}) {"1-", "12-"})
self.assertEqual(helper.get_nameplate_completions("2"), set()) self.assertEqual(helper.get_nameplate_completions("2"), set())
self.assertEqual(helper.get_nameplate_completions("3"), self.assertEqual(helper.get_nameplate_completions("3"),
{"4", "5", "67"}) {"34-", "35-", "367-"})
helper.choose_nameplate("34") helper.choose_nameplate("34")
with self.assertRaises(errors.AlreadyChoseNameplateError): with self.assertRaises(errors.AlreadyChoseNameplateError):
helper.refresh_nameplates() helper.refresh_nameplates()
@ -357,10 +360,16 @@ class Input(unittest.TestCase):
self.assertEqual(events, [("c.got_nameplate", "34")]) self.assertEqual(events, [("c.got_nameplate", "34")])
events[:] = [] events[:] = []
# no wordlist yet # no wordlist yet
self.assertNoResult(d)
self.assertEqual(helper.get_word_completions(""), set()) self.assertEqual(helper.get_word_completions(""), set())
wl = FakeWordList() wl = FakeWordList()
i.got_wordlist(wl) i.got_wordlist(wl)
wl._completions = {"bc", "bcd", "e"} self.assertEqual(self.successResultOf(d), None)
# a new Deferred should fire right away
d = helper.when_wordlist_is_available()
self.assertEqual(self.successResultOf(d), None)
wl._completions = {"abc-", "abcd-", "ae-"}
self.assertEqual(helper.get_word_completions("a"), wl._completions) self.assertEqual(helper.get_word_completions("a"), wl._completions)
self.assertEqual(wl._get_completions_prefix, "a") self.assertEqual(wl._get_completions_prefix, "a")
with self.assertRaises(errors.AlreadyChoseNameplateError): with self.assertRaises(errors.AlreadyChoseNameplateError):

View File

@ -8,6 +8,7 @@ from twisted.internet.threads import deferToThread
from .._rlcompleter import (input_with_completion, from .._rlcompleter import (input_with_completion,
_input_code_with_completion, _input_code_with_completion,
CodeInputter, warn_readline) CodeInputter, warn_readline)
from ..errors import KeyFormatError, AlreadyInputNameplateError
APPID = "appid" APPID = "appid"
class Input(unittest.TestCase): class Input(unittest.TestCase):
@ -231,7 +232,7 @@ class Completion(unittest.TestCase):
c = CodeInputter(helper, reactor) c = CodeInputter(helper, reactor)
cabc = c._commit_and_build_completions cabc = c._commit_and_build_completions
# in this test, we pretend that nameplates 1 and 12 are active. # in this test, we pretend that nameplates 1,12,34 are active.
# 43 TAB -> nothing (and refresh_nameplates) # 43 TAB -> nothing (and refresh_nameplates)
gnc.configure_mock(return_value=[]) gnc.configure_mock(return_value=[])
@ -243,50 +244,64 @@ class Completion(unittest.TestCase):
rn.reset_mock() rn.reset_mock()
gnc.reset_mock() gnc.reset_mock()
# 1 TAB -> 1, 12 (and refresh_nameplates) # 1 TAB -> 1-, 12- (and refresh_nameplates)
gnc.configure_mock(return_value=["", "2"]) gnc.configure_mock(return_value=["1-", "12-"])
matches = yield deferToThread(cabc, "1") matches = yield deferToThread(cabc, "1")
self.assertEqual(matches, ["1", "12"]) self.assertEqual(matches, ["1-", "12-"])
self.assertEqual(rn.mock_calls, [mock.call()]) self.assertEqual(rn.mock_calls, [mock.call()])
self.assertEqual(gnc.mock_calls, [mock.call("1")]) self.assertEqual(gnc.mock_calls, [mock.call("1")])
self.assertEqual(cn.mock_calls, []) self.assertEqual(cn.mock_calls, [])
rn.reset_mock() rn.reset_mock()
gnc.reset_mock() gnc.reset_mock()
# current: 12 TAB -> (and refresh_nameplates) # 12 TAB -> 12- (and refresh_nameplates)
# want: 12 TAB -> 12- (and choose_nameplate) # I wouldn't mind if it didn't refresh the nameplates here, but meh
gnc.configure_mock(return_value=[""]) gnc.configure_mock(return_value=["12-"])
matches = yield deferToThread(cabc, "12") matches = yield deferToThread(cabc, "12")
self.assertEqual(matches, ["12"]) # 12- self.assertEqual(matches, ["12-"])
self.assertEqual(rn.mock_calls, [mock.call()]) self.assertEqual(rn.mock_calls, [mock.call()])
self.assertEqual(gnc.mock_calls, [mock.call("12")]) self.assertEqual(gnc.mock_calls, [mock.call("12")])
self.assertEqual(cn.mock_calls, []) # 12 self.assertEqual(cn.mock_calls, [])
rn.reset_mock() rn.reset_mock()
gnc.reset_mock() gnc.reset_mock()
# current: 12-a TAB -> and ark aaah!zombies!! (and choose nameplate) # 12- TAB -> 12- {all words} (claim, no refresh)
gnc.configure_mock(side_effect=ValueError) gnc.configure_mock(return_value=["12-"])
gwc.configure_mock(return_value=["nd", "rk", "aah!zombies!!"]) gwc.configure_mock(return_value=["and-", "ark-", "aaah!zombies!!-"])
matches = yield deferToThread(cabc, "12-a") matches = yield deferToThread(cabc, "12-")
# matches are always sorted self.assertEqual(matches, ["12-aaah!zombies!!-", "12-and-", "12-ark-"])
self.assertEqual(matches, ["12-aaah!zombies!!", "12-and", "12-ark"])
self.assertEqual(rn.mock_calls, []) self.assertEqual(rn.mock_calls, [])
self.assertEqual(gnc.mock_calls, []) self.assertEqual(gnc.mock_calls, [])
self.assertEqual(cn.mock_calls, [mock.call("12")]) self.assertEqual(cn.mock_calls, [mock.call("12")])
self.assertEqual(gwc.mock_calls, [mock.call("a")]) self.assertEqual(gwc.mock_calls, [mock.call("")])
cn.reset_mock() cn.reset_mock()
gwc.reset_mock() gwc.reset_mock()
# current: 12-and-b TAB -> bat bet but # TODO: another path with "3 TAB" then "34-an TAB", so the claim
# happens in the second call (and it waits for the wordlist)
# 12-a TAB -> 12-and- 12-ark- 12-aaah!zombies!!-
gnc.configure_mock(side_effect=ValueError) gnc.configure_mock(side_effect=ValueError)
gwc.configure_mock(return_value=["at", "et", "ut"]) gwc.configure_mock(return_value=["and-", "ark-", "aaah!zombies!!-"])
matches = yield deferToThread(cabc, "12-a")
# matches are always sorted
self.assertEqual(matches, ["12-aaah!zombies!!-", "12-and-", "12-ark-"])
self.assertEqual(rn.mock_calls, [])
self.assertEqual(gnc.mock_calls, [])
self.assertEqual(cn.mock_calls, [])
self.assertEqual(gwc.mock_calls, [mock.call("a")])
gwc.reset_mock()
# 12-and-b TAB -> 12-and-bat 12-and-bet 12-and-but
gnc.configure_mock(side_effect=ValueError)
# wordlist knows the code length, so doesn't add hyphens to these
gwc.configure_mock(return_value=["and-bat", "and-bet", "and-but"])
matches = yield deferToThread(cabc, "12-and-b") matches = yield deferToThread(cabc, "12-and-b")
self.assertEqual(matches, ["12-and-bat", "12-and-bet", "12-and-but"]) self.assertEqual(matches, ["12-and-bat", "12-and-bet", "12-and-but"])
self.assertEqual(rn.mock_calls, []) self.assertEqual(rn.mock_calls, [])
self.assertEqual(gnc.mock_calls, []) self.assertEqual(gnc.mock_calls, [])
self.assertEqual(cn.mock_calls, []) self.assertEqual(cn.mock_calls, [])
self.assertEqual(gwc.mock_calls, [mock.call("and-b")]) self.assertEqual(gwc.mock_calls, [mock.call("and-b")])
cn.reset_mock()
gwc.reset_mock() gwc.reset_mock()
c.finish("12-and-bat") c.finish("12-and-bat")
@ -295,7 +310,7 @@ class Completion(unittest.TestCase):
def test_incomplete_code(self): def test_incomplete_code(self):
helper = mock.Mock() helper = mock.Mock()
c = CodeInputter(helper, "reactor") c = CodeInputter(helper, "reactor")
with self.assertRaises(ValueError) as e: with self.assertRaises(KeyFormatError) as e:
c.finish("1") c.finish("1")
self.assertEqual(str(e.exception), "incomplete wormhole code") self.assertEqual(str(e.exception), "incomplete wormhole code")
@ -303,38 +318,40 @@ class Completion(unittest.TestCase):
def test_rollback_nameplate_during_completion(self): def test_rollback_nameplate_during_completion(self):
helper = mock.Mock() helper = mock.Mock()
gwc = helper.get_word_completions = mock.Mock() gwc = helper.get_word_completions = mock.Mock()
gwc.configure_mock(return_value=["de", "urt"]) gwc.configure_mock(return_value=["code", "court"])
c = CodeInputter(helper, reactor) c = CodeInputter(helper, reactor)
cabc = c._commit_and_build_completions cabc = c._commit_and_build_completions
matches = yield deferToThread(cabc, "1-co") # this commits us to 1- matches = yield deferToThread(cabc, "1-co") # this commits us to 1-
self.assertEqual(helper.mock_calls, self.assertEqual(helper.mock_calls,
[mock.call.choose_nameplate("1"), [mock.call.choose_nameplate("1"),
mock.call.when_wordlist_is_available(),
mock.call.get_word_completions("co")]) mock.call.get_word_completions("co")])
self.assertEqual(matches, ["1-code", "1-court"]) self.assertEqual(matches, ["1-code", "1-court"])
helper.reset_mock() helper.reset_mock()
with self.assertRaises(ValueError) as e: with self.assertRaises(AlreadyInputNameplateError) as e:
yield deferToThread(cabc, "2-co") yield deferToThread(cabc, "2-co")
self.assertEqual(str(e.exception), self.assertEqual(str(e.exception),
"nameplate (NN-) already entered, cannot go back") "nameplate (1-) already entered, cannot go back")
self.assertEqual(helper.mock_calls, []) self.assertEqual(helper.mock_calls, [])
@inlineCallbacks @inlineCallbacks
def test_rollback_nameplate_during_finish(self): def test_rollback_nameplate_during_finish(self):
helper = mock.Mock() helper = mock.Mock()
gwc = helper.get_word_completions = mock.Mock() gwc = helper.get_word_completions = mock.Mock()
gwc.configure_mock(return_value=["de", "urt"]) gwc.configure_mock(return_value=["code", "court"])
c = CodeInputter(helper, reactor) c = CodeInputter(helper, reactor)
cabc = c._commit_and_build_completions cabc = c._commit_and_build_completions
matches = yield deferToThread(cabc, "1-co") # this commits us to 1- matches = yield deferToThread(cabc, "1-co") # this commits us to 1-
self.assertEqual(helper.mock_calls, self.assertEqual(helper.mock_calls,
[mock.call.choose_nameplate("1"), [mock.call.choose_nameplate("1"),
mock.call.when_wordlist_is_available(),
mock.call.get_word_completions("co")]) mock.call.get_word_completions("co")])
self.assertEqual(matches, ["1-code", "1-court"]) self.assertEqual(matches, ["1-code", "1-court"])
helper.reset_mock() helper.reset_mock()
with self.assertRaises(ValueError) as e: with self.assertRaises(AlreadyInputNameplateError) as e:
c.finish("2-code") c.finish("2-code")
self.assertEqual(str(e.exception), self.assertEqual(str(e.exception),
"nameplate (NN-) already entered, cannot go back") "nameplate (1-) already entered, cannot go back")
self.assertEqual(helper.mock_calls, []) self.assertEqual(helper.mock_calls, [])
@mock.patch("wormhole._rlcompleter.stderr") @mock.patch("wormhole._rlcompleter.stderr")

View File

@ -7,16 +7,18 @@ class Completions(unittest.TestCase):
def test_completions(self): def test_completions(self):
wl = PGPWordList() wl = PGPWordList()
gc = wl.get_completions gc = wl.get_completions
self.assertEqual(gc("ar", 2), {"mistice-", "ticle-"}) self.assertEqual(gc("ar", 2), {"armistice-", "article-"})
self.assertEqual(gc("armis", 2), {"tice-"}) self.assertEqual(gc("armis", 2), {"armistice-"})
self.assertEqual(gc("armistice", 2), {"-"}) self.assertEqual(gc("armistice", 2), {"armistice-"})
self.assertEqual(gc("armistice-ba", 2), self.assertEqual(gc("armistice-ba", 2),
{"boon", "ckfield", "ckward", "njo"}) {"armistice-baboon", "armistice-backfield",
"armistice-backward", "armistice-banjo"})
self.assertEqual(gc("armistice-ba", 3), self.assertEqual(gc("armistice-ba", 3),
{"boon-", "ckfield-", "ckward-", "njo-"}) {"armistice-baboon-", "armistice-backfield-",
self.assertEqual(gc("armistice-baboon", 2), {""}) "armistice-backward-", "armistice-banjo-"})
self.assertEqual(gc("armistice-baboon", 3), {"-"}) self.assertEqual(gc("armistice-baboon", 2), {"armistice-baboon"})
self.assertEqual(gc("armistice-baboon", 4), {"-"}) self.assertEqual(gc("armistice-baboon", 3), {"armistice-baboon-"})
self.assertEqual(gc("armistice-baboon", 4), {"armistice-baboon-"})
class Choose(unittest.TestCase): class Choose(unittest.TestCase):
def test_choose_words(self): def test_choose_words(self):